Skip to content

feat(@angular/cli): handle string key/value pairs, e.g. --define #28362

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 9, 2024
Merged
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/cli/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ ts_library(
"//packages/angular_devkit/schematics",
"//packages/angular_devkit/schematics/testing",
"@npm//@types/semver",
"@npm//@types/yargs",
],
)

Expand Down
72 changes: 10 additions & 62 deletions packages/angular/cli/src/command-builder/command-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { considerSettingUpAutocompletion } from '../utilities/completion';
import { AngularWorkspace } from '../utilities/config';
import { memoize } from '../utilities/memoize';
import { PackageManagerUtils } from '../utilities/package-manager';
import { Option } from './utilities/json-schema';
import { Option, addSchemaOptionsToCommand } from './utilities/json-schema';

export type Options<T> = { [key in keyof T as CamelCaseKey<key>]: T[key] };

Expand Down Expand Up @@ -188,68 +188,16 @@ export abstract class CommandModule<T extends {} = {}> implements CommandModuleI
* **Note:** This method should be called from the command bundler method.
*/
protected addSchemaOptionsToCommand<T>(localYargs: Argv<T>, options: Option[]): Argv<T> {
const booleanOptionsWithNoPrefix = new Set<string>();

for (const option of options) {
const {
default: defaultVal,
positional,
deprecated,
description,
alias,
userAnalytics,
type,
hidden,
name,
choices,
} = option;

const sharedOptions: YargsOptions & PositionalOptions = {
alias,
hidden,
description,
deprecated,
choices,
// This should only be done when `--help` is used otherwise default will override options set in angular.json.
...(this.context.args.options.help ? { default: defaultVal } : {}),
};

let dashedName = strings.dasherize(name);

// Handle options which have been defined in the schema with `no` prefix.
if (type === 'boolean' && dashedName.startsWith('no-')) {
dashedName = dashedName.slice(3);
booleanOptionsWithNoPrefix.add(dashedName);
}

if (positional === undefined) {
localYargs = localYargs.option(dashedName, {
type,
...sharedOptions,
});
} else {
localYargs = localYargs.positional(dashedName, {
type: type === 'array' || type === 'count' ? 'string' : type,
...sharedOptions,
});
}

// Record option of analytics.
if (userAnalytics !== undefined) {
this.optionsWithAnalytics.set(name, userAnalytics);
}
}
const optionsWithAnalytics = addSchemaOptionsToCommand(
localYargs,
options,
// This should only be done when `--help` is used otherwise default will override options set in angular.json.
/* includeDefaultValues= */ this.context.args.options.help,
);

// Handle options which have been defined in the schema with `no` prefix.
if (booleanOptionsWithNoPrefix.size) {
localYargs.middleware((options: Arguments) => {
for (const key of booleanOptionsWithNoPrefix) {
if (key in options) {
options[`no-${key}`] = !options[key];
delete options[key];
}
}
}, false);
// Record option of analytics.
for (const [name, userAnalytics] of optionsWithAnalytics) {
this.optionsWithAnalytics.set(name, userAnalytics);
}

return localYargs;
Expand Down
155 changes: 150 additions & 5 deletions packages/angular/cli/src/command-builder/utilities/json-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
* found in the LICENSE file at https://angular.dev/license
*/

import { json } from '@angular-devkit/core';
import yargs from 'yargs';
import { json, strings } from '@angular-devkit/core';
import yargs, { Arguments, Argv, PositionalOptions, Options as YargsOptions } from 'yargs';

/**
* An option description.
Expand Down Expand Up @@ -43,6 +43,55 @@ export interface Option extends yargs.Options {
* If this is falsey, do not report this option.
*/
userAnalytics?: string;

/**
* Type of the values in a key/value pair field.
*/
itemValueType?: 'string';
}

function coerceToStringMap(
dashedName: string,
value: (string | undefined)[],
): Record<string, string> | Promise<never> {
const stringMap: Record<string, string> = {};
for (const pair of value) {
// This happens when the flag isn't passed at all.
if (pair === undefined) {
continue;
}

const eqIdx = pair.indexOf('=');
if (eqIdx === -1) {
// TODO: Remove workaround once yargs properly handles thrown errors from coerce.
// Right now these sometimes end up as uncaught exceptions instead of proper validation
// errors with usage output.
return Promise.reject(
new Error(
`Invalid value for argument: ${dashedName}, Given: '${pair}', Expected key=value pair`,
),
);
}
const key = pair.slice(0, eqIdx);
const value = pair.slice(eqIdx + 1);
stringMap[key] = value;
}

return stringMap;
}

function isStringMap(node: json.JsonObject): boolean {
// Exclude fields with more specific kinds of properties.
if (node.properties || node.patternProperties) {
return false;
}

// Restrict to additionalProperties with string values.
return (
json.isJsonObject(node.additionalProperties) &&
!node.additionalProperties.enum &&
node.additionalProperties.type === 'string'
);
}

export async function parseJsonSchemaToOptions(
Expand Down Expand Up @@ -106,10 +155,13 @@ export async function parseJsonSchemaToOptions(

return false;

case 'object':
return isStringMap(current);

default:
return false;
}
}) as ('string' | 'number' | 'boolean' | 'array')[];
}) as ('string' | 'number' | 'boolean' | 'array' | 'object')[];

if (types.length == 0) {
// This means it's not usable on the command line. e.g. an Object.
Expand Down Expand Up @@ -150,7 +202,6 @@ export async function parseJsonSchemaToOptions(
}
}

const type = types[0];
const $default = current.$default;
const $defaultIndex =
json.isJsonObject($default) && $default['$source'] == 'argv' ? $default['index'] : undefined;
Expand Down Expand Up @@ -182,7 +233,6 @@ export async function parseJsonSchemaToOptions(
const option: Option = {
name,
description: '' + (current.description === undefined ? '' : current.description),
type,
default: defaultValue,
choices: enumValues.length ? enumValues : undefined,
required,
Expand All @@ -192,6 +242,14 @@ export async function parseJsonSchemaToOptions(
userAnalytics,
deprecated,
positional,
...(types[0] === 'object'
? {
type: 'array',
itemValueType: 'string',
}
: {
type: types[0],
}),
};

options.push(option);
Expand All @@ -211,3 +269,90 @@ export async function parseJsonSchemaToOptions(
return a.name.localeCompare(b.name);
});
}

/**
* Adds schema options to a command also this keeps track of options that are required for analytics.
* **Note:** This method should be called from the command bundler method.
*
* @returns A map from option name to analytics configuration.
*/
export function addSchemaOptionsToCommand<T>(
localYargs: Argv<T>,
options: Option[],
includeDefaultValues: boolean,
): Map<string, string> {
const booleanOptionsWithNoPrefix = new Set<string>();
const keyValuePairOptions = new Set<string>();
const optionsWithAnalytics = new Map<string, string>();

for (const option of options) {
const {
default: defaultVal,
positional,
deprecated,
description,
alias,
userAnalytics,
type,
itemValueType,
hidden,
name,
choices,
} = option;

let dashedName = strings.dasherize(name);

// Handle options which have been defined in the schema with `no` prefix.
if (type === 'boolean' && dashedName.startsWith('no-')) {
dashedName = dashedName.slice(3);
booleanOptionsWithNoPrefix.add(dashedName);
}

if (itemValueType) {
keyValuePairOptions.add(name);
}

const sharedOptions: YargsOptions & PositionalOptions = {
alias,
hidden,
description,
deprecated,
choices,
coerce: itemValueType ? coerceToStringMap.bind(null, dashedName) : undefined,
// This should only be done when `--help` is used otherwise default will override options set in angular.json.
...(includeDefaultValues ? { default: defaultVal } : {}),
};

if (positional === undefined) {
localYargs = localYargs.option(dashedName, {
array: itemValueType ? true : undefined,
type: itemValueType ?? type,
...sharedOptions,
});
} else {
localYargs = localYargs.positional(dashedName, {
type: type === 'array' || type === 'count' ? 'string' : type,
...sharedOptions,
});
}

// Record option of analytics.
if (userAnalytics !== undefined) {
optionsWithAnalytics.set(name, userAnalytics);
}
}

// Handle options which have been defined in the schema with `no` prefix.
if (booleanOptionsWithNoPrefix.size) {
localYargs.middleware((options: Arguments) => {
for (const key of booleanOptionsWithNoPrefix) {
if (key in options) {
options[`no-${key}`] = !options[key];
delete options[key];
}
}
}, false);
}

return optionsWithAnalytics;
}
Loading