@ontrails/commander 1.0.0-beta.24 → 1.0.0-beta.29
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 +49 -0
- package/package.json +3 -3
- package/src/surface.ts +6 -0
- package/src/to-commander.ts +346 -34
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# @ontrails/commander
|
|
2
2
|
|
|
3
|
+
## 1.0.0-beta.29
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- @ontrails/cli@1.0.0-beta.29
|
|
8
|
+
- @ontrails/core@1.0.0-beta.29
|
|
9
|
+
|
|
10
|
+
## 1.0.0-beta.28
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- @ontrails/cli@1.0.0-beta.28
|
|
15
|
+
- @ontrails/core@1.0.0-beta.28
|
|
16
|
+
|
|
17
|
+
## 1.0.0-beta.27
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- @ontrails/cli@1.0.0-beta.27
|
|
22
|
+
- @ontrails/core@1.0.0-beta.27
|
|
23
|
+
|
|
24
|
+
## 1.0.0-beta.26
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- Updated dependencies [1307568]
|
|
29
|
+
- Updated dependencies [371d19e]
|
|
30
|
+
- @ontrails/core@1.0.0-beta.26
|
|
31
|
+
- @ontrails/cli@1.0.0-beta.26
|
|
32
|
+
|
|
33
|
+
## 1.0.0-beta.25
|
|
34
|
+
|
|
35
|
+
### Patch Changes
|
|
36
|
+
|
|
37
|
+
- 60caabf: Render operator-actionable detail lines after CLI execution errors: validation failures list their topo issues (message plus trail id) and permission failures name the required permit scopes with a copyable `--permit` form. Non-internal Trails error context only, passed through the shared redactor; internal errors keep the redacted generic message.
|
|
38
|
+
- dbf4ff4: Emit structured CLI error envelopes for JSON/JSONL command failures and map compile-time Trails DB lock contention to a retryable timeout instead of a generic internal error.
|
|
39
|
+
- f1e6efa: Prevent executable parent command defaults from leaking into nested child commands.
|
|
40
|
+
- a8e4dc3: Clean up the Wayfinder navigation grammar before RC, including explicit pattern/query/file selectors, target-bound dependency and impact flags, drift-first provenance fields, stricter fires declaration diagnostics, and updated operator dogfood coverage.
|
|
41
|
+
- 1d3ae74: Materialize resolved CLI command aliases through the Commander surface while
|
|
42
|
+
preserving the same trail contract and execution path.
|
|
43
|
+
- Updated dependencies [c36aca9]
|
|
44
|
+
- Updated dependencies [3befcf1]
|
|
45
|
+
- Updated dependencies [f1e6efa]
|
|
46
|
+
- Updated dependencies [a4f9cf6]
|
|
47
|
+
- Updated dependencies [9bcf34e]
|
|
48
|
+
- Updated dependencies [f7d97fc]
|
|
49
|
+
- @ontrails/core@1.0.0-beta.25
|
|
50
|
+
- @ontrails/cli@1.0.0-beta.25
|
|
51
|
+
|
|
3
52
|
## 1.0.0-beta.24
|
|
4
53
|
|
|
5
54
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ontrails/commander",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.29",
|
|
4
4
|
"files": [
|
|
5
5
|
"src/**/*.ts",
|
|
6
6
|
"!src/**/__tests__/**",
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"clean": "rm -rf dist *.tsbuildinfo"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@ontrails/cli": "^1.0.0-beta.
|
|
26
|
-
"@ontrails/core": "^1.0.0-beta.
|
|
25
|
+
"@ontrails/cli": "^1.0.0-beta.29",
|
|
26
|
+
"@ontrails/core": "^1.0.0-beta.29",
|
|
27
27
|
"commander": "^14.0.3"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
package/src/surface.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
6
|
BaseSurfaceOptions,
|
|
7
|
+
CliCommandAliasInput,
|
|
7
8
|
Layer,
|
|
8
9
|
ResourceOverrideMap,
|
|
9
10
|
Topo,
|
|
@@ -27,6 +28,9 @@ import { toCommander } from './to-commander.js';
|
|
|
27
28
|
* Options for creating Commander CLI surfaces from a Trails topo.
|
|
28
29
|
*/
|
|
29
30
|
export interface CreateProgramOptions extends BaseSurfaceOptions {
|
|
31
|
+
readonly aliases?:
|
|
32
|
+
| Readonly<Record<string, readonly CliCommandAliasInput[]>>
|
|
33
|
+
| undefined;
|
|
30
34
|
readonly createContext?:
|
|
31
35
|
| (() => TrailContextInit | Promise<TrailContextInit>)
|
|
32
36
|
| undefined;
|
|
@@ -58,6 +62,7 @@ const deriveCommanderOptions = (
|
|
|
58
62
|
): ToCommanderOptions => {
|
|
59
63
|
const commanderOpts: ToCommanderOptions = {
|
|
60
64
|
name: options.name ?? graph.name,
|
|
65
|
+
topoName: graph.name,
|
|
61
66
|
};
|
|
62
67
|
if (options.version !== undefined || graph.version !== undefined) {
|
|
63
68
|
commanderOpts.version = options.version ?? graph.version;
|
|
@@ -88,6 +93,7 @@ export const createProgram = (
|
|
|
88
93
|
options: CreateProgramOptions = {}
|
|
89
94
|
) => {
|
|
90
95
|
const commandsResult = deriveCliCommands(graph, {
|
|
96
|
+
aliases: options.aliases,
|
|
91
97
|
configValues: options.configValues,
|
|
92
98
|
createContext: options.createContext,
|
|
93
99
|
exclude: options.exclude,
|
package/src/to-commander.ts
CHANGED
|
@@ -2,9 +2,20 @@
|
|
|
2
2
|
* Adapt framework-agnostic CliCommand[] to a Commander program.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
isTrailsError,
|
|
7
|
+
projectPublicSurfaceError,
|
|
8
|
+
redactErrorContext,
|
|
9
|
+
redactErrorString,
|
|
10
|
+
ValidationError,
|
|
11
|
+
} from '@ontrails/core';
|
|
12
|
+
import type { SurfaceErrorProjection } from '@ontrails/core';
|
|
6
13
|
import type { CliCommand, CliFlag } from '@ontrails/cli';
|
|
7
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
applyCliFlagValueAliases,
|
|
16
|
+
deriveOutputMode,
|
|
17
|
+
validateCliCommands,
|
|
18
|
+
} from '@ontrails/cli';
|
|
8
19
|
import { Command, InvalidArgumentError, Option } from 'commander';
|
|
9
20
|
|
|
10
21
|
// ---------------------------------------------------------------------------
|
|
@@ -17,6 +28,7 @@ import { Command, InvalidArgumentError, Option } from 'commander';
|
|
|
17
28
|
export interface ToCommanderOptions {
|
|
18
29
|
description?: string | undefined;
|
|
19
30
|
name?: string | undefined;
|
|
31
|
+
topoName?: string | undefined;
|
|
20
32
|
version?: string | undefined;
|
|
21
33
|
}
|
|
22
34
|
|
|
@@ -53,8 +65,34 @@ const strictParseNumber = (value: string): number => {
|
|
|
53
65
|
return n;
|
|
54
66
|
};
|
|
55
67
|
|
|
68
|
+
const validateChoiceValue = (flag: CliFlag, value: string): void => {
|
|
69
|
+
if (flag.choices && !flag.choices.includes(value)) {
|
|
70
|
+
throw new InvalidArgumentError(
|
|
71
|
+
`Allowed choices are ${flag.choices.join(', ')}.`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const isRepeatableArrayFlag = (flag: CliFlag): boolean =>
|
|
77
|
+
!flag.variadic && (flag.type === 'number[]' || flag.type === 'string[]');
|
|
78
|
+
|
|
79
|
+
const buildRepeatableArrayParser =
|
|
80
|
+
(flag: CliFlag) =>
|
|
81
|
+
(value: string, previous: unknown): readonly (number | string)[] => {
|
|
82
|
+
const parsed = flag.type === 'number[]' ? strictParseNumber(value) : value;
|
|
83
|
+
validateChoiceValue(flag, String(parsed));
|
|
84
|
+
return [...(Array.isArray(previous) ? previous : []), parsed];
|
|
85
|
+
};
|
|
86
|
+
|
|
56
87
|
/** Apply common modifiers (choices, default, arg parser) to a Commander Option. */
|
|
57
88
|
const applyOptionModifiers = (opt: Option, flag: CliFlag): void => {
|
|
89
|
+
if (isRepeatableArrayFlag(flag)) {
|
|
90
|
+
opt.argParser(buildRepeatableArrayParser(flag));
|
|
91
|
+
if (flag.default !== undefined) {
|
|
92
|
+
opt.default(flag.default);
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
58
96
|
if (flag.choices) {
|
|
59
97
|
opt.choices(flag.choices);
|
|
60
98
|
}
|
|
@@ -135,6 +173,32 @@ const isUserSuppliedOption = (command: Command, name: string): boolean => {
|
|
|
135
173
|
const getCommandOptionNames = (command: Command): Set<string> =>
|
|
136
174
|
new Set(command.options.map((option) => option.attributeName()));
|
|
137
175
|
|
|
176
|
+
const renderCommandPath = (command: Command): string => {
|
|
177
|
+
const segments: string[] = [];
|
|
178
|
+
let current: Command | null = command;
|
|
179
|
+
while (current !== null && current.parent !== null) {
|
|
180
|
+
segments.push(current.name());
|
|
181
|
+
current = current.parent;
|
|
182
|
+
}
|
|
183
|
+
return segments.toReversed().join(' ');
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const renderOptionName = (option: Option): string =>
|
|
187
|
+
option.long ?? `--${option.attributeName()}`;
|
|
188
|
+
|
|
189
|
+
const INHERITED_SURFACE_OPTION_KEYS = new Set([
|
|
190
|
+
'cwd',
|
|
191
|
+
'devPermit',
|
|
192
|
+
'json',
|
|
193
|
+
'jsonl',
|
|
194
|
+
'output',
|
|
195
|
+
'permit',
|
|
196
|
+
'quiet',
|
|
197
|
+
'token',
|
|
198
|
+
'trace',
|
|
199
|
+
'watch',
|
|
200
|
+
]);
|
|
201
|
+
|
|
138
202
|
const hasUserSuppliedOptionOutside = (
|
|
139
203
|
sourceCommand: Command,
|
|
140
204
|
allowedCommand: Command
|
|
@@ -158,23 +222,64 @@ const getActionTarget = (fallbackTarget: Command, actionArgs: unknown[]) => {
|
|
|
158
222
|
return candidate instanceof Command ? candidate : fallbackTarget;
|
|
159
223
|
};
|
|
160
224
|
|
|
161
|
-
const getParsedFlags = (command: Command): Record<string, unknown> =>
|
|
162
|
-
command.optsWithGlobals() as Record<string, unknown>;
|
|
225
|
+
const getParsedFlags = (command: Command): Record<string, unknown> => {
|
|
226
|
+
const flags = command.optsWithGlobals() as Record<string, unknown>;
|
|
227
|
+
const commandOptionNames = getCommandOptionNames(command);
|
|
228
|
+
let { parent } = command;
|
|
229
|
+
while (parent !== null) {
|
|
230
|
+
for (const option of parent.options) {
|
|
231
|
+
const name = option.attributeName();
|
|
232
|
+
if (commandOptionNames.has(name)) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (
|
|
236
|
+
INHERITED_SURFACE_OPTION_KEYS.has(name) &&
|
|
237
|
+
isUserSuppliedOption(parent, name)
|
|
238
|
+
) {
|
|
239
|
+
flags[name] = parent.getOptionValue(name);
|
|
240
|
+
} else {
|
|
241
|
+
Reflect.deleteProperty(flags, name);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
({ parent } = parent);
|
|
245
|
+
}
|
|
246
|
+
for (const option of command.options) {
|
|
247
|
+
const name = option.attributeName();
|
|
248
|
+
if (command.getOptionValueSource(name) !== undefined) {
|
|
249
|
+
flags[name] = command.getOptionValue(name);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return flags;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const toOptionKey = (name: string): string =>
|
|
256
|
+
name.replaceAll(/-([a-zA-Z0-9])/g, (_, ch: string) => ch.toUpperCase());
|
|
163
257
|
|
|
164
258
|
const getFlagOptionKeys = (flags: readonly CliCommand['flags'][number][]) =>
|
|
165
259
|
new Set(
|
|
166
260
|
flags.flatMap((flag) => [
|
|
167
|
-
flag.name
|
|
168
|
-
|
|
169
|
-
),
|
|
170
|
-
...(flag.valueAliases ?? []).map((alias) =>
|
|
171
|
-
alias.name.replaceAll(/-([a-zA-Z0-9])/g, (_, ch: string) =>
|
|
172
|
-
ch.toUpperCase()
|
|
173
|
-
)
|
|
174
|
-
),
|
|
261
|
+
toOptionKey(flag.name),
|
|
262
|
+
...(flag.valueAliases ?? []).map((alias) => toOptionKey(alias.name)),
|
|
175
263
|
])
|
|
176
264
|
);
|
|
177
265
|
|
|
266
|
+
const getCanonicalUserSuppliedFlagKeys = (
|
|
267
|
+
flags: readonly CliCommand['flags'][number][],
|
|
268
|
+
userSuppliedFlagKeys: ReadonlySet<string>
|
|
269
|
+
): ReadonlySet<string> => {
|
|
270
|
+
const canonicalKeys = new Set(userSuppliedFlagKeys);
|
|
271
|
+
for (const flag of flags) {
|
|
272
|
+
const flagKey = toOptionKey(flag.name);
|
|
273
|
+
const aliasSelected = (flag.valueAliases ?? []).some((alias) =>
|
|
274
|
+
userSuppliedFlagKeys.has(toOptionKey(alias.name))
|
|
275
|
+
);
|
|
276
|
+
if (aliasSelected) {
|
|
277
|
+
canonicalKeys.add(flagKey);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return canonicalKeys;
|
|
281
|
+
};
|
|
282
|
+
|
|
178
283
|
const getUserSuppliedFlagKeys = (
|
|
179
284
|
command: Command,
|
|
180
285
|
flags: readonly CliCommand['flags'][number][]
|
|
@@ -220,19 +325,178 @@ const getFallbackUserSuppliedFlagKeys = (
|
|
|
220
325
|
return userSupplied;
|
|
221
326
|
};
|
|
222
327
|
|
|
328
|
+
const collectValidationIssueLines = (
|
|
329
|
+
context: Readonly<Record<string, unknown>>
|
|
330
|
+
): readonly string[] => {
|
|
331
|
+
const { issues } = context;
|
|
332
|
+
if (!Array.isArray(issues)) {
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
return issues.flatMap((issue) => {
|
|
336
|
+
if (typeof issue !== 'object' || issue === null) {
|
|
337
|
+
return [];
|
|
338
|
+
}
|
|
339
|
+
const { message, trailId } = issue as {
|
|
340
|
+
message?: unknown;
|
|
341
|
+
trailId?: unknown;
|
|
342
|
+
};
|
|
343
|
+
if (typeof message !== 'string') {
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
const suffix = typeof trailId === 'string' ? ` (${trailId})` : '';
|
|
347
|
+
return [`- ${redactErrorString(message)}${suffix}`];
|
|
348
|
+
});
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const collectPermitScopeLines = (
|
|
352
|
+
context: Readonly<Record<string, unknown>>
|
|
353
|
+
): readonly string[] => {
|
|
354
|
+
const candidate = [context['required'], context['missing']].find((value) =>
|
|
355
|
+
Array.isArray(value)
|
|
356
|
+
);
|
|
357
|
+
const scopes = (Array.isArray(candidate) ? candidate : [])
|
|
358
|
+
.filter((scope): scope is string => typeof scope === 'string')
|
|
359
|
+
.map((scope) => redactErrorString(scope));
|
|
360
|
+
if (scopes.length === 0) {
|
|
361
|
+
return [];
|
|
362
|
+
}
|
|
363
|
+
const scopeJson = scopes.map((scope) => JSON.stringify(scope)).join(',');
|
|
364
|
+
return [
|
|
365
|
+
`Required scopes: ${scopes.join(', ')}`,
|
|
366
|
+
`Grant with: --permit '{"id":"<caller-id>","scopes":[${scopeJson}]}'`,
|
|
367
|
+
];
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Collect operator-facing detail lines for an execution error.
|
|
372
|
+
*
|
|
373
|
+
* The public surface projection intentionally drops structured context, but
|
|
374
|
+
* the CLI is an operator surface: validation issues and permit scope
|
|
375
|
+
* requirements are what the operator needs to act, so they are re-rendered
|
|
376
|
+
* here (through the shared redactor) for non-internal Trails errors.
|
|
377
|
+
*/
|
|
378
|
+
const collectErrorDetailLines = (error: Error): readonly string[] => {
|
|
379
|
+
if (!isTrailsError(error) || error.category === 'internal') {
|
|
380
|
+
return [];
|
|
381
|
+
}
|
|
382
|
+
const context = error.context ?? {};
|
|
383
|
+
if (error.category === 'validation') {
|
|
384
|
+
return collectValidationIssueLines(context);
|
|
385
|
+
}
|
|
386
|
+
if (error.category === 'permission') {
|
|
387
|
+
return collectPermitScopeLines(context);
|
|
388
|
+
}
|
|
389
|
+
return [];
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const collectErrorContext = (
|
|
393
|
+
error: Error
|
|
394
|
+
): Record<string, unknown> | undefined => {
|
|
395
|
+
if (!isTrailsError(error) || error.category === 'internal') {
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
return redactErrorContext(error.context);
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
interface CliErrorEnvelope {
|
|
402
|
+
readonly ok: false;
|
|
403
|
+
readonly context?: Record<string, unknown> | undefined;
|
|
404
|
+
readonly error: SurfaceErrorProjection;
|
|
405
|
+
readonly details?: readonly string[] | undefined;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
type StructuredErrorMode = 'json' | 'jsonl';
|
|
409
|
+
|
|
410
|
+
const structuredErrorMode = (
|
|
411
|
+
flags: Readonly<Record<string, unknown>>,
|
|
412
|
+
topoName: string,
|
|
413
|
+
userSuppliedFlagKeys: ReadonlySet<string>
|
|
414
|
+
): StructuredErrorMode | undefined => {
|
|
415
|
+
const modeFlags = { ...flags };
|
|
416
|
+
if (!userSuppliedFlagKeys.has('output')) {
|
|
417
|
+
delete modeFlags['output'];
|
|
418
|
+
}
|
|
419
|
+
const { mode } = deriveOutputMode(modeFlags, topoName);
|
|
420
|
+
return mode === 'text' ? undefined : mode;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const writeStructuredError = (
|
|
424
|
+
envelope: CliErrorEnvelope,
|
|
425
|
+
mode: StructuredErrorMode
|
|
426
|
+
): void => {
|
|
427
|
+
process.stderr.write(
|
|
428
|
+
mode === 'json'
|
|
429
|
+
? `${JSON.stringify(envelope, null, 2)}\n`
|
|
430
|
+
: `${JSON.stringify(envelope)}\n`
|
|
431
|
+
);
|
|
432
|
+
};
|
|
433
|
+
|
|
223
434
|
/** Handle execution errors with appropriate exit codes. */
|
|
224
|
-
const handleError = (
|
|
435
|
+
const handleError = (
|
|
436
|
+
error: unknown,
|
|
437
|
+
flags: Readonly<Record<string, unknown>>,
|
|
438
|
+
topoName: string,
|
|
439
|
+
userSuppliedFlagKeys: ReadonlySet<string>
|
|
440
|
+
): void => {
|
|
225
441
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
226
442
|
const projection = projectPublicSurfaceError('cli', err);
|
|
227
|
-
|
|
443
|
+
const context = collectErrorContext(err);
|
|
444
|
+
const details = collectErrorDetailLines(err);
|
|
445
|
+
const mode = structuredErrorMode(flags, topoName, userSuppliedFlagKeys);
|
|
446
|
+
if (mode === undefined) {
|
|
447
|
+
process.stderr.write(`Error: ${projection.message}\n`);
|
|
448
|
+
for (const line of details) {
|
|
449
|
+
process.stderr.write(` ${line}\n`);
|
|
450
|
+
}
|
|
451
|
+
} else {
|
|
452
|
+
writeStructuredError(
|
|
453
|
+
{
|
|
454
|
+
...(context === undefined ? {} : { context }),
|
|
455
|
+
error: projection,
|
|
456
|
+
...(details.length === 0 ? {} : { details }),
|
|
457
|
+
ok: false,
|
|
458
|
+
},
|
|
459
|
+
mode
|
|
460
|
+
);
|
|
461
|
+
}
|
|
228
462
|
process.exit(projection.code);
|
|
229
463
|
};
|
|
230
464
|
|
|
465
|
+
const collectDisallowedAncestorOptions = (
|
|
466
|
+
target: Command,
|
|
467
|
+
allowedFlags: readonly CliCommand['flags'][number][]
|
|
468
|
+
): readonly string[] => {
|
|
469
|
+
const allowedKeys = getFlagOptionKeys(allowedFlags);
|
|
470
|
+
const childPath = renderCommandPath(target);
|
|
471
|
+
const disallowed: string[] = [];
|
|
472
|
+
let { parent } = target;
|
|
473
|
+
|
|
474
|
+
while (parent !== null) {
|
|
475
|
+
const parentPath = renderCommandPath(parent);
|
|
476
|
+
for (const option of parent.options) {
|
|
477
|
+
const name = option.attributeName();
|
|
478
|
+
if (
|
|
479
|
+
isUserSuppliedOption(parent, name) &&
|
|
480
|
+
!allowedKeys.has(name) &&
|
|
481
|
+
!INHERITED_SURFACE_OPTION_KEYS.has(name)
|
|
482
|
+
) {
|
|
483
|
+
disallowed.push(
|
|
484
|
+
`${renderOptionName(option)} belongs to "${parentPath}" and is not supported by "${childPath}".`
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
({ parent } = parent);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return disallowed;
|
|
492
|
+
};
|
|
493
|
+
|
|
231
494
|
interface BareChildFallback {
|
|
232
495
|
readonly argName: string;
|
|
233
496
|
readonly argValue: string;
|
|
234
497
|
readonly parentCommand: CliCommand;
|
|
235
498
|
readonly parentTarget: Command;
|
|
499
|
+
readonly requiresParentSignal: boolean;
|
|
236
500
|
}
|
|
237
501
|
|
|
238
502
|
const maybeUseBareChildFallback = (
|
|
@@ -246,10 +510,14 @@ const maybeUseBareChildFallback = (
|
|
|
246
510
|
readonly parsedFlags: Record<string, unknown>;
|
|
247
511
|
readonly userSuppliedFlagKeys: ReadonlySet<string>;
|
|
248
512
|
} => {
|
|
513
|
+
const hasParentOnlySignal = fallback
|
|
514
|
+
? hasUserSuppliedOptionOutside(fallback.parentTarget, target)
|
|
515
|
+
: false;
|
|
249
516
|
if (
|
|
250
517
|
!fallback ||
|
|
251
518
|
hasAnyPositionalValue(cmd, parsedArgs) ||
|
|
252
|
-
hasUserSuppliedOptionOutside(target, fallback.parentTarget)
|
|
519
|
+
hasUserSuppliedOptionOutside(target, fallback.parentTarget) ||
|
|
520
|
+
(fallback.requiresParentSignal && !hasParentOnlySignal)
|
|
253
521
|
) {
|
|
254
522
|
return {
|
|
255
523
|
command: cmd,
|
|
@@ -275,6 +543,7 @@ const maybeUseBareChildFallback = (
|
|
|
275
543
|
const wireAction = (
|
|
276
544
|
target: Command,
|
|
277
545
|
cmd: CliCommand,
|
|
546
|
+
topoName: string,
|
|
278
547
|
fallback?: BareChildFallback | undefined
|
|
279
548
|
): void => {
|
|
280
549
|
target.action(async (...actionArgs: unknown[]) => {
|
|
@@ -291,17 +560,38 @@ const wireAction = (
|
|
|
291
560
|
parentTarget: actionTarget.parent ?? fallback.parentTarget,
|
|
292
561
|
}
|
|
293
562
|
);
|
|
563
|
+
let { parsedFlags } = action;
|
|
294
564
|
try {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
565
|
+
const disallowedAncestorOptions = collectDisallowedAncestorOptions(
|
|
566
|
+
actionTarget,
|
|
567
|
+
action.command.flags
|
|
568
|
+
);
|
|
569
|
+
if (disallowedAncestorOptions.length > 0) {
|
|
570
|
+
throw new ValidationError('Unsupported option for this CLI command.', {
|
|
571
|
+
context: {
|
|
572
|
+
issues: disallowedAncestorOptions.map((message) => ({
|
|
573
|
+
message,
|
|
574
|
+
trailId: action.command.trail.id,
|
|
575
|
+
})),
|
|
576
|
+
},
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
parsedFlags = applyCliFlagValueAliases(
|
|
580
|
+
action.command.flags,
|
|
581
|
+
action.parsedFlags,
|
|
582
|
+
action.userSuppliedFlagKeys
|
|
583
|
+
);
|
|
584
|
+
await action.command.execute(action.parsedArgs, parsedFlags);
|
|
585
|
+
} catch (error: unknown) {
|
|
586
|
+
handleError(
|
|
587
|
+
error,
|
|
588
|
+
parsedFlags,
|
|
589
|
+
topoName,
|
|
590
|
+
getCanonicalUserSuppliedFlagKeys(
|
|
298
591
|
action.command.flags,
|
|
299
|
-
action.parsedFlags,
|
|
300
592
|
action.userSuppliedFlagKeys
|
|
301
593
|
)
|
|
302
594
|
);
|
|
303
|
-
} catch (error: unknown) {
|
|
304
|
-
handleError(error);
|
|
305
595
|
}
|
|
306
596
|
});
|
|
307
597
|
};
|
|
@@ -387,22 +677,22 @@ const ensureCommandNode = (
|
|
|
387
677
|
|
|
388
678
|
const createBareChildFallback = (
|
|
389
679
|
cmd: CliCommand,
|
|
680
|
+
path: readonly string[],
|
|
390
681
|
parentState?: CommandNodeState | undefined
|
|
391
682
|
): BareChildFallback | undefined => {
|
|
392
|
-
if (!parentState?.cliCommand ||
|
|
683
|
+
if (!parentState?.cliCommand || path.length < 2) {
|
|
393
684
|
return undefined;
|
|
394
685
|
}
|
|
395
686
|
|
|
396
687
|
const [parentArg] = parentState.cliCommand.args;
|
|
397
688
|
const [childArg] = cmd.args;
|
|
398
|
-
const childSegment =
|
|
689
|
+
const childSegment = path.at(-1);
|
|
399
690
|
if (
|
|
400
691
|
parentArg === undefined ||
|
|
401
|
-
childArg === undefined ||
|
|
402
692
|
parentArg.required ||
|
|
403
693
|
parentArg.variadic ||
|
|
404
|
-
childArg
|
|
405
|
-
|
|
694
|
+
(childArg !== undefined &&
|
|
695
|
+
(childArg.variadic || childArg.name !== parentArg.name)) ||
|
|
406
696
|
childSegment === undefined
|
|
407
697
|
) {
|
|
408
698
|
return undefined;
|
|
@@ -413,16 +703,19 @@ const createBareChildFallback = (
|
|
|
413
703
|
argValue: childSegment,
|
|
414
704
|
parentCommand: parentState.cliCommand,
|
|
415
705
|
parentTarget: parentState.command,
|
|
706
|
+
requiresParentSignal: childArg === undefined,
|
|
416
707
|
};
|
|
417
708
|
};
|
|
418
709
|
|
|
419
710
|
const applyCliCommand = (
|
|
420
711
|
state: CommandNodeState,
|
|
421
712
|
cmd: CliCommand,
|
|
713
|
+
path: readonly string[],
|
|
714
|
+
topoName: string,
|
|
422
715
|
fallback?: BareChildFallback | undefined
|
|
423
716
|
): void => {
|
|
424
717
|
if (state.executable) {
|
|
425
|
-
throw new Error(`Duplicate CLI path: ${
|
|
718
|
+
throw new Error(`Duplicate CLI path: ${path.join(' ')}`);
|
|
426
719
|
}
|
|
427
720
|
|
|
428
721
|
if (cmd.description) {
|
|
@@ -436,11 +729,26 @@ const applyCliCommand = (
|
|
|
436
729
|
addArgs(state.command, cmd, {
|
|
437
730
|
forceOptionalFirstArg: fallback !== undefined,
|
|
438
731
|
});
|
|
439
|
-
wireAction(state.command, cmd, fallback);
|
|
732
|
+
wireAction(state.command, cmd, topoName, fallback);
|
|
440
733
|
state.cliCommand = cmd;
|
|
441
734
|
state.executable = true;
|
|
442
735
|
};
|
|
443
736
|
|
|
737
|
+
const commandRoutes = (cmd: CliCommand) =>
|
|
738
|
+
cmd.routes ?? [
|
|
739
|
+
{
|
|
740
|
+
kind: 'canonical' as const,
|
|
741
|
+
path: cmd.path,
|
|
742
|
+
source: 'derived' as const,
|
|
743
|
+
target: cmd.trail.id,
|
|
744
|
+
},
|
|
745
|
+
];
|
|
746
|
+
|
|
747
|
+
const commandRouteEntries = (commands: readonly CliCommand[]) =>
|
|
748
|
+
commands.flatMap((cmd) =>
|
|
749
|
+
commandRoutes(cmd).map((route) => ({ cmd, path: route.path }))
|
|
750
|
+
);
|
|
751
|
+
|
|
444
752
|
/**
|
|
445
753
|
* Convert framework-agnostic CLI commands into a Commander program.
|
|
446
754
|
*
|
|
@@ -452,7 +760,10 @@ const applyCliCommand = (
|
|
|
452
760
|
* const commands = deriveCliCommands(graph);
|
|
453
761
|
* if (commands.isErr()) throw commands.error;
|
|
454
762
|
*
|
|
455
|
-
* const program = toCommander(commands.value, {
|
|
763
|
+
* const program = toCommander(commands.value, {
|
|
764
|
+
* name: 'demo',
|
|
765
|
+
* topoName: 'demo',
|
|
766
|
+
* });
|
|
456
767
|
* ```
|
|
457
768
|
*/
|
|
458
769
|
export const toCommander = (
|
|
@@ -462,17 +773,18 @@ export const toCommander = (
|
|
|
462
773
|
validateCliCommands(commands);
|
|
463
774
|
const program = new Command();
|
|
464
775
|
applyOptions(program, options);
|
|
776
|
+
const topoName = options?.topoName ?? options?.name ?? program.name();
|
|
465
777
|
const nodes = new Map<string, CommandNodeState>();
|
|
466
778
|
|
|
467
|
-
for (const cmd of commands.toSorted((a, b) =>
|
|
779
|
+
for (const { cmd, path } of commandRouteEntries(commands).toSorted((a, b) =>
|
|
468
780
|
a.path.length === b.path.length
|
|
469
781
|
? a.path.join('.').localeCompare(b.path.join('.'))
|
|
470
782
|
: a.path.length - b.path.length
|
|
471
783
|
)) {
|
|
472
|
-
const state = ensureCommandNode(
|
|
473
|
-
const parentKey = pathKey(
|
|
474
|
-
const fallback = createBareChildFallback(cmd, nodes.get(parentKey));
|
|
475
|
-
applyCliCommand(state, cmd, fallback);
|
|
784
|
+
const state = ensureCommandNode(path, program, nodes);
|
|
785
|
+
const parentKey = pathKey(path.slice(0, -1));
|
|
786
|
+
const fallback = createBareChildFallback(cmd, path, nodes.get(parentKey));
|
|
787
|
+
applyCliCommand(state, cmd, path, topoName, fallback);
|
|
476
788
|
}
|
|
477
789
|
|
|
478
790
|
return program;
|