Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/angular/build/src/builders/dev-server/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
}
},
{
"const": true,
"type": "boolean",
"description": "Indicates that all hosts are allowed. This is not recommended and a security risk."
}
Expand Down
81 changes: 79 additions & 2 deletions packages/angular/cli/src/command-builder/utilities/json-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'>;
}

/**
Expand Down Expand Up @@ -82,6 +87,41 @@ function checkStringMap(keyValuePairOptions: Set<string>, 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;
}
Comment thread
alan-agius4 marked this conversation as resolved.
Resolved

/**
* A Yargs coerce function that converts an array of `key=value` strings to an object.
* @param value An array of `key=value` strings.
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -424,6 +493,7 @@ export function addSchemaOptionsToCommand<T>(
alias,
userAnalytics,
type,
types,
itemValueType,
hidden,
name,
Expand All @@ -442,13 +512,20 @@ export function addSchemaOptionsToCommand<T>(
keyValuePairOptions.add(dashedName);
}

let coerce: YargsOptions['coerce'];
if (itemValueType) {
coerce = coerceToStringMap;
} else if (type !== 'boolean' && types?.includes('boolean')) {
coerce = coerceToBoolean;
}
Comment thread
alan-agius4 marked this conversation as resolved.
Resolved

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 } : {}),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,20 @@ describe('parseJsonSchemaToOptions', () => {
],
},
},
'allowedHosts': {
'default': [],
'oneOf': [
{
'type': 'array',
'items': {
'type': 'string',
},
},
{
'type': 'boolean',
},
],
},
},
};

Expand Down Expand Up @@ -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(
Expand Down
Loading