@ontrails/commander 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +355 -0
- package/README.md +64 -0
- package/package.json +37 -0
- package/src/index.ts +5 -0
- package/src/multiselect-argv.ts +182 -0
- package/src/surface.ts +150 -0
- package/src/to-commander.ts +940 -0
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapt framework-agnostic CliCommand[] to a Commander program.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
isTrailsError,
|
|
7
|
+
renderPublicSurfaceError,
|
|
8
|
+
redactErrorContext,
|
|
9
|
+
redactErrorString,
|
|
10
|
+
ValidationError,
|
|
11
|
+
} from '@ontrails/core';
|
|
12
|
+
import type { SurfaceErrorRendering } from '@ontrails/core';
|
|
13
|
+
import type { CliCommand, CliFlag } from '@ontrails/cli';
|
|
14
|
+
import {
|
|
15
|
+
applyCliFlagValueAliases,
|
|
16
|
+
deriveOutputMode,
|
|
17
|
+
validateCliCommands,
|
|
18
|
+
} from '@ontrails/cli';
|
|
19
|
+
import { Command, InvalidArgumentError, Option } from 'commander';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
invocationOptionMatches,
|
|
23
|
+
isNegativeNumberArg,
|
|
24
|
+
optionConsumesFollowingValue,
|
|
25
|
+
TrailsCommanderProgram,
|
|
26
|
+
visibleOptionsFor,
|
|
27
|
+
} from './multiselect-argv.js';
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Options
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Options used when constructing a Commander program from trail metadata.
|
|
35
|
+
*/
|
|
36
|
+
export interface ToCommanderOptions {
|
|
37
|
+
description?: string | undefined;
|
|
38
|
+
name?: string | undefined;
|
|
39
|
+
topoName?: string | undefined;
|
|
40
|
+
version?: string | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Helpers
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/** Build the flag string portion of a Commander Option. */
|
|
48
|
+
const buildFlagArgument = (flag: CliFlag): string => {
|
|
49
|
+
if (flag.variadic) {
|
|
50
|
+
return flag.required ? '<values...>' : '[values...]';
|
|
51
|
+
}
|
|
52
|
+
return flag.required ? '<value>' : '[value]';
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const buildFlagString = (flag: CliFlag): string => {
|
|
56
|
+
const long = `--${flag.name}`;
|
|
57
|
+
const short = flag.short ? `-${flag.short}` : undefined;
|
|
58
|
+
|
|
59
|
+
if (flag.type === 'boolean') {
|
|
60
|
+
return short ? `${short}, ${long}` : long;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const argPart = buildFlagArgument(flag);
|
|
64
|
+
return short ? `${short}, ${long} ${argPart}` : `${long} ${argPart}`;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Strict number parser that rejects partial parses and non-finite values. */
|
|
68
|
+
const strictParseNumber = (value: string): number => {
|
|
69
|
+
const n = Number(value);
|
|
70
|
+
if (Number.isNaN(n) || !Number.isFinite(n)) {
|
|
71
|
+
throw new InvalidArgumentError(`"${value}" is not a valid number`);
|
|
72
|
+
}
|
|
73
|
+
return n;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const validateChoiceValue = (flag: CliFlag, value: string): void => {
|
|
77
|
+
if (flag.choices && !flag.choices.includes(value)) {
|
|
78
|
+
throw new InvalidArgumentError(
|
|
79
|
+
`Allowed choices are ${flag.choices.join(', ')}.`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const isRepeatableArrayFlag = (flag: CliFlag): boolean =>
|
|
85
|
+
!flag.variadic && (flag.type === 'number[]' || flag.type === 'string[]');
|
|
86
|
+
|
|
87
|
+
const buildRepeatableArrayParser =
|
|
88
|
+
(flag: CliFlag) =>
|
|
89
|
+
(value: string, previous: unknown): readonly (number | string)[] => {
|
|
90
|
+
const parsed = flag.type === 'number[]' ? strictParseNumber(value) : value;
|
|
91
|
+
validateChoiceValue(flag, String(parsed));
|
|
92
|
+
return [...(Array.isArray(previous) ? previous : []), parsed];
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const structuredInputOptions = new WeakSet<Option>();
|
|
96
|
+
|
|
97
|
+
/** Apply common modifiers (choices, default, arg parser) to a Commander Option. */
|
|
98
|
+
const applyOptionModifiers = (opt: Option, flag: CliFlag): void => {
|
|
99
|
+
if (isRepeatableArrayFlag(flag)) {
|
|
100
|
+
opt.argParser(buildRepeatableArrayParser(flag));
|
|
101
|
+
if (flag.default !== undefined) {
|
|
102
|
+
opt.default(flag.default);
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (flag.choices) {
|
|
107
|
+
opt.choices(flag.choices);
|
|
108
|
+
}
|
|
109
|
+
if (flag.default !== undefined) {
|
|
110
|
+
opt.default(flag.default);
|
|
111
|
+
}
|
|
112
|
+
if (flag.type === 'number' || flag.type === 'number[]') {
|
|
113
|
+
opt.argParser(strictParseNumber);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Build Commander Option(s) from a CliFlag. Returns one or two options. */
|
|
118
|
+
const buildOptions = (flag: CliFlag): Option[] => {
|
|
119
|
+
const opt = new Option(buildFlagString(flag), flag.description);
|
|
120
|
+
if (flag.role === 'structured-input') {
|
|
121
|
+
structuredInputOptions.add(opt);
|
|
122
|
+
}
|
|
123
|
+
applyOptionModifiers(opt, flag);
|
|
124
|
+
const valueAliasOptions = (flag.valueAliases ?? []).map(
|
|
125
|
+
(alias) =>
|
|
126
|
+
new Option(
|
|
127
|
+
`--${alias.name}`,
|
|
128
|
+
alias.description ?? `Shorthand for --${flag.name} ${alias.value}`
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
if (flag.type === 'boolean') {
|
|
132
|
+
const negation = new Option(
|
|
133
|
+
`--no-${flag.name}`,
|
|
134
|
+
flag.description ? `Negate ${flag.description}` : undefined
|
|
135
|
+
);
|
|
136
|
+
return [opt, negation, ...valueAliasOptions];
|
|
137
|
+
}
|
|
138
|
+
return [opt, ...valueAliasOptions];
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Add positional args to a Commander subcommand. */
|
|
142
|
+
const buildArgTemplate = (
|
|
143
|
+
arg: CliCommand['args'][number],
|
|
144
|
+
required = arg.required
|
|
145
|
+
): string => {
|
|
146
|
+
if (arg.variadic) {
|
|
147
|
+
return required ? `<${arg.name}...>` : `[${arg.name}...]`;
|
|
148
|
+
}
|
|
149
|
+
return required ? `<${arg.name}>` : `[${arg.name}]`;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const addArgs = (
|
|
153
|
+
sub: Command,
|
|
154
|
+
cmd: CliCommand,
|
|
155
|
+
options?: { readonly forceOptionalFirstArg?: boolean } | undefined
|
|
156
|
+
): void => {
|
|
157
|
+
for (const [index, arg] of cmd.args.entries()) {
|
|
158
|
+
const template = buildArgTemplate(
|
|
159
|
+
arg,
|
|
160
|
+
options?.forceOptionalFirstArg === true && index === 0 ? false : undefined
|
|
161
|
+
);
|
|
162
|
+
sub.argument(template, arg.description);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Collect positional args from Commander's action callback into a record. */
|
|
167
|
+
const collectPositionalArgs = (
|
|
168
|
+
cmd: CliCommand,
|
|
169
|
+
actionArgs: unknown[]
|
|
170
|
+
): Record<string, unknown> => {
|
|
171
|
+
const parsedArgs: Record<string, unknown> = {};
|
|
172
|
+
for (let i = 0; i < cmd.args.length; i += 1) {
|
|
173
|
+
const argDef = cmd.args[i];
|
|
174
|
+
if (argDef) {
|
|
175
|
+
parsedArgs[argDef.name] = actionArgs[i];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return parsedArgs;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const isUserSuppliedOption = (command: Command, name: string): boolean => {
|
|
182
|
+
const source = command.getOptionValueSource(name);
|
|
183
|
+
return source !== undefined && source !== 'default' && source !== 'implied';
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const getCommandOptionNames = (command: Command): Set<string> =>
|
|
187
|
+
new Set(command.options.map((option) => option.attributeName()));
|
|
188
|
+
|
|
189
|
+
const renderCommandPath = (command: Command): string => {
|
|
190
|
+
const segments: string[] = [];
|
|
191
|
+
let current: Command | null = command;
|
|
192
|
+
while (current !== null && current.parent !== null) {
|
|
193
|
+
segments.push(current.name());
|
|
194
|
+
current = current.parent;
|
|
195
|
+
}
|
|
196
|
+
return segments.toReversed().join(' ');
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const renderOptionName = (option: Option): string =>
|
|
200
|
+
option.long ?? `--${option.attributeName()}`;
|
|
201
|
+
|
|
202
|
+
const INHERITED_SURFACE_OPTION_KEYS = new Set([
|
|
203
|
+
'cwd',
|
|
204
|
+
'devPermit',
|
|
205
|
+
'json',
|
|
206
|
+
'jsonl',
|
|
207
|
+
'output',
|
|
208
|
+
'permit',
|
|
209
|
+
'quiet',
|
|
210
|
+
'token',
|
|
211
|
+
'trace',
|
|
212
|
+
'watch',
|
|
213
|
+
]);
|
|
214
|
+
const commandPathCommands = (command: Command): readonly Command[] => {
|
|
215
|
+
const commands: Command[] = [];
|
|
216
|
+
let current: Command | null = command;
|
|
217
|
+
while (current !== null && current.parent !== null) {
|
|
218
|
+
commands.push(current);
|
|
219
|
+
current = current.parent;
|
|
220
|
+
}
|
|
221
|
+
return commands.toReversed();
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const rootCommandFor = (command: Command): Command => {
|
|
225
|
+
let root = command;
|
|
226
|
+
while (root.parent !== null) {
|
|
227
|
+
root = root.parent;
|
|
228
|
+
}
|
|
229
|
+
return root;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const invocationArgsFor = (command: Command): readonly string[] =>
|
|
233
|
+
rootCommandFor(command).args;
|
|
234
|
+
|
|
235
|
+
const isVariadicOptionValue = (
|
|
236
|
+
command: Command,
|
|
237
|
+
option: Option | undefined,
|
|
238
|
+
token: string
|
|
239
|
+
): boolean =>
|
|
240
|
+
option !== undefined &&
|
|
241
|
+
(!token.startsWith('-') || isNegativeNumberArg(command, token));
|
|
242
|
+
|
|
243
|
+
interface InvocationScan {
|
|
244
|
+
readonly pathEnd: number | undefined;
|
|
245
|
+
readonly structuredInputIndexes: ReadonlySet<number>;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const scanInvocation = (command: Command): InvocationScan => {
|
|
249
|
+
const rawArgs = invocationArgsFor(command);
|
|
250
|
+
const pathCommands = commandPathCommands(command);
|
|
251
|
+
const structuredInputIndexes = new Set<number>();
|
|
252
|
+
let activeCommand = rootCommandFor(command);
|
|
253
|
+
let activeVariadicOption: Option | undefined;
|
|
254
|
+
let optionsEnded = false;
|
|
255
|
+
let pathEnd: number | undefined;
|
|
256
|
+
let pathOffset = 0;
|
|
257
|
+
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
258
|
+
const token = rawArgs[index];
|
|
259
|
+
if (token === undefined) {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (!optionsEnded && token === '--') {
|
|
263
|
+
optionsEnded = true;
|
|
264
|
+
activeVariadicOption = undefined;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (isVariadicOptionValue(command, activeVariadicOption, token)) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
activeVariadicOption = undefined;
|
|
271
|
+
const optionMatches = optionsEnded
|
|
272
|
+
? []
|
|
273
|
+
: invocationOptionMatches(visibleOptionsFor(activeCommand), token);
|
|
274
|
+
const finalOptionMatch = optionMatches.at(-1);
|
|
275
|
+
if (finalOptionMatch !== undefined) {
|
|
276
|
+
if (
|
|
277
|
+
optionMatches.some((match) => structuredInputOptions.has(match.option))
|
|
278
|
+
) {
|
|
279
|
+
structuredInputIndexes.add(index);
|
|
280
|
+
}
|
|
281
|
+
activeVariadicOption =
|
|
282
|
+
finalOptionMatch.option.variadic && !finalOptionMatch.inlineValue
|
|
283
|
+
? finalOptionMatch.option
|
|
284
|
+
: undefined;
|
|
285
|
+
if (
|
|
286
|
+
optionConsumesFollowingValue(
|
|
287
|
+
command,
|
|
288
|
+
finalOptionMatch,
|
|
289
|
+
rawArgs[index + 1]
|
|
290
|
+
)
|
|
291
|
+
) {
|
|
292
|
+
index += 1;
|
|
293
|
+
}
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const nextPathCommand = pathCommands[pathOffset];
|
|
297
|
+
if (nextPathCommand?.name() === token) {
|
|
298
|
+
activeCommand = nextPathCommand;
|
|
299
|
+
pathOffset += 1;
|
|
300
|
+
if (pathOffset === pathCommands.length) {
|
|
301
|
+
pathEnd = index;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return { pathEnd, structuredInputIndexes };
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const hasStructuredInputAfterCommandPath = (command: Command): boolean => {
|
|
309
|
+
const { pathEnd, structuredInputIndexes } = scanInvocation(command);
|
|
310
|
+
if (pathEnd === undefined) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
return [...structuredInputIndexes].some((index) => index > pathEnd);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const hasUserSuppliedOptionOutside = (
|
|
317
|
+
sourceCommand: Command,
|
|
318
|
+
allowedCommand: Command
|
|
319
|
+
): boolean => {
|
|
320
|
+
const allowedOptionNames = getCommandOptionNames(allowedCommand);
|
|
321
|
+
return sourceCommand.options.some((option) => {
|
|
322
|
+
const name = option.attributeName();
|
|
323
|
+
return (
|
|
324
|
+
isUserSuppliedOption(sourceCommand, name) && !allowedOptionNames.has(name)
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const hasAnyPositionalValue = (
|
|
330
|
+
cmd: CliCommand,
|
|
331
|
+
parsedArgs: Readonly<Record<string, unknown>>
|
|
332
|
+
): boolean => cmd.args.some((arg) => parsedArgs[arg.name] !== undefined);
|
|
333
|
+
|
|
334
|
+
const hasUserSuppliedStructuredInput = (
|
|
335
|
+
command: Command,
|
|
336
|
+
fallback?: BareChildFallback | undefined,
|
|
337
|
+
ownOptionsOnly = false
|
|
338
|
+
): boolean => {
|
|
339
|
+
for (const option of command.options) {
|
|
340
|
+
if (!structuredInputOptions.has(option)) {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const name = option.attributeName();
|
|
344
|
+
const source = ownOptionsOnly
|
|
345
|
+
? command.getOptionValueSource(name)
|
|
346
|
+
: command.getOptionValueSourceWithGlobals(name);
|
|
347
|
+
if (source !== undefined && source !== 'default' && source !== 'implied') {
|
|
348
|
+
return (
|
|
349
|
+
fallback === undefined || hasStructuredInputAfterCommandPath(command)
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return false;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const getActionTarget = (fallbackTarget: Command, actionArgs: unknown[]) => {
|
|
357
|
+
const candidate = actionArgs.at(-1);
|
|
358
|
+
return candidate instanceof Command ? candidate : fallbackTarget;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const getParsedFlags = (command: Command): Record<string, unknown> => {
|
|
362
|
+
const flags = command.optsWithGlobals() as Record<string, unknown>;
|
|
363
|
+
const commandOptionNames = getCommandOptionNames(command);
|
|
364
|
+
let { parent } = command;
|
|
365
|
+
while (parent !== null) {
|
|
366
|
+
for (const option of parent.options) {
|
|
367
|
+
const name = option.attributeName();
|
|
368
|
+
if (commandOptionNames.has(name)) {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (
|
|
372
|
+
INHERITED_SURFACE_OPTION_KEYS.has(name) &&
|
|
373
|
+
isUserSuppliedOption(parent, name)
|
|
374
|
+
) {
|
|
375
|
+
flags[name] = parent.getOptionValue(name);
|
|
376
|
+
} else {
|
|
377
|
+
Reflect.deleteProperty(flags, name);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
({ parent } = parent);
|
|
381
|
+
}
|
|
382
|
+
for (const option of command.options) {
|
|
383
|
+
const name = option.attributeName();
|
|
384
|
+
if (command.getOptionValueSource(name) !== undefined) {
|
|
385
|
+
flags[name] = command.getOptionValue(name);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return flags;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const toOptionKey = (name: string): string =>
|
|
392
|
+
name.replaceAll(/-([a-zA-Z0-9])/g, (_, ch: string) => ch.toUpperCase());
|
|
393
|
+
|
|
394
|
+
const getFlagOptionKeys = (flags: readonly CliCommand['flags'][number][]) =>
|
|
395
|
+
new Set(
|
|
396
|
+
flags.flatMap((flag) => [
|
|
397
|
+
toOptionKey(flag.name),
|
|
398
|
+
...(flag.valueAliases ?? []).map((alias) => toOptionKey(alias.name)),
|
|
399
|
+
])
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
const getCanonicalUserSuppliedFlagKeys = (
|
|
403
|
+
flags: readonly CliCommand['flags'][number][],
|
|
404
|
+
userSuppliedFlagKeys: ReadonlySet<string>
|
|
405
|
+
): ReadonlySet<string> => {
|
|
406
|
+
const canonicalKeys = new Set(userSuppliedFlagKeys);
|
|
407
|
+
for (const flag of flags) {
|
|
408
|
+
const flagKey = toOptionKey(flag.name);
|
|
409
|
+
const aliasSelected = (flag.valueAliases ?? []).some((alias) =>
|
|
410
|
+
userSuppliedFlagKeys.has(toOptionKey(alias.name))
|
|
411
|
+
);
|
|
412
|
+
if (aliasSelected) {
|
|
413
|
+
canonicalKeys.add(flagKey);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return canonicalKeys;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const getUserSuppliedFlagKeys = (
|
|
420
|
+
command: Command,
|
|
421
|
+
flags: readonly CliCommand['flags'][number][]
|
|
422
|
+
): ReadonlySet<string> => {
|
|
423
|
+
const userSupplied = new Set<string>();
|
|
424
|
+
for (const key of getFlagOptionKeys(flags)) {
|
|
425
|
+
let owner: Command | null = command;
|
|
426
|
+
while (owner !== null) {
|
|
427
|
+
if (isUserSuppliedOption(owner, key)) {
|
|
428
|
+
userSupplied.add(key);
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
owner = owner.parent;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return userSupplied;
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const getFallbackParsedFlags = (
|
|
438
|
+
parentTarget: Command,
|
|
439
|
+
target: Command
|
|
440
|
+
): Record<string, unknown> => {
|
|
441
|
+
const flags = { ...getParsedFlags(parentTarget) };
|
|
442
|
+
const parentOptionNames = getCommandOptionNames(parentTarget);
|
|
443
|
+
for (const option of target.options) {
|
|
444
|
+
const name = option.attributeName();
|
|
445
|
+
if (parentOptionNames.has(name) && isUserSuppliedOption(target, name)) {
|
|
446
|
+
flags[name] = target.getOptionValue(name);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return flags;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const getFallbackUserSuppliedFlagKeys = (
|
|
453
|
+
parentTarget: Command,
|
|
454
|
+
target: Command,
|
|
455
|
+
flags: readonly CliCommand['flags'][number][]
|
|
456
|
+
): ReadonlySet<string> => {
|
|
457
|
+
const userSupplied = new Set<string>();
|
|
458
|
+
for (const key of getFlagOptionKeys(flags)) {
|
|
459
|
+
if (
|
|
460
|
+
isUserSuppliedOption(parentTarget, key) ||
|
|
461
|
+
isUserSuppliedOption(target, key)
|
|
462
|
+
) {
|
|
463
|
+
userSupplied.add(key);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return userSupplied;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const collectValidationIssueLines = (
|
|
470
|
+
context: Readonly<Record<string, unknown>>
|
|
471
|
+
): readonly string[] => {
|
|
472
|
+
const { issues } = context;
|
|
473
|
+
if (!Array.isArray(issues)) {
|
|
474
|
+
return [];
|
|
475
|
+
}
|
|
476
|
+
return issues.flatMap((issue) => {
|
|
477
|
+
if (typeof issue !== 'object' || issue === null) {
|
|
478
|
+
return [];
|
|
479
|
+
}
|
|
480
|
+
const { message, trailId } = issue as {
|
|
481
|
+
message?: unknown;
|
|
482
|
+
trailId?: unknown;
|
|
483
|
+
};
|
|
484
|
+
if (typeof message !== 'string') {
|
|
485
|
+
return [];
|
|
486
|
+
}
|
|
487
|
+
const suffix = typeof trailId === 'string' ? ` (${trailId})` : '';
|
|
488
|
+
return [`- ${redactErrorString(message)}${suffix}`];
|
|
489
|
+
});
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const collectPermitScopeLines = (
|
|
493
|
+
context: Readonly<Record<string, unknown>>
|
|
494
|
+
): readonly string[] => {
|
|
495
|
+
const candidate = [context['required'], context['missing']].find((value) =>
|
|
496
|
+
Array.isArray(value)
|
|
497
|
+
);
|
|
498
|
+
const scopes = (Array.isArray(candidate) ? candidate : [])
|
|
499
|
+
.filter((scope): scope is string => typeof scope === 'string')
|
|
500
|
+
.map((scope) => redactErrorString(scope));
|
|
501
|
+
if (scopes.length === 0) {
|
|
502
|
+
return [];
|
|
503
|
+
}
|
|
504
|
+
const scopeJson = scopes.map((scope) => JSON.stringify(scope)).join(',');
|
|
505
|
+
return [
|
|
506
|
+
`Required scopes: ${scopes.join(', ')}`,
|
|
507
|
+
`Grant with: --permit '{"id":"<caller-id>","scopes":[${scopeJson}]}'`,
|
|
508
|
+
];
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Collect operator-facing detail lines for an execution error.
|
|
513
|
+
*
|
|
514
|
+
* The public surface rendering intentionally drops structured context, but
|
|
515
|
+
* the CLI is an operator surface: validation issues and permit scope
|
|
516
|
+
* requirements are what the operator needs to act, so they are re-rendered
|
|
517
|
+
* here (through the shared redactor) for non-internal Trails errors.
|
|
518
|
+
*/
|
|
519
|
+
const collectErrorDetailLines = (error: Error): readonly string[] => {
|
|
520
|
+
if (!isTrailsError(error) || error.category === 'internal') {
|
|
521
|
+
return [];
|
|
522
|
+
}
|
|
523
|
+
const context = error.context ?? {};
|
|
524
|
+
if (error.category === 'validation') {
|
|
525
|
+
return collectValidationIssueLines(context);
|
|
526
|
+
}
|
|
527
|
+
if (error.category === 'permission') {
|
|
528
|
+
return collectPermitScopeLines(context);
|
|
529
|
+
}
|
|
530
|
+
return [];
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
const collectErrorContext = (
|
|
534
|
+
error: Error
|
|
535
|
+
): Record<string, unknown> | undefined => {
|
|
536
|
+
if (!isTrailsError(error) || error.category === 'internal') {
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
return redactErrorContext(error.context);
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
interface CliErrorEnvelope {
|
|
543
|
+
readonly ok: false;
|
|
544
|
+
readonly context?: Record<string, unknown> | undefined;
|
|
545
|
+
readonly error: SurfaceErrorRendering;
|
|
546
|
+
readonly details?: readonly string[] | undefined;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
type StructuredErrorMode = 'json' | 'jsonl';
|
|
550
|
+
|
|
551
|
+
const structuredErrorMode = (
|
|
552
|
+
flags: Readonly<Record<string, unknown>>,
|
|
553
|
+
topoName: string,
|
|
554
|
+
userSuppliedFlagKeys: ReadonlySet<string>
|
|
555
|
+
): StructuredErrorMode | undefined => {
|
|
556
|
+
const modeFlags = { ...flags };
|
|
557
|
+
if (!userSuppliedFlagKeys.has('output')) {
|
|
558
|
+
delete modeFlags['output'];
|
|
559
|
+
}
|
|
560
|
+
const { mode } = deriveOutputMode(modeFlags, topoName);
|
|
561
|
+
return mode === 'text' ? undefined : mode;
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const writeStructuredError = (
|
|
565
|
+
envelope: CliErrorEnvelope,
|
|
566
|
+
mode: StructuredErrorMode
|
|
567
|
+
): void => {
|
|
568
|
+
process.stderr.write(
|
|
569
|
+
mode === 'json'
|
|
570
|
+
? `${JSON.stringify(envelope, null, 2)}\n`
|
|
571
|
+
: `${JSON.stringify(envelope)}\n`
|
|
572
|
+
);
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
/** Handle execution errors with appropriate exit codes. */
|
|
576
|
+
const handleError = (
|
|
577
|
+
error: unknown,
|
|
578
|
+
flags: Readonly<Record<string, unknown>>,
|
|
579
|
+
topoName: string,
|
|
580
|
+
userSuppliedFlagKeys: ReadonlySet<string>
|
|
581
|
+
): void => {
|
|
582
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
583
|
+
const rendering = renderPublicSurfaceError('cli', err);
|
|
584
|
+
const context = collectErrorContext(err);
|
|
585
|
+
const details = collectErrorDetailLines(err);
|
|
586
|
+
const mode = structuredErrorMode(flags, topoName, userSuppliedFlagKeys);
|
|
587
|
+
if (mode === undefined) {
|
|
588
|
+
process.stderr.write(`Error: ${rendering.message}\n`);
|
|
589
|
+
for (const line of details) {
|
|
590
|
+
process.stderr.write(` ${line}\n`);
|
|
591
|
+
}
|
|
592
|
+
} else {
|
|
593
|
+
writeStructuredError(
|
|
594
|
+
{
|
|
595
|
+
...(context === undefined ? {} : { context }),
|
|
596
|
+
error: rendering,
|
|
597
|
+
...(details.length === 0 ? {} : { details }),
|
|
598
|
+
ok: false,
|
|
599
|
+
},
|
|
600
|
+
mode
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
process.exit(rendering.code);
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const collectDisallowedAncestorOptions = (
|
|
607
|
+
target: Command,
|
|
608
|
+
allowedFlags: readonly CliCommand['flags'][number][]
|
|
609
|
+
): readonly string[] => {
|
|
610
|
+
const allowedKeys = getFlagOptionKeys(allowedFlags);
|
|
611
|
+
const childPath = renderCommandPath(target);
|
|
612
|
+
const disallowed: string[] = [];
|
|
613
|
+
let { parent } = target;
|
|
614
|
+
|
|
615
|
+
while (parent !== null) {
|
|
616
|
+
const parentPath = renderCommandPath(parent);
|
|
617
|
+
for (const option of parent.options) {
|
|
618
|
+
const name = option.attributeName();
|
|
619
|
+
if (
|
|
620
|
+
isUserSuppliedOption(parent, name) &&
|
|
621
|
+
!allowedKeys.has(name) &&
|
|
622
|
+
!INHERITED_SURFACE_OPTION_KEYS.has(name)
|
|
623
|
+
) {
|
|
624
|
+
disallowed.push(
|
|
625
|
+
`${renderOptionName(option)} belongs to "${parentPath}" and is not supported by "${childPath}".`
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
({ parent } = parent);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return disallowed;
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
interface BareChildFallback {
|
|
636
|
+
readonly argName: string;
|
|
637
|
+
readonly argValue: string;
|
|
638
|
+
readonly parentCommand: CliCommand;
|
|
639
|
+
readonly parentTarget: Command;
|
|
640
|
+
readonly requiresParentSignal: boolean;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const maybeUseBareChildFallback = (
|
|
644
|
+
target: Command,
|
|
645
|
+
cmd: CliCommand,
|
|
646
|
+
parsedArgs: Readonly<Record<string, unknown>>,
|
|
647
|
+
fallback?: BareChildFallback | undefined
|
|
648
|
+
): {
|
|
649
|
+
readonly command: CliCommand;
|
|
650
|
+
readonly parsedArgs: Record<string, unknown>;
|
|
651
|
+
readonly parsedFlags: Record<string, unknown>;
|
|
652
|
+
readonly userSuppliedFlagKeys: ReadonlySet<string>;
|
|
653
|
+
} => {
|
|
654
|
+
const hasParentOnlySignal =
|
|
655
|
+
fallback !== undefined &&
|
|
656
|
+
(hasUserSuppliedOptionOutside(fallback.parentTarget, target) ||
|
|
657
|
+
(hasUserSuppliedStructuredInput(fallback.parentTarget, undefined, true) &&
|
|
658
|
+
!hasStructuredInputAfterCommandPath(target)));
|
|
659
|
+
if (
|
|
660
|
+
!fallback ||
|
|
661
|
+
hasAnyPositionalValue(cmd, parsedArgs) ||
|
|
662
|
+
hasUserSuppliedStructuredInput(target, fallback) ||
|
|
663
|
+
hasUserSuppliedOptionOutside(target, fallback.parentTarget) ||
|
|
664
|
+
(fallback.requiresParentSignal && !hasParentOnlySignal)
|
|
665
|
+
) {
|
|
666
|
+
return {
|
|
667
|
+
command: cmd,
|
|
668
|
+
parsedArgs: { ...parsedArgs },
|
|
669
|
+
parsedFlags: getParsedFlags(target),
|
|
670
|
+
userSuppliedFlagKeys: getUserSuppliedFlagKeys(target, cmd.flags),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return {
|
|
675
|
+
command: fallback.parentCommand,
|
|
676
|
+
parsedArgs: { [fallback.argName]: fallback.argValue },
|
|
677
|
+
parsedFlags: getFallbackParsedFlags(fallback.parentTarget, target),
|
|
678
|
+
userSuppliedFlagKeys: getFallbackUserSuppliedFlagKeys(
|
|
679
|
+
fallback.parentTarget,
|
|
680
|
+
target,
|
|
681
|
+
fallback.parentCommand.flags
|
|
682
|
+
),
|
|
683
|
+
};
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
/** Wire a CliCommand's action to a Commander subcommand. */
|
|
687
|
+
const wireAction = (
|
|
688
|
+
target: Command,
|
|
689
|
+
cmd: CliCommand,
|
|
690
|
+
topoName: string,
|
|
691
|
+
fallback?: BareChildFallback | undefined
|
|
692
|
+
): void => {
|
|
693
|
+
target.action(async (...actionArgs: unknown[]) => {
|
|
694
|
+
const actionTarget = getActionTarget(target, actionArgs);
|
|
695
|
+
const parsedArgs = collectPositionalArgs(cmd, actionArgs);
|
|
696
|
+
const action = maybeUseBareChildFallback(
|
|
697
|
+
actionTarget,
|
|
698
|
+
cmd,
|
|
699
|
+
parsedArgs,
|
|
700
|
+
fallback === undefined
|
|
701
|
+
? undefined
|
|
702
|
+
: {
|
|
703
|
+
...fallback,
|
|
704
|
+
parentTarget: actionTarget.parent ?? fallback.parentTarget,
|
|
705
|
+
}
|
|
706
|
+
);
|
|
707
|
+
let { parsedFlags } = action;
|
|
708
|
+
try {
|
|
709
|
+
const disallowedAncestorOptions = collectDisallowedAncestorOptions(
|
|
710
|
+
actionTarget,
|
|
711
|
+
action.command.flags
|
|
712
|
+
);
|
|
713
|
+
if (disallowedAncestorOptions.length > 0) {
|
|
714
|
+
throw new ValidationError('Unsupported option for this CLI command.', {
|
|
715
|
+
context: {
|
|
716
|
+
issues: disallowedAncestorOptions.map((message) => ({
|
|
717
|
+
message,
|
|
718
|
+
trailId: action.command.trail.id,
|
|
719
|
+
})),
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
parsedFlags = applyCliFlagValueAliases(
|
|
724
|
+
action.command.flags,
|
|
725
|
+
action.parsedFlags,
|
|
726
|
+
action.userSuppliedFlagKeys
|
|
727
|
+
);
|
|
728
|
+
await action.command.execute(action.parsedArgs, parsedFlags, undefined, {
|
|
729
|
+
userSuppliedFlagKeys: getCanonicalUserSuppliedFlagKeys(
|
|
730
|
+
action.command.flags,
|
|
731
|
+
action.userSuppliedFlagKeys
|
|
732
|
+
),
|
|
733
|
+
});
|
|
734
|
+
} catch (error: unknown) {
|
|
735
|
+
handleError(
|
|
736
|
+
error,
|
|
737
|
+
parsedFlags,
|
|
738
|
+
topoName,
|
|
739
|
+
getCanonicalUserSuppliedFlagKeys(
|
|
740
|
+
action.command.flags,
|
|
741
|
+
action.userSuppliedFlagKeys
|
|
742
|
+
)
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
/** Apply options to a Commander program. */
|
|
749
|
+
const applyOptions = (program: Command, options?: ToCommanderOptions): void => {
|
|
750
|
+
if (options?.name) {
|
|
751
|
+
program.name(options.name);
|
|
752
|
+
}
|
|
753
|
+
if (options?.version) {
|
|
754
|
+
program.version(options.version);
|
|
755
|
+
}
|
|
756
|
+
if (options?.description) {
|
|
757
|
+
program.description(options.description);
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
// ---------------------------------------------------------------------------
|
|
762
|
+
// toCommander
|
|
763
|
+
// ---------------------------------------------------------------------------
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Convert CliCommand[] into a configured Commander program.
|
|
767
|
+
*
|
|
768
|
+
* Builds a nested command tree from each command's full ordered path.
|
|
769
|
+
* Wires each command's `.action()` to call `execute()` and handle errors.
|
|
770
|
+
*/
|
|
771
|
+
const pathKey = (path: readonly string[]): string => path.join('\0');
|
|
772
|
+
|
|
773
|
+
interface CommandNodeState {
|
|
774
|
+
readonly command: Command;
|
|
775
|
+
cliCommand?: CliCommand | undefined;
|
|
776
|
+
executable: boolean;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const getPathSegment = (path: readonly string[], index: number): string => {
|
|
780
|
+
const segment = path[index];
|
|
781
|
+
if (segment === undefined) {
|
|
782
|
+
throw new Error('CLI command path contains an undefined segment');
|
|
783
|
+
}
|
|
784
|
+
return segment;
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
const getOrCreateCommandNode = (
|
|
788
|
+
key: string,
|
|
789
|
+
segment: string,
|
|
790
|
+
parent: Command,
|
|
791
|
+
nodes: Map<string, CommandNodeState>
|
|
792
|
+
): CommandNodeState => {
|
|
793
|
+
const existing = nodes.get(key);
|
|
794
|
+
if (existing) {
|
|
795
|
+
return existing;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const command = new Command(segment);
|
|
799
|
+
const state = { command, executable: false };
|
|
800
|
+
nodes.set(key, state);
|
|
801
|
+
parent.addCommand(command);
|
|
802
|
+
return state;
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const ensureCommandNode = (
|
|
806
|
+
path: readonly string[],
|
|
807
|
+
program: Command,
|
|
808
|
+
nodes: Map<string, CommandNodeState>
|
|
809
|
+
): CommandNodeState => {
|
|
810
|
+
let parent = program;
|
|
811
|
+
let state: CommandNodeState | undefined;
|
|
812
|
+
|
|
813
|
+
for (let index = 0; index < path.length; index += 1) {
|
|
814
|
+
const segment = getPathSegment(path, index);
|
|
815
|
+
const key = pathKey(path.slice(0, index + 1));
|
|
816
|
+
state = getOrCreateCommandNode(key, segment, parent, nodes);
|
|
817
|
+
parent = state.command;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (!state) {
|
|
821
|
+
throw new Error('CLI command path cannot be empty');
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return state;
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
const createBareChildFallback = (
|
|
828
|
+
cmd: CliCommand,
|
|
829
|
+
path: readonly string[],
|
|
830
|
+
parentState?: CommandNodeState | undefined
|
|
831
|
+
): BareChildFallback | undefined => {
|
|
832
|
+
if (!parentState?.cliCommand || path.length < 2) {
|
|
833
|
+
return undefined;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const [parentArg] = parentState.cliCommand.args;
|
|
837
|
+
const [childArg] = cmd.args;
|
|
838
|
+
const childSegment = path.at(-1);
|
|
839
|
+
if (
|
|
840
|
+
parentArg === undefined ||
|
|
841
|
+
parentArg.required ||
|
|
842
|
+
parentArg.variadic ||
|
|
843
|
+
(childArg !== undefined &&
|
|
844
|
+
(childArg.variadic || childArg.name !== parentArg.name)) ||
|
|
845
|
+
childSegment === undefined
|
|
846
|
+
) {
|
|
847
|
+
return undefined;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return {
|
|
851
|
+
argName: parentArg.name,
|
|
852
|
+
argValue: childSegment,
|
|
853
|
+
parentCommand: parentState.cliCommand,
|
|
854
|
+
parentTarget: parentState.command,
|
|
855
|
+
requiresParentSignal: childArg === undefined,
|
|
856
|
+
};
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
const applyCliCommand = (
|
|
860
|
+
state: CommandNodeState,
|
|
861
|
+
cmd: CliCommand,
|
|
862
|
+
path: readonly string[],
|
|
863
|
+
topoName: string,
|
|
864
|
+
fallback?: BareChildFallback | undefined
|
|
865
|
+
): void => {
|
|
866
|
+
if (state.executable) {
|
|
867
|
+
throw new Error(`Duplicate CLI path: ${path.join(' ')}`);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (cmd.description) {
|
|
871
|
+
state.command.description(cmd.description);
|
|
872
|
+
}
|
|
873
|
+
for (const flag of cmd.flags) {
|
|
874
|
+
for (const opt of buildOptions(flag)) {
|
|
875
|
+
state.command.addOption(opt);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
addArgs(state.command, cmd, {
|
|
879
|
+
forceOptionalFirstArg: fallback !== undefined,
|
|
880
|
+
});
|
|
881
|
+
wireAction(state.command, cmd, topoName, fallback);
|
|
882
|
+
state.cliCommand = cmd;
|
|
883
|
+
state.executable = true;
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
const commandRoutes = (cmd: CliCommand) =>
|
|
887
|
+
cmd.routes ?? [
|
|
888
|
+
{
|
|
889
|
+
kind: 'canonical' as const,
|
|
890
|
+
path: cmd.path,
|
|
891
|
+
source: 'derived' as const,
|
|
892
|
+
target: cmd.trail.id,
|
|
893
|
+
},
|
|
894
|
+
];
|
|
895
|
+
|
|
896
|
+
const commandRouteEntries = (commands: readonly CliCommand[]) =>
|
|
897
|
+
commands.flatMap((cmd) =>
|
|
898
|
+
commandRoutes(cmd).map((route) => ({ cmd, path: route.path }))
|
|
899
|
+
);
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Convert framework-agnostic CLI commands into a Commander program.
|
|
903
|
+
*
|
|
904
|
+
* @example
|
|
905
|
+
* ```ts
|
|
906
|
+
* import { deriveCliCommands } from '@ontrails/cli';
|
|
907
|
+
* import { toCommander } from '@ontrails/commander';
|
|
908
|
+
*
|
|
909
|
+
* const commands = deriveCliCommands(graph);
|
|
910
|
+
* if (commands.isErr()) throw commands.error;
|
|
911
|
+
*
|
|
912
|
+
* const program = toCommander(commands.value, {
|
|
913
|
+
* name: 'demo',
|
|
914
|
+
* topoName: 'demo',
|
|
915
|
+
* });
|
|
916
|
+
* ```
|
|
917
|
+
*/
|
|
918
|
+
export const toCommander = (
|
|
919
|
+
commands: CliCommand[],
|
|
920
|
+
options?: ToCommanderOptions
|
|
921
|
+
): Command => {
|
|
922
|
+
validateCliCommands(commands);
|
|
923
|
+
const program = new TrailsCommanderProgram(commands);
|
|
924
|
+
applyOptions(program, options);
|
|
925
|
+
const topoName = options?.topoName ?? options?.name ?? program.name();
|
|
926
|
+
const nodes = new Map<string, CommandNodeState>();
|
|
927
|
+
|
|
928
|
+
for (const { cmd, path } of commandRouteEntries(commands).toSorted((a, b) =>
|
|
929
|
+
a.path.length === b.path.length
|
|
930
|
+
? a.path.join('.').localeCompare(b.path.join('.'))
|
|
931
|
+
: a.path.length - b.path.length
|
|
932
|
+
)) {
|
|
933
|
+
const state = ensureCommandNode(path, program, nodes);
|
|
934
|
+
const parentKey = pathKey(path.slice(0, -1));
|
|
935
|
+
const fallback = createBareChildFallback(cmd, path, nodes.get(parentKey));
|
|
936
|
+
applyCliCommand(state, cmd, path, topoName, fallback);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return program;
|
|
940
|
+
};
|