Skip to content

Commit 27ea8d6

Browse files
committed
feat(@angular/cli): handle string key/value pairs, e.g. --define
1 parent ecc107d commit 27ea8d6

File tree

4 files changed

+327
-68
lines changed

4 files changed

+327
-68
lines changed

packages/angular/cli/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ ts_library(
147147
"//packages/angular_devkit/schematics",
148148
"//packages/angular_devkit/schematics/testing",
149149
"@npm//@types/semver",
150+
"@npm//@types/yargs",
150151
],
151152
)
152153

packages/angular/cli/src/command-builder/command-module.ts

+10-62
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { considerSettingUpAutocompletion } from '../utilities/completion';
2626
import { AngularWorkspace } from '../utilities/config';
2727
import { memoize } from '../utilities/memoize';
2828
import { PackageManagerUtils } from '../utilities/package-manager';
29-
import { Option } from './utilities/json-schema';
29+
import { Option, addSchemaOptionsToCommand } from './utilities/json-schema';
3030

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

@@ -188,68 +188,16 @@ export abstract class CommandModule<T extends {} = {}> implements CommandModuleI
188188
* **Note:** This method should be called from the command bundler method.
189189
*/
190190
protected addSchemaOptionsToCommand<T>(localYargs: Argv<T>, options: Option[]): Argv<T> {
191-
const booleanOptionsWithNoPrefix = new Set<string>();
192-
193-
for (const option of options) {
194-
const {
195-
default: defaultVal,
196-
positional,
197-
deprecated,
198-
description,
199-
alias,
200-
userAnalytics,
201-
type,
202-
hidden,
203-
name,
204-
choices,
205-
} = option;
206-
207-
const sharedOptions: YargsOptions & PositionalOptions = {
208-
alias,
209-
hidden,
210-
description,
211-
deprecated,
212-
choices,
213-
// This should only be done when `--help` is used otherwise default will override options set in angular.json.
214-
...(this.context.args.options.help ? { default: defaultVal } : {}),
215-
};
216-
217-
let dashedName = strings.dasherize(name);
218-
219-
// Handle options which have been defined in the schema with `no` prefix.
220-
if (type === 'boolean' && dashedName.startsWith('no-')) {
221-
dashedName = dashedName.slice(3);
222-
booleanOptionsWithNoPrefix.add(dashedName);
223-
}
224-
225-
if (positional === undefined) {
226-
localYargs = localYargs.option(dashedName, {
227-
type,
228-
...sharedOptions,
229-
});
230-
} else {
231-
localYargs = localYargs.positional(dashedName, {
232-
type: type === 'array' || type === 'count' ? 'string' : type,
233-
...sharedOptions,
234-
});
235-
}
236-
237-
// Record option of analytics.
238-
if (userAnalytics !== undefined) {
239-
this.optionsWithAnalytics.set(name, userAnalytics);
240-
}
241-
}
191+
const optionsWithAnalytics = addSchemaOptionsToCommand(
192+
localYargs,
193+
options,
194+
// This should only be done when `--help` is used otherwise default will override options set in angular.json.
195+
/* includeDefaultValues= */ this.context.args.options.help,
196+
);
242197

243-
// Handle options which have been defined in the schema with `no` prefix.
244-
if (booleanOptionsWithNoPrefix.size) {
245-
localYargs.middleware((options: Arguments) => {
246-
for (const key of booleanOptionsWithNoPrefix) {
247-
if (key in options) {
248-
options[`no-${key}`] = !options[key];
249-
delete options[key];
250-
}
251-
}
252-
}, false);
198+
// Record option of analytics.
199+
for (const [name, userAnalytics] of optionsWithAnalytics) {
200+
this.optionsWithAnalytics.set(name, userAnalytics);
253201
}
254202

255203
return localYargs;

packages/angular/cli/src/command-builder/utilities/json-schema.ts

+173-6
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

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

1212
/**
1313
* An option description.
@@ -43,6 +43,75 @@ export interface Option extends yargs.Options {
4343
* If this is falsey, do not report this option.
4444
*/
4545
userAnalytics?: string;
46+
47+
/**
48+
* Type of the values in a key/value pair field.
49+
*/
50+
itemValueType?: 'string';
51+
}
52+
53+
/**
54+
* Note: This is done in a middleware because of how coerce and check work in
55+
* yargs: coerce cannot throw validation errors but check only receives the
56+
* post-coerce values. Instead of building a brittle communication channel
57+
* between those two functions, it's easier to do both inside a single middleware.
58+
*/
59+
function coerceToStringMap(dashedName: string, value: (string | undefined)[]) {
60+
const stringMap: Record<string, string> = {};
61+
for (const pair of value) {
62+
// This happens when the flag isn't passed at all.
63+
if (pair === undefined) {
64+
continue;
65+
}
66+
67+
const eqIdx = pair.indexOf('=');
68+
if (eqIdx === -1) {
69+
// This error will be picked up later in the check() callback.
70+
// We can't throw in coerce and checks only happen after coerce completed.
71+
throw new Error(
72+
`Invalid value for argument: ${dashedName}, Given: '${pair}', Expected key=value pair`,
73+
);
74+
}
75+
const key = pair.slice(0, eqIdx);
76+
const value = pair.slice(eqIdx + 1);
77+
stringMap[key] = value;
78+
}
79+
80+
return stringMap;
81+
}
82+
83+
function stringMapMiddleware(optionNames: Set<string>) {
84+
return (argv: Arguments) => {
85+
for (const name of optionNames) {
86+
if (name in argv) {
87+
const value = argv[name];
88+
const dashedName = strings.dasherize(name);
89+
const newValue = coerceToStringMap(dashedName, value as (string | undefined)[]);
90+
argv[name] = argv[dashedName] = newValue;
91+
}
92+
}
93+
};
94+
}
95+
96+
function isStringMap(node: json.JsonObject) {
97+
if (node.properties) {
98+
return false;
99+
}
100+
if (node.patternProperties) {
101+
return false;
102+
}
103+
if (!json.isJsonObject(node.additionalProperties)) {
104+
return false;
105+
}
106+
107+
if (node.additionalProperties?.type !== 'string') {
108+
return false;
109+
}
110+
if (node.additionalProperties?.enum) {
111+
return false;
112+
}
113+
114+
return true;
46115
}
47116

48117
export async function parseJsonSchemaToOptions(
@@ -106,10 +175,13 @@ export async function parseJsonSchemaToOptions(
106175

107176
return false;
108177

178+
case 'object':
179+
return isStringMap(current);
180+
109181
default:
110182
return false;
111183
}
112-
}) as ('string' | 'number' | 'boolean' | 'array')[];
184+
}) as ('string' | 'number' | 'boolean' | 'array' | 'object')[];
113185

114186
if (types.length == 0) {
115187
// This means it's not usable on the command line. e.g. an Object.
@@ -150,7 +222,6 @@ export async function parseJsonSchemaToOptions(
150222
}
151223
}
152224

153-
const type = types[0];
154225
const $default = current.$default;
155226
const $defaultIndex =
156227
json.isJsonObject($default) && $default['$source'] == 'argv' ? $default['index'] : undefined;
@@ -182,16 +253,22 @@ export async function parseJsonSchemaToOptions(
182253
const option: Option = {
183254
name,
184255
description: '' + (current.description === undefined ? '' : current.description),
185-
type,
186256
default: defaultValue,
187257
choices: enumValues.length ? enumValues : undefined,
188-
required,
189258
alias,
190259
format,
191260
hidden,
192261
userAnalytics,
193262
deprecated,
194263
positional,
264+
...(types[0] === 'object'
265+
? {
266+
type: 'array',
267+
itemValueType: 'string',
268+
}
269+
: {
270+
type: types[0],
271+
}),
195272
};
196273

197274
options.push(option);
@@ -211,3 +288,93 @@ export async function parseJsonSchemaToOptions(
211288
return a.name.localeCompare(b.name);
212289
});
213290
}
291+
292+
/**
293+
* Adds schema options to a command also this keeps track of options that are required for analytics.
294+
* **Note:** This method should be called from the command bundler method.
295+
*
296+
* @returns A map from option name to analytics configuration.
297+
*/
298+
export function addSchemaOptionsToCommand<T>(
299+
localYargs: Argv<T>,
300+
options: Option[],
301+
includeDefaultValues: boolean,
302+
): Map<string, string> {
303+
const booleanOptionsWithNoPrefix = new Set<string>();
304+
const keyValuePairOptions = new Set<string>();
305+
const optionsWithAnalytics = new Map<string, string>();
306+
307+
for (const option of options) {
308+
const {
309+
default: defaultVal,
310+
positional,
311+
deprecated,
312+
description,
313+
alias,
314+
userAnalytics,
315+
type,
316+
itemValueType,
317+
hidden,
318+
name,
319+
choices,
320+
} = option;
321+
322+
const sharedOptions: YargsOptions & PositionalOptions = {
323+
alias,
324+
hidden,
325+
description,
326+
deprecated,
327+
choices,
328+
// This should only be done when `--help` is used otherwise default will override options set in angular.json.
329+
...(includeDefaultValues ? { default: defaultVal } : {}),
330+
};
331+
332+
let dashedName = strings.dasherize(name);
333+
334+
// Handle options which have been defined in the schema with `no` prefix.
335+
if (type === 'boolean' && dashedName.startsWith('no-')) {
336+
dashedName = dashedName.slice(3);
337+
booleanOptionsWithNoPrefix.add(dashedName);
338+
}
339+
340+
if (itemValueType) {
341+
keyValuePairOptions.add(name);
342+
}
343+
344+
if (positional === undefined) {
345+
localYargs = localYargs.option(dashedName, {
346+
array: itemValueType ? true : undefined,
347+
type: itemValueType ?? type,
348+
...sharedOptions,
349+
});
350+
} else {
351+
localYargs = localYargs.positional(dashedName, {
352+
type: type === 'array' || type === 'count' ? 'string' : type,
353+
...sharedOptions,
354+
});
355+
}
356+
357+
// Record option of analytics.
358+
if (userAnalytics !== undefined) {
359+
optionsWithAnalytics.set(name, userAnalytics);
360+
}
361+
}
362+
363+
// Handle options which have been defined in the schema with `no` prefix.
364+
if (booleanOptionsWithNoPrefix.size) {
365+
localYargs.middleware((options: Arguments) => {
366+
for (const key of booleanOptionsWithNoPrefix) {
367+
if (key in options) {
368+
options[`no-${key}`] = !options[key];
369+
delete options[key];
370+
}
371+
}
372+
}, false);
373+
}
374+
375+
if (keyValuePairOptions.size) {
376+
localYargs.middleware(stringMapMiddleware(keyValuePairOptions), true);
377+
}
378+
379+
return optionsWithAnalytics;
380+
}

0 commit comments

Comments
 (0)