@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.
@@ -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
+ };