Skip to content

Commit

Permalink
feat(@angular/cli): handle string key/value pairs, e.g. --define
Browse files Browse the repository at this point in the history
  • Loading branch information
jkrems committed Sep 9, 2024
1 parent eb97c43 commit 7c1c955
Show file tree
Hide file tree
Showing 4 changed files with 389 additions and 67 deletions.
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
162 changes: 157 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,62 @@ 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<Record<string, string>> {
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) {
if (node.properties) {
return false;
}
if (node.patternProperties) {
return false;
}
if (!json.isJsonObject(node.additionalProperties)) {
return false;
}

if (node.additionalProperties.type !== 'string') {
return false;
}
if (node.additionalProperties.enum) {
return false;
}

return true;
}

export async function parseJsonSchemaToOptions(
Expand Down Expand Up @@ -106,10 +162,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 +209,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 +240,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 +249,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 +276,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

0 comments on commit 7c1c955

Please sign in to comment.