Skip to content

Commit d88a23e

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

File tree

4 files changed

+389
-67
lines changed

4 files changed

+389
-67
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

+157-5
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,62 @@ 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+
function coerceToStringMap(
54+
dashedName: string,
55+
value: (string | undefined)[],
56+
): Record<string, string> | Promise<never> {
57+
const stringMap: Record<string, string> = {};
58+
for (const pair of value) {
59+
// This happens when the flag isn't passed at all.
60+
if (pair === undefined) {
61+
continue;
62+
}
63+
64+
const eqIdx = pair.indexOf('=');
65+
if (eqIdx === -1) {
66+
// TODO: Remove workaround once yargs properly handles thrown errors from coerce.
67+
// Right now these sometimes end up as uncaught exceptions instead of proper validation
68+
// errors with usage output.
69+
return Promise.reject(
70+
new Error(
71+
`Invalid value for argument: ${dashedName}, Given: '${pair}', Expected key=value pair`,
72+
),
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 isStringMap(node: json.JsonObject): boolean {
84+
if (node.properties) {
85+
return false;
86+
}
87+
if (node.patternProperties) {
88+
return false;
89+
}
90+
if (!json.isJsonObject(node.additionalProperties)) {
91+
return false;
92+
}
93+
94+
if (node.additionalProperties.type !== 'string') {
95+
return false;
96+
}
97+
if (node.additionalProperties.enum) {
98+
return false;
99+
}
100+
101+
return true;
46102
}
47103

48104
export async function parseJsonSchemaToOptions(
@@ -106,10 +162,13 @@ export async function parseJsonSchemaToOptions(
106162

107163
return false;
108164

165+
case 'object':
166+
return isStringMap(current);
167+
109168
default:
110169
return false;
111170
}
112-
}) as ('string' | 'number' | 'boolean' | 'array')[];
171+
}) as ('string' | 'number' | 'boolean' | 'array' | 'object')[];
113172

114173
if (types.length == 0) {
115174
// This means it's not usable on the command line. e.g. an Object.
@@ -150,7 +209,6 @@ export async function parseJsonSchemaToOptions(
150209
}
151210
}
152211

153-
const type = types[0];
154212
const $default = current.$default;
155213
const $defaultIndex =
156214
json.isJsonObject($default) && $default['$source'] == 'argv' ? $default['index'] : undefined;
@@ -182,7 +240,6 @@ export async function parseJsonSchemaToOptions(
182240
const option: Option = {
183241
name,
184242
description: '' + (current.description === undefined ? '' : current.description),
185-
type,
186243
default: defaultValue,
187244
choices: enumValues.length ? enumValues : undefined,
188245
required,
@@ -192,6 +249,14 @@ export async function parseJsonSchemaToOptions(
192249
userAnalytics,
193250
deprecated,
194251
positional,
252+
...(types[0] === 'object'
253+
? {
254+
type: 'array',
255+
itemValueType: 'string',
256+
}
257+
: {
258+
type: types[0],
259+
}),
195260
};
196261

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

0 commit comments

Comments
 (0)