Skip to content

Commit 201b60e

Browse files
jkremsalan-agius4
authored andcommitted
feat(@angular/cli): handle string key/value pairs, e.g. --define
1 parent f8b2203 commit 201b60e

File tree

4 files changed

+382
-67
lines changed

4 files changed

+382
-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

+150-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,55 @@ 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+
// Exclude fields with more specific kinds of properties.
85+
if (node.properties || node.patternProperties) {
86+
return false;
87+
}
88+
89+
// Restrict to additionalProperties with string values.
90+
return (
91+
json.isJsonObject(node.additionalProperties) &&
92+
!node.additionalProperties.enum &&
93+
node.additionalProperties.type === 'string'
94+
);
4695
}
4796

4897
export async function parseJsonSchemaToOptions(
@@ -106,10 +155,13 @@ export async function parseJsonSchemaToOptions(
106155

107156
return false;
108157

158+
case 'object':
159+
return isStringMap(current);
160+
109161
default:
110162
return false;
111163
}
112-
}) as ('string' | 'number' | 'boolean' | 'array')[];
164+
}) as ('string' | 'number' | 'boolean' | 'array' | 'object')[];
113165

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

153-
const type = types[0];
154205
const $default = current.$default;
155206
const $defaultIndex =
156207
json.isJsonObject($default) && $default['$source'] == 'argv' ? $default['index'] : undefined;
@@ -182,7 +233,6 @@ export async function parseJsonSchemaToOptions(
182233
const option: Option = {
183234
name,
184235
description: '' + (current.description === undefined ? '' : current.description),
185-
type,
186236
default: defaultValue,
187237
choices: enumValues.length ? enumValues : undefined,
188238
required,
@@ -192,6 +242,14 @@ export async function parseJsonSchemaToOptions(
192242
userAnalytics,
193243
deprecated,
194244
positional,
245+
...(types[0] === 'object'
246+
? {
247+
type: 'array',
248+
itemValueType: 'string',
249+
}
250+
: {
251+
type: types[0],
252+
}),
195253
};
196254

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

0 commit comments

Comments
 (0)