Skip to content

Commit 201b60e

Browse files
jkremsalan-agius4
authored andcommittedSep 9, 2024·
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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { json, schema } from '@angular-devkit/core';
10+
import yargs, { positional } from 'yargs';
11+
12+
import { addSchemaOptionsToCommand, parseJsonSchemaToOptions } from './json-schema';
13+
14+
const YError = (() => {
15+
try {
16+
const y = yargs().strict().fail(false).exitProcess(false).parse(['--forced-failure']);
17+
} catch (e) {
18+
if (!(e instanceof Error)) {
19+
throw new Error('Unexpected non-Error thrown');
20+
}
21+
22+
return e.constructor as typeof Error;
23+
}
24+
throw new Error('Expected parse to fail');
25+
})();
26+
27+
interface ParseFunction {
28+
(argv: string[]): unknown;
29+
}
30+
31+
function withParseForSchema(
32+
jsonSchema: json.JsonObject,
33+
{
34+
interactive = true,
35+
includeDefaultValues = true,
36+
}: { interactive?: boolean; includeDefaultValues?: boolean } = {},
37+
): ParseFunction {
38+
let actualParse: ParseFunction = () => {
39+
throw new Error('Called before init');
40+
};
41+
const parse: ParseFunction = (args) => {
42+
return actualParse(args);
43+
};
44+
45+
beforeEach(async () => {
46+
const registry = new schema.CoreSchemaRegistry();
47+
const options = await parseJsonSchemaToOptions(registry, jsonSchema, interactive);
48+
49+
actualParse = async (args: string[]) => {
50+
// Create a fresh yargs for each call. The yargs object is stateful and
51+
// calling .parse multiple times on the same instance isn't safe.
52+
const localYargs = yargs().exitProcess(false).strict().fail(false);
53+
addSchemaOptionsToCommand(localYargs, options, includeDefaultValues);
54+
55+
// Yargs only exposes the parse errors as proper errors when using the
56+
// callback syntax. This unwraps that ugly workaround so tests can just
57+
// use simple .toThrow/.toEqual assertions.
58+
return localYargs.parseAsync(args);
59+
};
60+
});
61+
62+
return parse;
63+
}
64+
65+
describe('parseJsonSchemaToOptions', () => {
66+
describe('without required fields in schema', () => {
67+
const parse = withParseForSchema({
68+
'type': 'object',
69+
'properties': {
70+
'maxSize': {
71+
'type': 'number',
72+
},
73+
'ssr': {
74+
'type': 'string',
75+
'enum': ['always', 'surprise-me', 'never'],
76+
},
77+
'extendable': {
78+
'type': 'object',
79+
'properties': {},
80+
'additionalProperties': {
81+
'type': 'string',
82+
},
83+
},
84+
'someDefine': {
85+
'type': 'object',
86+
'additionalProperties': {
87+
'type': 'string',
88+
},
89+
},
90+
},
91+
});
92+
93+
describe('type=number', () => {
94+
it('parses valid option value', async () => {
95+
expect(await parse(['--max-size', '42'])).toEqual(
96+
jasmine.objectContaining({
97+
'maxSize': 42,
98+
}),
99+
);
100+
});
101+
});
102+
103+
describe('type=string, enum', () => {
104+
it('parses valid option value', async () => {
105+
expect(await parse(['--ssr', 'never'])).toEqual(
106+
jasmine.objectContaining({
107+
'ssr': 'never',
108+
}),
109+
);
110+
});
111+
112+
it('rejects non-enum values', async () => {
113+
await expectAsync(parse(['--ssr', 'yes'])).toBeRejectedWithError(
114+
/Argument: ssr, Given: "yes", Choices:/,
115+
);
116+
});
117+
});
118+
119+
describe('type=object', () => {
120+
it('ignores fields that define specific properties', async () => {
121+
await expectAsync(parse(['--extendable', 'a=b'])).toBeRejectedWithError(
122+
/Unknown argument: extendable/,
123+
);
124+
});
125+
126+
it('rejects invalid values for string maps', async () => {
127+
await expectAsync(parse(['--some-define', 'foo'])).toBeRejectedWithError(
128+
YError,
129+
/Invalid value for argument: some-define, Given: 'foo', Expected key=value pair/,
130+
);
131+
await expectAsync(parse(['--some-define', '42'])).toBeRejectedWithError(
132+
YError,
133+
/Invalid value for argument: some-define, Given: '42', Expected key=value pair/,
134+
);
135+
});
136+
137+
it('aggregates an object value', async () => {
138+
expect(
139+
await parse([
140+
'--some-define',
141+
'A_BOOLEAN=true',
142+
'--some-define',
143+
'AN_INTEGER=42',
144+
// Ensure we can handle '=' inside of string values.
145+
'--some-define=A_STRING="❤️=❤️"',
146+
'--some-define',
147+
'AN_UNQUOTED_STRING=❤️=❤️',
148+
]),
149+
).toEqual(
150+
jasmine.objectContaining({
151+
'someDefine': {
152+
'A_BOOLEAN': 'true',
153+
'AN_INTEGER': '42',
154+
'A_STRING': '"❤️=❤️"',
155+
'AN_UNQUOTED_STRING': '❤️=❤️',
156+
},
157+
}),
158+
);
159+
});
160+
});
161+
});
162+
163+
describe('with required positional argument', () => {
164+
it('marks the required argument as required', async () => {
165+
const jsonSchema = JSON.parse(`
166+
{
167+
"$id": "FakeSchema",
168+
"title": "Fake Schema",
169+
"type": "object",
170+
"required": ["a"],
171+
"properties": {
172+
"b": {
173+
"type": "string",
174+
"description": "b.",
175+
"$default": {
176+
"$source": "argv",
177+
"index": 1
178+
}
179+
},
180+
"a": {
181+
"type": "string",
182+
"description": "a.",
183+
"$default": {
184+
"$source": "argv",
185+
"index": 0
186+
}
187+
},
188+
"optC": {
189+
"type": "string",
190+
"description": "optC"
191+
},
192+
"optA": {
193+
"type": "string",
194+
"description": "optA"
195+
},
196+
"optB": {
197+
"type": "string",
198+
"description": "optB"
199+
}
200+
}
201+
}`) as json.JsonObject;
202+
const registry = new schema.CoreSchemaRegistry();
203+
const options = await parseJsonSchemaToOptions(registry, jsonSchema, /* interactive= */ true);
204+
205+
expect(options.find((opt) => opt.name === 'a')).toEqual(
206+
jasmine.objectContaining({
207+
name: 'a',
208+
positional: 0,
209+
required: true,
210+
}),
211+
);
212+
expect(options.find((opt) => opt.name === 'b')).toEqual(
213+
jasmine.objectContaining({
214+
name: 'b',
215+
positional: 1,
216+
required: false,
217+
}),
218+
);
219+
});
220+
});
221+
});

0 commit comments

Comments
 (0)
Please sign in to comment.