Skip to content

Commit b19693e

Browse files
committed
Add supersedes arg support for CLI
1 parent 40b8521 commit b19693e

2 files changed

Lines changed: 138 additions & 2 deletions

File tree

scripts/utils/CLI.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ type BooleanArg = CLIArg;
2121
* Any other argument is provided raw in process.argv as a string.
2222
* It can remain a string, or can be transformed into another type by a custom `parse` function.
2323
* It can be optional (by providing a default) or required (no default value).
24+
* It can also supersede other named arguments when provided.
2425
*/
2526
type StringArg<T = unknown> = CLIArg & {
2627
default?: T;
2728
parse?: (val: string) => T;
29+
supersedes?: string[];
30+
required?: boolean;
2831
};
2932

3033
/**
@@ -162,6 +165,8 @@ class CLI<TConfig extends CLIConfig> {
162165

163166
const parsedNamedArgs: Partial<Writable<typeof this.namedArgs>> = {};
164167
const parsedPositionalArgs: Partial<Writable<typeof this.positionalArgs>> = {};
168+
const providedNamedArgs = new Set<string>();
169+
165170
let positionalIndex = 0;
166171
for (let i = 0; i < rawArgs.length; i++) {
167172
const rawArg = rawArgs.at(i);
@@ -177,6 +182,8 @@ class CLI<TConfig extends CLIConfig> {
177182
this.flags[rawArgName as keyof typeof this.flags] = true;
178183
} else if (config.namedArgs && rawArgName in config.namedArgs) {
179184
// Arg is a named arg
185+
providedNamedArgs.add(rawArgName);
186+
180187
// Grab the value from the split token, otherwise go for the next token
181188
let argValueBeforeParse = '';
182189
if (rawArgValue) {
@@ -205,16 +212,34 @@ class CLI<TConfig extends CLIConfig> {
205212
}
206213
}
207214

215+
// Handle supercession logic
216+
const supersededArgs = new Set<string>();
217+
for (const [name, spec] of Object.entries(config.namedArgs ?? {})) {
218+
if (providedNamedArgs.has(name) && spec.supersedes) {
219+
for (const supersededArg of spec.supersedes) {
220+
supersededArgs.add(supersededArg);
221+
}
222+
}
223+
}
224+
208225
// Validate that all required args are present, assign defaults where values are not parsed
209226
for (const [name, spec] of Object.entries(config.namedArgs ?? {})) {
210227
if (!(name in parsedNamedArgs)) {
211-
if (spec.default !== undefined) {
228+
if (supersededArgs.has(name)) {
229+
// This arg was superseded, so don't require it and don't assign a default
230+
continue;
231+
} else if (spec.default !== undefined) {
212232
parsedNamedArgs[name as keyof typeof parsedNamedArgs] = spec.default as ValueOf<typeof parsedNamedArgs>;
233+
} else if (spec.required === false) {
234+
// Explicitly marked as optional, leave undefined
235+
continue;
213236
} else {
237+
// Arguments without defaults are required by default (unless explicitly marked as optional)
214238
throw new Error(`Missing required named argument --${name}`);
215239
}
216240
}
217241
}
242+
218243
for (const spec of config.positionalArgs ?? []) {
219244
if (!(spec.name in parsedPositionalArgs)) {
220245
if (spec.default !== undefined) {
@@ -263,7 +288,8 @@ class CLI<TConfig extends CLIConfig> {
263288
console.log('Named Arguments:');
264289
for (const [name, spec] of Object.entries(namedArgs)) {
265290
const defaultLabel = spec.default !== undefined ? ` (default: ${String(spec.default)})` : '';
266-
console.log(` --${name.padEnd(20)} ${spec.description}${defaultLabel}`);
291+
const supersededLabel = spec.supersedes && spec.supersedes.length > 0 ? ` (supersedes: ${spec.supersedes.join(', ')})` : '';
292+
console.log(` --${name.padEnd(20)} ${spec.description}${defaultLabel}${supersededLabel}`);
267293
}
268294
console.log('');
269295
}

tests/unit/CLITests.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,4 +212,114 @@ describe('CLI', () => {
212212
expect(actualOutput).toBe(expectedOutput);
213213
expect(mockExit).toHaveBeenCalledWith(0);
214214
});
215+
216+
it('handles supercession when superseding arg is provided', () => {
217+
process.argv.push('--paths', 'common.save,errors.generic');
218+
const cli = new CLI({
219+
namedArgs: {
220+
compareRef: {
221+
description: 'Compare reference',
222+
default: 'main',
223+
},
224+
paths: {
225+
description: 'Specific paths to process',
226+
parse: (val) => val.split(','),
227+
supersedes: ['compareRef'],
228+
required: false,
229+
},
230+
},
231+
});
232+
233+
expect(cli.namedArgs.paths).toEqual(['common.save', 'errors.generic']);
234+
expect(cli.namedArgs.compareRef).toBeUndefined();
235+
});
236+
237+
it('uses default value when superseding arg is not provided', () => {
238+
const cli = new CLI({
239+
namedArgs: {
240+
compareRef: {
241+
description: 'Compare reference',
242+
default: 'main',
243+
},
244+
paths: {
245+
description: 'Specific paths to process',
246+
parse: (val) => val.split(','),
247+
supersedes: ['compareRef'],
248+
required: false,
249+
},
250+
},
251+
});
252+
253+
expect(cli.namedArgs.paths).toBeUndefined();
254+
expect(cli.namedArgs.compareRef).toBe('main');
255+
});
256+
257+
it('shows supercession information in help message', () => {
258+
process.argv.push('--help');
259+
expect(
260+
() =>
261+
new CLI({
262+
namedArgs: {
263+
compareRef: {
264+
description: 'Compare reference',
265+
default: 'main',
266+
},
267+
paths: {
268+
description: 'Specific paths to process',
269+
supersedes: ['compareRef'],
270+
required: false,
271+
},
272+
},
273+
}),
274+
).toThrow('exit');
275+
276+
const actualOutput = mockLog.mock.calls.flat().join('\n');
277+
expect(actualOutput).toContain('(supersedes: compareRef)');
278+
expect(mockExit).toHaveBeenCalledWith(0);
279+
});
280+
281+
it('handles multiple superseded args', () => {
282+
process.argv.push('--priority', 'high');
283+
const cli = new CLI({
284+
namedArgs: {
285+
lowPriority: {
286+
description: 'Low priority mode',
287+
default: 'enabled',
288+
},
289+
mediumPriority: {
290+
description: 'Medium priority mode',
291+
default: 'enabled',
292+
},
293+
priority: {
294+
description: 'Priority level',
295+
supersedes: ['lowPriority', 'mediumPriority'],
296+
required: false,
297+
},
298+
},
299+
});
300+
301+
expect(cli.namedArgs.priority).toBe('high');
302+
expect(cli.namedArgs.lowPriority).toBeUndefined();
303+
expect(cli.namedArgs.mediumPriority).toBeUndefined();
304+
});
305+
306+
it('requires superseded args when superseding arg is not provided', () => {
307+
expect(
308+
() =>
309+
new CLI({
310+
namedArgs: {
311+
compareRef: {
312+
description: 'Compare reference',
313+
// No default value
314+
},
315+
paths: {
316+
description: 'Specific paths to process',
317+
supersedes: ['compareRef'],
318+
required: false,
319+
},
320+
},
321+
}),
322+
).toThrow();
323+
expect(mockError).toHaveBeenCalledWith('Missing required named argument --compareRef');
324+
});
215325
});

0 commit comments

Comments
 (0)