@ontrails/commander 1.0.0-beta.15
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 +5 -0
- package/README.md +52 -0
- package/package.json +32 -0
- package/src/index.ts +5 -0
- package/src/surface.ts +120 -0
- package/src/to-commander.ts +462 -0
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @ontrails/commander
|
|
2
|
+
|
|
3
|
+
Commander adapter for Trails. Use this package when you want to expose a topo as a Commander-powered command-line program while keeping `@ontrails/cli` focused on framework-agnostic command derivation.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { surface } from '@ontrails/commander';
|
|
9
|
+
import { graph } from './app';
|
|
10
|
+
|
|
11
|
+
await surface(graph);
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
For program construction without parsing argv:
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import { createProgram } from '@ontrails/commander';
|
|
18
|
+
import { graph } from './app';
|
|
19
|
+
|
|
20
|
+
const program = createProgram(graph, { name: 'myapp' });
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
For lower-level adapter wiring, derive the command model with `@ontrails/cli`
|
|
24
|
+
and materialize it with `toCommander()`:
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { deriveCliCommands } from '@ontrails/cli';
|
|
28
|
+
import { toCommander } from '@ontrails/commander';
|
|
29
|
+
import { graph } from './app';
|
|
30
|
+
|
|
31
|
+
const commands = deriveCliCommands(graph);
|
|
32
|
+
if (commands.isErr()) {
|
|
33
|
+
throw commands.error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const program = toCommander(commands.value, { name: 'myapp' });
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
bun add @ontrails/cli @ontrails/commander
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Migration
|
|
46
|
+
|
|
47
|
+
<!-- warden-ignore-next-line -->
|
|
48
|
+
This package replaces the old `@ontrails/cli/commander` subpath.
|
|
49
|
+
|
|
50
|
+
<!-- warden-ignore-next-line -->
|
|
51
|
+
- Before: `import { surface } from '@ontrails/cli/commander'`
|
|
52
|
+
- After: `import { surface } from '@ontrails/commander'`
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ontrails/commander",
|
|
3
|
+
"version": "1.0.0-beta.15",
|
|
4
|
+
"files": [
|
|
5
|
+
"src/**/*.ts",
|
|
6
|
+
"!src/**/__tests__/**",
|
|
7
|
+
"!src/**/*.test.ts",
|
|
8
|
+
"!src/**/*.test-d.ts",
|
|
9
|
+
"README.md",
|
|
10
|
+
"CHANGELOG.md"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -b",
|
|
19
|
+
"test": "bun test",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"lint": "oxlint ./src",
|
|
22
|
+
"clean": "rm -rf dist *.tsbuildinfo"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@ontrails/cli": "^1.0.0-beta.15",
|
|
26
|
+
"@ontrails/core": "^1.0.0-beta.15",
|
|
27
|
+
"commander": "^14.0.3"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"zod": "^4.3.5"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/index.ts
ADDED
package/src/surface.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface helpers for wiring a topo to Commander.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
BaseSurfaceOptions,
|
|
7
|
+
Layer,
|
|
8
|
+
ResourceOverrideMap,
|
|
9
|
+
Topo,
|
|
10
|
+
TrailContextInit,
|
|
11
|
+
} from '@ontrails/core';
|
|
12
|
+
import type {
|
|
13
|
+
ActionResultContext,
|
|
14
|
+
CliFlag,
|
|
15
|
+
InputResolver,
|
|
16
|
+
ResolveCliPermitFromToken,
|
|
17
|
+
} from '@ontrails/cli';
|
|
18
|
+
import { defaultOnResult, deriveCliCommands } from '@ontrails/cli';
|
|
19
|
+
import type { ToCommanderOptions } from './to-commander.js';
|
|
20
|
+
import { toCommander } from './to-commander.js';
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Options
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface CreateProgramOptions extends BaseSurfaceOptions {
|
|
27
|
+
readonly createContext?:
|
|
28
|
+
| (() => TrailContextInit | Promise<TrailContextInit>)
|
|
29
|
+
| undefined;
|
|
30
|
+
readonly description?: string | undefined;
|
|
31
|
+
readonly layers?: readonly Layer[] | undefined;
|
|
32
|
+
readonly name?: string | undefined;
|
|
33
|
+
readonly onResult?: ((ctx: ActionResultContext) => Promise<void>) | undefined;
|
|
34
|
+
readonly presets?: CliFlag[][] | undefined;
|
|
35
|
+
readonly resources?: ResourceOverrideMap | undefined;
|
|
36
|
+
readonly resolveInput?: InputResolver | undefined;
|
|
37
|
+
readonly resolvePermitFromToken?: ResolveCliPermitFromToken | undefined;
|
|
38
|
+
readonly version?: string | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SurfaceCliResult {
|
|
42
|
+
readonly exitCode: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// createProgram
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
const deriveCommanderOptions = (
|
|
50
|
+
graph: Topo,
|
|
51
|
+
options: CreateProgramOptions
|
|
52
|
+
): ToCommanderOptions => {
|
|
53
|
+
const commanderOpts: ToCommanderOptions = {
|
|
54
|
+
name: options.name ?? graph.name,
|
|
55
|
+
};
|
|
56
|
+
if (options.version !== undefined || graph.version !== undefined) {
|
|
57
|
+
commanderOpts.version = options.version ?? graph.version;
|
|
58
|
+
}
|
|
59
|
+
if (options.description !== undefined || graph.description !== undefined) {
|
|
60
|
+
commanderOpts.description = options.description ?? graph.description;
|
|
61
|
+
}
|
|
62
|
+
return commanderOpts;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a Commander program from a topo without parsing argv.
|
|
67
|
+
*
|
|
68
|
+
* @remarks This is a host materialization boundary. Derivation failures are
|
|
69
|
+
* thrown for the caller's CLI bootstrap code after `deriveCliCommands` has
|
|
70
|
+
* already represented the framework error as a Result.
|
|
71
|
+
*/
|
|
72
|
+
export const createProgram = (
|
|
73
|
+
graph: Topo,
|
|
74
|
+
options: CreateProgramOptions = {}
|
|
75
|
+
) => {
|
|
76
|
+
const commandsResult = deriveCliCommands(graph, {
|
|
77
|
+
configValues: options.configValues,
|
|
78
|
+
createContext: options.createContext,
|
|
79
|
+
exclude: options.exclude,
|
|
80
|
+
include: options.include,
|
|
81
|
+
intent: options.intent,
|
|
82
|
+
layers: options.layers,
|
|
83
|
+
onResult: options.onResult ?? defaultOnResult,
|
|
84
|
+
presets: options.presets,
|
|
85
|
+
resolveInput: options.resolveInput,
|
|
86
|
+
resolvePermitFromToken: options.resolvePermitFromToken,
|
|
87
|
+
resources: options.resources,
|
|
88
|
+
validate: options.validate,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (commandsResult.isErr()) {
|
|
92
|
+
throw commandsResult.error;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return toCommander(
|
|
96
|
+
commandsResult.value,
|
|
97
|
+
deriveCommanderOptions(graph, options)
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// surface
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse argv for a topo through Commander.
|
|
107
|
+
*
|
|
108
|
+
* Returns the process exit code without calling `process.exit()`, so callers
|
|
109
|
+
* can run cleanup before terminating. The CLI `surface()` entry point
|
|
110
|
+
* delegates here and lets the process exit naturally.
|
|
111
|
+
*/
|
|
112
|
+
export const surface = async (
|
|
113
|
+
graph: Topo,
|
|
114
|
+
options: CreateProgramOptions = {}
|
|
115
|
+
): Promise<SurfaceCliResult> => {
|
|
116
|
+
const program = createProgram(graph, options);
|
|
117
|
+
await program.parseAsync();
|
|
118
|
+
const { exitCode } = process;
|
|
119
|
+
return { exitCode: typeof exitCode === 'number' ? exitCode : 0 };
|
|
120
|
+
};
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapt framework-agnostic CliCommand[] to a Commander program.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { projectPublicSurfaceError } from '@ontrails/core';
|
|
6
|
+
import type { CliCommand, CliFlag } from '@ontrails/cli';
|
|
7
|
+
import { applyCliFlagValueAliases, validateCliCommands } from '@ontrails/cli';
|
|
8
|
+
import { Command, InvalidArgumentError, Option } from 'commander';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Options
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export interface ToCommanderOptions {
|
|
15
|
+
description?: string | undefined;
|
|
16
|
+
name?: string | undefined;
|
|
17
|
+
version?: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** Build the flag string portion of a Commander Option. */
|
|
25
|
+
const buildFlagArgument = (flag: CliFlag): string => {
|
|
26
|
+
if (flag.variadic) {
|
|
27
|
+
return flag.required ? '<values...>' : '[values...]';
|
|
28
|
+
}
|
|
29
|
+
return flag.required ? '<value>' : '[value]';
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const buildFlagString = (flag: CliFlag): string => {
|
|
33
|
+
const long = `--${flag.name}`;
|
|
34
|
+
const short = flag.short ? `-${flag.short}` : undefined;
|
|
35
|
+
|
|
36
|
+
if (flag.type === 'boolean') {
|
|
37
|
+
return short ? `${short}, ${long}` : long;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const argPart = buildFlagArgument(flag);
|
|
41
|
+
return short ? `${short}, ${long} ${argPart}` : `${long} ${argPart}`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Strict number parser that rejects partial parses and non-finite values. */
|
|
45
|
+
const strictParseNumber = (value: string): number => {
|
|
46
|
+
const n = Number(value);
|
|
47
|
+
if (Number.isNaN(n) || !Number.isFinite(n)) {
|
|
48
|
+
throw new InvalidArgumentError(`"${value}" is not a valid number`);
|
|
49
|
+
}
|
|
50
|
+
return n;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Apply common modifiers (choices, default, arg parser) to a Commander Option. */
|
|
54
|
+
const applyOptionModifiers = (opt: Option, flag: CliFlag): void => {
|
|
55
|
+
if (flag.choices) {
|
|
56
|
+
opt.choices(flag.choices);
|
|
57
|
+
}
|
|
58
|
+
if (flag.default !== undefined) {
|
|
59
|
+
opt.default(flag.default);
|
|
60
|
+
}
|
|
61
|
+
if (flag.type === 'number' || flag.type === 'number[]') {
|
|
62
|
+
opt.argParser(strictParseNumber);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Build Commander Option(s) from a CliFlag. Returns one or two options. */
|
|
67
|
+
const buildOptions = (flag: CliFlag): Option[] => {
|
|
68
|
+
const opt = new Option(buildFlagString(flag), flag.description);
|
|
69
|
+
applyOptionModifiers(opt, flag);
|
|
70
|
+
const valueAliasOptions = (flag.valueAliases ?? []).map(
|
|
71
|
+
(alias) =>
|
|
72
|
+
new Option(
|
|
73
|
+
`--${alias.name}`,
|
|
74
|
+
alias.description ?? `Shorthand for --${flag.name} ${alias.value}`
|
|
75
|
+
)
|
|
76
|
+
);
|
|
77
|
+
if (flag.type === 'boolean') {
|
|
78
|
+
const negation = new Option(
|
|
79
|
+
`--no-${flag.name}`,
|
|
80
|
+
flag.description ? `Negate ${flag.description}` : undefined
|
|
81
|
+
);
|
|
82
|
+
return [opt, negation, ...valueAliasOptions];
|
|
83
|
+
}
|
|
84
|
+
return [opt, ...valueAliasOptions];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Add positional args to a Commander subcommand. */
|
|
88
|
+
const buildArgTemplate = (
|
|
89
|
+
arg: CliCommand['args'][number],
|
|
90
|
+
required = arg.required
|
|
91
|
+
): string => {
|
|
92
|
+
if (arg.variadic) {
|
|
93
|
+
return required ? `<${arg.name}...>` : `[${arg.name}...]`;
|
|
94
|
+
}
|
|
95
|
+
return required ? `<${arg.name}>` : `[${arg.name}]`;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const addArgs = (
|
|
99
|
+
sub: Command,
|
|
100
|
+
cmd: CliCommand,
|
|
101
|
+
options?: { readonly forceOptionalFirstArg?: boolean } | undefined
|
|
102
|
+
): void => {
|
|
103
|
+
for (const [index, arg] of cmd.args.entries()) {
|
|
104
|
+
const template = buildArgTemplate(
|
|
105
|
+
arg,
|
|
106
|
+
options?.forceOptionalFirstArg === true && index === 0 ? false : undefined
|
|
107
|
+
);
|
|
108
|
+
sub.argument(template, arg.description);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Collect positional args from Commander's action callback into a record. */
|
|
113
|
+
const collectPositionalArgs = (
|
|
114
|
+
cmd: CliCommand,
|
|
115
|
+
actionArgs: unknown[]
|
|
116
|
+
): Record<string, unknown> => {
|
|
117
|
+
const parsedArgs: Record<string, unknown> = {};
|
|
118
|
+
for (let i = 0; i < cmd.args.length; i += 1) {
|
|
119
|
+
const argDef = cmd.args[i];
|
|
120
|
+
if (argDef) {
|
|
121
|
+
parsedArgs[argDef.name] = actionArgs[i];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return parsedArgs;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const isUserSuppliedOption = (command: Command, name: string): boolean => {
|
|
128
|
+
const source = command.getOptionValueSource(name);
|
|
129
|
+
return source !== undefined && source !== 'default' && source !== 'implied';
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const getCommandOptionNames = (command: Command): Set<string> =>
|
|
133
|
+
new Set(command.options.map((option) => option.attributeName()));
|
|
134
|
+
|
|
135
|
+
const hasUserSuppliedOptionOutside = (
|
|
136
|
+
sourceCommand: Command,
|
|
137
|
+
allowedCommand: Command
|
|
138
|
+
): boolean => {
|
|
139
|
+
const allowedOptionNames = getCommandOptionNames(allowedCommand);
|
|
140
|
+
return sourceCommand.options.some((option) => {
|
|
141
|
+
const name = option.attributeName();
|
|
142
|
+
return (
|
|
143
|
+
isUserSuppliedOption(sourceCommand, name) && !allowedOptionNames.has(name)
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const hasAnyPositionalValue = (
|
|
149
|
+
cmd: CliCommand,
|
|
150
|
+
parsedArgs: Readonly<Record<string, unknown>>
|
|
151
|
+
): boolean => cmd.args.some((arg) => parsedArgs[arg.name] !== undefined);
|
|
152
|
+
|
|
153
|
+
const getActionTarget = (fallbackTarget: Command, actionArgs: unknown[]) => {
|
|
154
|
+
const candidate = actionArgs.at(-1);
|
|
155
|
+
return candidate instanceof Command ? candidate : fallbackTarget;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const getParsedFlags = (command: Command): Record<string, unknown> =>
|
|
159
|
+
command.optsWithGlobals() as Record<string, unknown>;
|
|
160
|
+
|
|
161
|
+
const getFlagOptionKeys = (flags: readonly CliCommand['flags'][number][]) =>
|
|
162
|
+
new Set(
|
|
163
|
+
flags.flatMap((flag) => [
|
|
164
|
+
flag.name.replaceAll(/-([a-zA-Z0-9])/g, (_, ch: string) =>
|
|
165
|
+
ch.toUpperCase()
|
|
166
|
+
),
|
|
167
|
+
...(flag.valueAliases ?? []).map((alias) =>
|
|
168
|
+
alias.name.replaceAll(/-([a-zA-Z0-9])/g, (_, ch: string) =>
|
|
169
|
+
ch.toUpperCase()
|
|
170
|
+
)
|
|
171
|
+
),
|
|
172
|
+
])
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const getUserSuppliedFlagKeys = (
|
|
176
|
+
command: Command,
|
|
177
|
+
flags: readonly CliCommand['flags'][number][]
|
|
178
|
+
): ReadonlySet<string> => {
|
|
179
|
+
const userSupplied = new Set<string>();
|
|
180
|
+
for (const key of getFlagOptionKeys(flags)) {
|
|
181
|
+
if (isUserSuppliedOption(command, key)) {
|
|
182
|
+
userSupplied.add(key);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return userSupplied;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const getFallbackParsedFlags = (
|
|
189
|
+
parentTarget: Command,
|
|
190
|
+
target: Command
|
|
191
|
+
): Record<string, unknown> => {
|
|
192
|
+
const flags = { ...getParsedFlags(parentTarget) };
|
|
193
|
+
const parentOptionNames = getCommandOptionNames(parentTarget);
|
|
194
|
+
for (const option of target.options) {
|
|
195
|
+
const name = option.attributeName();
|
|
196
|
+
if (parentOptionNames.has(name) && isUserSuppliedOption(target, name)) {
|
|
197
|
+
flags[name] = target.getOptionValue(name);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return flags;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const getFallbackUserSuppliedFlagKeys = (
|
|
204
|
+
parentTarget: Command,
|
|
205
|
+
target: Command,
|
|
206
|
+
flags: readonly CliCommand['flags'][number][]
|
|
207
|
+
): ReadonlySet<string> => {
|
|
208
|
+
const userSupplied = new Set<string>();
|
|
209
|
+
for (const key of getFlagOptionKeys(flags)) {
|
|
210
|
+
if (
|
|
211
|
+
isUserSuppliedOption(parentTarget, key) ||
|
|
212
|
+
isUserSuppliedOption(target, key)
|
|
213
|
+
) {
|
|
214
|
+
userSupplied.add(key);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return userSupplied;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/** Handle execution errors with appropriate exit codes. */
|
|
221
|
+
const handleError = (error: unknown): void => {
|
|
222
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
223
|
+
const projection = projectPublicSurfaceError('cli', err);
|
|
224
|
+
process.stderr.write(`Error: ${projection.message}\n`);
|
|
225
|
+
process.exit(projection.code);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
interface BareChildFallback {
|
|
229
|
+
readonly argName: string;
|
|
230
|
+
readonly argValue: string;
|
|
231
|
+
readonly parentCommand: CliCommand;
|
|
232
|
+
readonly parentTarget: Command;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const maybeUseBareChildFallback = (
|
|
236
|
+
target: Command,
|
|
237
|
+
cmd: CliCommand,
|
|
238
|
+
parsedArgs: Readonly<Record<string, unknown>>,
|
|
239
|
+
fallback?: BareChildFallback | undefined
|
|
240
|
+
): {
|
|
241
|
+
readonly command: CliCommand;
|
|
242
|
+
readonly parsedArgs: Record<string, unknown>;
|
|
243
|
+
readonly parsedFlags: Record<string, unknown>;
|
|
244
|
+
readonly userSuppliedFlagKeys: ReadonlySet<string>;
|
|
245
|
+
} => {
|
|
246
|
+
if (
|
|
247
|
+
!fallback ||
|
|
248
|
+
hasAnyPositionalValue(cmd, parsedArgs) ||
|
|
249
|
+
hasUserSuppliedOptionOutside(target, fallback.parentTarget)
|
|
250
|
+
) {
|
|
251
|
+
return {
|
|
252
|
+
command: cmd,
|
|
253
|
+
parsedArgs: { ...parsedArgs },
|
|
254
|
+
parsedFlags: getParsedFlags(target),
|
|
255
|
+
userSuppliedFlagKeys: getUserSuppliedFlagKeys(target, cmd.flags),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
command: fallback.parentCommand,
|
|
261
|
+
parsedArgs: { [fallback.argName]: fallback.argValue },
|
|
262
|
+
parsedFlags: getFallbackParsedFlags(fallback.parentTarget, target),
|
|
263
|
+
userSuppliedFlagKeys: getFallbackUserSuppliedFlagKeys(
|
|
264
|
+
fallback.parentTarget,
|
|
265
|
+
target,
|
|
266
|
+
fallback.parentCommand.flags
|
|
267
|
+
),
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** Wire a CliCommand's action to a Commander subcommand. */
|
|
272
|
+
const wireAction = (
|
|
273
|
+
target: Command,
|
|
274
|
+
cmd: CliCommand,
|
|
275
|
+
fallback?: BareChildFallback | undefined
|
|
276
|
+
): void => {
|
|
277
|
+
target.action(async (...actionArgs: unknown[]) => {
|
|
278
|
+
const actionTarget = getActionTarget(target, actionArgs);
|
|
279
|
+
const parsedArgs = collectPositionalArgs(cmd, actionArgs);
|
|
280
|
+
const action = maybeUseBareChildFallback(
|
|
281
|
+
actionTarget,
|
|
282
|
+
cmd,
|
|
283
|
+
parsedArgs,
|
|
284
|
+
fallback === undefined
|
|
285
|
+
? undefined
|
|
286
|
+
: {
|
|
287
|
+
...fallback,
|
|
288
|
+
parentTarget: actionTarget.parent ?? fallback.parentTarget,
|
|
289
|
+
}
|
|
290
|
+
);
|
|
291
|
+
try {
|
|
292
|
+
await action.command.execute(
|
|
293
|
+
action.parsedArgs,
|
|
294
|
+
applyCliFlagValueAliases(
|
|
295
|
+
action.command.flags,
|
|
296
|
+
action.parsedFlags,
|
|
297
|
+
action.userSuppliedFlagKeys
|
|
298
|
+
)
|
|
299
|
+
);
|
|
300
|
+
} catch (error: unknown) {
|
|
301
|
+
handleError(error);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/** Apply options to a Commander program. */
|
|
307
|
+
const applyOptions = (program: Command, options?: ToCommanderOptions): void => {
|
|
308
|
+
if (options?.name) {
|
|
309
|
+
program.name(options.name);
|
|
310
|
+
}
|
|
311
|
+
if (options?.version) {
|
|
312
|
+
program.version(options.version);
|
|
313
|
+
}
|
|
314
|
+
if (options?.description) {
|
|
315
|
+
program.description(options.description);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// toCommander
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Convert CliCommand[] into a configured Commander program.
|
|
325
|
+
*
|
|
326
|
+
* Builds a nested command tree from each command's full ordered path.
|
|
327
|
+
* Wires each command's `.action()` to call `execute()` and handle errors.
|
|
328
|
+
*/
|
|
329
|
+
const pathKey = (path: readonly string[]): string => path.join('\0');
|
|
330
|
+
|
|
331
|
+
interface CommandNodeState {
|
|
332
|
+
readonly command: Command;
|
|
333
|
+
cliCommand?: CliCommand | undefined;
|
|
334
|
+
executable: boolean;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const getPathSegment = (path: readonly string[], index: number): string => {
|
|
338
|
+
const segment = path[index];
|
|
339
|
+
if (segment === undefined) {
|
|
340
|
+
throw new Error('CLI command path contains an undefined segment');
|
|
341
|
+
}
|
|
342
|
+
return segment;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const getOrCreateCommandNode = (
|
|
346
|
+
key: string,
|
|
347
|
+
segment: string,
|
|
348
|
+
parent: Command,
|
|
349
|
+
nodes: Map<string, CommandNodeState>
|
|
350
|
+
): CommandNodeState => {
|
|
351
|
+
const existing = nodes.get(key);
|
|
352
|
+
if (existing) {
|
|
353
|
+
return existing;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const command = new Command(segment);
|
|
357
|
+
const state = { command, executable: false };
|
|
358
|
+
nodes.set(key, state);
|
|
359
|
+
parent.addCommand(command);
|
|
360
|
+
return state;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const ensureCommandNode = (
|
|
364
|
+
path: readonly string[],
|
|
365
|
+
program: Command,
|
|
366
|
+
nodes: Map<string, CommandNodeState>
|
|
367
|
+
): CommandNodeState => {
|
|
368
|
+
let parent = program;
|
|
369
|
+
let state: CommandNodeState | undefined;
|
|
370
|
+
|
|
371
|
+
for (let index = 0; index < path.length; index += 1) {
|
|
372
|
+
const segment = getPathSegment(path, index);
|
|
373
|
+
const key = pathKey(path.slice(0, index + 1));
|
|
374
|
+
state = getOrCreateCommandNode(key, segment, parent, nodes);
|
|
375
|
+
parent = state.command;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (!state) {
|
|
379
|
+
throw new Error('CLI command path cannot be empty');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return state;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const createBareChildFallback = (
|
|
386
|
+
cmd: CliCommand,
|
|
387
|
+
parentState?: CommandNodeState | undefined
|
|
388
|
+
): BareChildFallback | undefined => {
|
|
389
|
+
if (!parentState?.cliCommand || cmd.path.length < 2) {
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const [parentArg] = parentState.cliCommand.args;
|
|
394
|
+
const [childArg] = cmd.args;
|
|
395
|
+
const childSegment = cmd.path.at(-1);
|
|
396
|
+
if (
|
|
397
|
+
parentArg === undefined ||
|
|
398
|
+
childArg === undefined ||
|
|
399
|
+
parentArg.required ||
|
|
400
|
+
parentArg.variadic ||
|
|
401
|
+
childArg.variadic ||
|
|
402
|
+
childArg.name !== parentArg.name ||
|
|
403
|
+
childSegment === undefined
|
|
404
|
+
) {
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
argName: parentArg.name,
|
|
410
|
+
argValue: childSegment,
|
|
411
|
+
parentCommand: parentState.cliCommand,
|
|
412
|
+
parentTarget: parentState.command,
|
|
413
|
+
};
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const applyCliCommand = (
|
|
417
|
+
state: CommandNodeState,
|
|
418
|
+
cmd: CliCommand,
|
|
419
|
+
fallback?: BareChildFallback | undefined
|
|
420
|
+
): void => {
|
|
421
|
+
if (state.executable) {
|
|
422
|
+
throw new Error(`Duplicate CLI path: ${cmd.path.join(' ')}`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (cmd.description) {
|
|
426
|
+
state.command.description(cmd.description);
|
|
427
|
+
}
|
|
428
|
+
for (const flag of cmd.flags) {
|
|
429
|
+
for (const opt of buildOptions(flag)) {
|
|
430
|
+
state.command.addOption(opt);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
addArgs(state.command, cmd, {
|
|
434
|
+
forceOptionalFirstArg: fallback !== undefined,
|
|
435
|
+
});
|
|
436
|
+
wireAction(state.command, cmd, fallback);
|
|
437
|
+
state.cliCommand = cmd;
|
|
438
|
+
state.executable = true;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
export const toCommander = (
|
|
442
|
+
commands: CliCommand[],
|
|
443
|
+
options?: ToCommanderOptions
|
|
444
|
+
): Command => {
|
|
445
|
+
validateCliCommands(commands);
|
|
446
|
+
const program = new Command();
|
|
447
|
+
applyOptions(program, options);
|
|
448
|
+
const nodes = new Map<string, CommandNodeState>();
|
|
449
|
+
|
|
450
|
+
for (const cmd of commands.toSorted((a, b) =>
|
|
451
|
+
a.path.length === b.path.length
|
|
452
|
+
? a.path.join('.').localeCompare(b.path.join('.'))
|
|
453
|
+
: a.path.length - b.path.length
|
|
454
|
+
)) {
|
|
455
|
+
const state = ensureCommandNode(cmd.path, program, nodes);
|
|
456
|
+
const parentKey = pathKey(cmd.path.slice(0, -1));
|
|
457
|
+
const fallback = createBareChildFallback(cmd, nodes.get(parentKey));
|
|
458
|
+
applyCliCommand(state, cmd, fallback);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return program;
|
|
462
|
+
};
|