diff --git a/packages/angular/build/src/builders/dev-server/schema.json b/packages/angular/build/src/builders/dev-server/schema.json index 667bde5692da..5d4cc971a1fc 100644 --- a/packages/angular/build/src/builders/dev-server/schema.json +++ b/packages/angular/build/src/builders/dev-server/schema.json @@ -48,6 +48,7 @@ } }, { + "const": true, "type": "boolean", "description": "Indicates that all hosts are allowed. This is not recommended and a security risk." } diff --git a/packages/angular/cli/src/command-builder/utilities/json-schema.ts b/packages/angular/cli/src/command-builder/utilities/json-schema.ts index 78218465cf02..0cf3bf617a07 100644 --- a/packages/angular/cli/src/command-builder/utilities/json-schema.ts +++ b/packages/angular/cli/src/command-builder/utilities/json-schema.ts @@ -50,6 +50,11 @@ export interface Option extends YargsOptions { * Type of the values in a key/value pair field. */ itemValueType?: 'string'; + + /** + * The supported types of the option. + */ + types?: ReadonlyArray<'string' | 'number' | 'boolean' | 'array' | 'object'>; } /** @@ -82,6 +87,41 @@ function checkStringMap(keyValuePairOptions: Set, args: Arguments): bool return true; } +/** + * A Yargs coerce function that converts empty or boolean-like values to a boolean + * when an option supports both `boolean` and another type (such as `array` or `string`). + * @param value The parsed argument value. + * @returns A boolean if the value represents a boolean flag, otherwise the original value. + */ +function coerceToBoolean(value: unknown): unknown { + if (typeof value === 'string') { + if (value === '' || value === 'true') { + return true; + } + + if (value === 'false') { + return false; + } + } else if (Array.isArray(value)) { + if (value.length === 0) { + return true; + } + + if (value.length === 1) { + const firstValue = value[0]; + if (typeof firstValue === 'boolean') { + return firstValue; + } + + if (firstValue === 'true' || firstValue === 'false') { + return firstValue === 'true'; + } + } + } + + return value; +} + /** * A Yargs coerce function that converts an array of `key=value` strings to an object. * @param value An array of `key=value` strings. @@ -177,6 +217,34 @@ function isSupportedArrayItemSchema(schema: json.JsonObject): boolean { return false; } +/** + * Checks if a JSON schema node represents a supported array schema, + * either directly or within `oneOf`/`anyOf` subschemas. + * @param schema The JSON schema node to check. + * @returns `true` if the schema represents a supported array, otherwise `false`. + */ +function isSupportedArraySchema(schema: json.JsonObject): boolean { + if (isJsonObject(schema.items) && isSupportedArrayItemSchema(schema.items)) { + return true; + } + + if ( + json.isJsonArray(schema.oneOf) && + schema.oneOf.some((item) => isJsonObject(item) && isSupportedArraySchema(item)) + ) { + return true; + } + + if ( + json.isJsonArray(schema.anyOf) && + schema.anyOf.some((item) => isJsonObject(item) && isSupportedArraySchema(item)) + ) { + return true; + } + + return false; +} + /** * Gets the supported types for a JSON schema node. * @param current The JSON schema node to get the supported types for. @@ -198,7 +266,7 @@ function getSupportedTypes( case 'string': return true; case 'array': - return isJsonObject(current.items) && isSupportedArrayItemSchema(current.items); + return isSupportedArraySchema(current); case 'object': return isStringMap(current); default: @@ -372,6 +440,7 @@ export async function parseJsonSchemaToOptions( typeof current['x-user-analytics'] === 'string' ? current['x-user-analytics'] : undefined, deprecated: xDeprecated === true || typeof xDeprecated === 'string' ? xDeprecated : undefined, positional, + types, ...(type === 'object' ? { type: 'array', @@ -424,6 +493,7 @@ export function addSchemaOptionsToCommand( alias, userAnalytics, type, + types, itemValueType, hidden, name, @@ -442,13 +512,20 @@ export function addSchemaOptionsToCommand( keyValuePairOptions.add(dashedName); } + let coerce: YargsOptions['coerce']; + if (itemValueType) { + coerce = coerceToStringMap; + } else if (type !== 'boolean' && types?.includes('boolean')) { + coerce = coerceToBoolean; + } + const sharedOptions: YargsOptions & PositionalOptions = { alias, hidden, description, deprecated, choices, - coerce: itemValueType ? coerceToStringMap : undefined, + coerce, // This should only be done when `--help` is used otherwise default will override options set in angular.json. ...(includeDefaultValues ? { default: defaultVal } : {}), }; diff --git a/packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts b/packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts index 117b325f26b1..dfe4c679ca8f 100644 --- a/packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts +++ b/packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts @@ -116,6 +116,20 @@ describe('parseJsonSchemaToOptions', () => { ], }, }, + 'allowedHosts': { + 'default': [], + 'oneOf': [ + { + 'type': 'array', + 'items': { + 'type': 'string', + }, + }, + { + 'type': 'boolean', + }, + ], + }, }, }; @@ -231,6 +245,50 @@ describe('parseJsonSchemaToOptions', () => { }); }); + describe('type=array in oneOf', () => { + it('parses valid option value when specified once', async () => { + expect(await parse(['--allowed-hosts', 'a.example.com', 'b.example.com'])).toEqual( + jasmine.objectContaining({ + 'allowedHosts': ['a.example.com', 'b.example.com'], + }), + ); + }); + + it('parses valid option value when specified multiple times', async () => { + expect( + await parse(['--allowed-hosts', 'a.example.com', '--allowed-hosts', 'b.example.com']), + ).toEqual( + jasmine.objectContaining({ + 'allowedHosts': ['a.example.com', 'b.example.com'], + }), + ); + }); + + it('parses as boolean true when specified without values', async () => { + expect(await parse(['--allowed-hosts'])).toEqual( + jasmine.objectContaining({ + 'allowedHosts': true, + }), + ); + }); + + it('parses as boolean true when specified with =true', async () => { + expect(await parse(['--allowed-hosts=true'])).toEqual( + jasmine.objectContaining({ + 'allowedHosts': true, + }), + ); + }); + + it('parses as boolean false when specified with =false', async () => { + expect(await parse(['--allowed-hosts=false'])).toEqual( + jasmine.objectContaining({ + 'allowedHosts': false, + }), + ); + }); + }); + describe('type=string, enum', () => { it('parses valid option value', async () => { expect(await parse(['--ssr', 'never'])).toEqual(