@ontrails/cli 1.0.0-beta.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/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +20 -0
- package/README.md +166 -0
- package/dist/build.d.ts +25 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +167 -0
- package/dist/build.js.map +1 -0
- package/dist/command.d.ts +47 -0
- package/dist/command.d.ts.map +1 -0
- package/dist/command.js +9 -0
- package/dist/command.js.map +1 -0
- package/dist/commander/blaze.d.ts +31 -0
- package/dist/commander/blaze.d.ts.map +1 -0
- package/dist/commander/blaze.js +42 -0
- package/dist/commander/blaze.js.map +1 -0
- package/dist/commander/index.d.ts +5 -0
- package/dist/commander/index.d.ts.map +1 -0
- package/dist/commander/index.js +3 -0
- package/dist/commander/index.js.map +1 -0
- package/dist/commander/to-commander.d.ts +12 -0
- package/dist/commander/to-commander.d.ts.map +1 -0
- package/dist/commander/to-commander.js +148 -0
- package/dist/commander/to-commander.js.map +1 -0
- package/dist/flags.d.ts +17 -0
- package/dist/flags.d.ts.map +1 -0
- package/dist/flags.js +180 -0
- package/dist/flags.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/layers.d.ts +21 -0
- package/dist/layers.d.ts.map +1 -0
- package/dist/layers.js +156 -0
- package/dist/layers.js.map +1 -0
- package/dist/on-result.d.ts +12 -0
- package/dist/on-result.d.ts.map +1 -0
- package/dist/on-result.js +21 -0
- package/dist/on-result.js.map +1 -0
- package/dist/output.d.ts +20 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +82 -0
- package/dist/output.js.map +1 -0
- package/dist/prompt.d.ts +29 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +12 -0
- package/dist/prompt.js.map +1 -0
- package/package.json +29 -0
- package/src/__tests__/blaze.test.ts +78 -0
- package/src/__tests__/build.test.ts +219 -0
- package/src/__tests__/flags.test.ts +176 -0
- package/src/__tests__/layers.test.ts +218 -0
- package/src/__tests__/on-result.test.ts +64 -0
- package/src/__tests__/output.test.ts +115 -0
- package/src/__tests__/to-commander.test.ts +133 -0
- package/src/build.ts +267 -0
- package/src/command.ts +73 -0
- package/src/commander/blaze.ts +67 -0
- package/src/commander/index.ts +5 -0
- package/src/commander/to-commander.ts +186 -0
- package/src/flags.ts +250 -0
- package/src/index.ts +28 -0
- package/src/layers.ts +231 -0
- package/src/on-result.ts +27 -0
- package/src/output.ts +101 -0
- package/src/prompt.ts +40 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/build.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build framework-agnostic CliCommand[] from an App's topology.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Field, Layer, Topo, TrailContext } from '@ontrails/core';
|
|
6
|
+
import {
|
|
7
|
+
Result,
|
|
8
|
+
composeLayers,
|
|
9
|
+
createTrailContext,
|
|
10
|
+
deriveFields,
|
|
11
|
+
validateInput,
|
|
12
|
+
} from '@ontrails/core';
|
|
13
|
+
|
|
14
|
+
import type { AnyTrail, CliCommand, CliFlag } from './command.js';
|
|
15
|
+
import { dryRunPreset, toFlags } from './flags.js';
|
|
16
|
+
import type { InputResolver } from './prompt.js';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Public types
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** Context passed to the onResult callback. */
|
|
23
|
+
export interface ActionResultContext {
|
|
24
|
+
readonly args: Record<string, unknown>;
|
|
25
|
+
readonly flags: Record<string, unknown>;
|
|
26
|
+
readonly input: unknown;
|
|
27
|
+
readonly result: Result<unknown, Error>;
|
|
28
|
+
readonly trail: AnyTrail;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Options for buildCliCommands. */
|
|
32
|
+
export interface BuildCliCommandsOptions {
|
|
33
|
+
createContext?: (() => TrailContext | Promise<TrailContext>) | undefined;
|
|
34
|
+
layers?: Layer[] | undefined;
|
|
35
|
+
onResult?: ((ctx: ActionResultContext) => Promise<void>) | undefined;
|
|
36
|
+
presets?: CliFlag[][] | undefined;
|
|
37
|
+
resolveInput?: InputResolver | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Helpers
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** Convert kebab-case flag name back to camelCase for input merging. */
|
|
45
|
+
const toCamel = (str: string): string =>
|
|
46
|
+
str.replaceAll(/-([a-z])/g, (_, ch: string) => ch.toUpperCase());
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse a trail ID into group + command name.
|
|
50
|
+
* "entity.show" -> { group: "entity", name: "show" }
|
|
51
|
+
* "search" -> { group: undefined, name: "search" }
|
|
52
|
+
*/
|
|
53
|
+
const parseTrailId = (
|
|
54
|
+
id: string
|
|
55
|
+
): { group: string | undefined; name: string } => {
|
|
56
|
+
const dotIndex = id.indexOf('.');
|
|
57
|
+
if (dotIndex === -1) {
|
|
58
|
+
return { group: undefined, name: id };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
group: id.slice(0, dotIndex),
|
|
62
|
+
name: id.slice(dotIndex + 1),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Merge preset flags with schema-derived flags.
|
|
68
|
+
* Schema-derived flags take precedence on name collision.
|
|
69
|
+
*/
|
|
70
|
+
const mergeFlags = (presets: CliFlag[], derived: CliFlag[]): CliFlag[] => {
|
|
71
|
+
const derivedNames = new Set(derived.map((f) => f.name));
|
|
72
|
+
const merged = [...derived];
|
|
73
|
+
for (const preset of presets) {
|
|
74
|
+
if (!derivedNames.has(preset.name)) {
|
|
75
|
+
merged.push(preset);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return merged;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// buildCliCommands
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build an array of framework-agnostic CLI commands from an App.
|
|
87
|
+
*
|
|
88
|
+
* Iterates the topo, derives flags from input schemas, groups by
|
|
89
|
+
* dot-notation, and wires up the execute function with validation,
|
|
90
|
+
* layer composition, and onResult handling.
|
|
91
|
+
*/
|
|
92
|
+
const META_FLAGS = new Set(['json', 'jsonl', 'output']);
|
|
93
|
+
|
|
94
|
+
/** Merge parsed args and flags into a camelCase input record. */
|
|
95
|
+
const mergeArgsAndFlags = (
|
|
96
|
+
parsedArgs: Record<string, unknown>,
|
|
97
|
+
parsedFlags: Record<string, unknown>
|
|
98
|
+
): Record<string, unknown> => {
|
|
99
|
+
const mergedInput: Record<string, unknown> = { ...parsedArgs };
|
|
100
|
+
for (const [key, value] of Object.entries(parsedFlags)) {
|
|
101
|
+
if (!META_FLAGS.has(key)) {
|
|
102
|
+
mergedInput[toCamel(key)] = value;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return mergedInput;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Apply interactive prompting and merge results. */
|
|
109
|
+
const applyPrompting = async (
|
|
110
|
+
fields: readonly Field[],
|
|
111
|
+
mergedInput: Record<string, unknown>,
|
|
112
|
+
options?: BuildCliCommandsOptions
|
|
113
|
+
): Promise<void> => {
|
|
114
|
+
if (!options?.resolveInput) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const resolved = await options.resolveInput(fields, mergedInput);
|
|
118
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
119
|
+
if (value !== undefined) {
|
|
120
|
+
mergedInput[key] = value;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Resolve a TrailContext from overrides, factory, or default. */
|
|
126
|
+
const resolveContext = (
|
|
127
|
+
ctxOverrides: Partial<TrailContext> | undefined,
|
|
128
|
+
options?: BuildCliCommandsOptions
|
|
129
|
+
): Promise<TrailContext> => {
|
|
130
|
+
if (ctxOverrides) {
|
|
131
|
+
return Promise.resolve(createTrailContext(ctxOverrides));
|
|
132
|
+
}
|
|
133
|
+
if (options?.createContext) {
|
|
134
|
+
return Promise.resolve(options.createContext());
|
|
135
|
+
}
|
|
136
|
+
return Promise.resolve(createTrailContext());
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Report a result via onResult callback if provided. */
|
|
140
|
+
const reportResult = async (
|
|
141
|
+
options: BuildCliCommandsOptions | undefined,
|
|
142
|
+
ctx: ActionResultContext
|
|
143
|
+
): Promise<void> => {
|
|
144
|
+
if (options?.onResult) {
|
|
145
|
+
await options.onResult(ctx);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Execute a trail with validated input. */
|
|
150
|
+
const executeTrail = async (
|
|
151
|
+
t: AnyTrail,
|
|
152
|
+
validatedInput: unknown,
|
|
153
|
+
ctxOverrides: Partial<TrailContext> | undefined,
|
|
154
|
+
options?: BuildCliCommandsOptions
|
|
155
|
+
): Promise<Result<unknown, Error>> => {
|
|
156
|
+
const ctx = await resolveContext(ctxOverrides, options);
|
|
157
|
+
const layers = options?.layers ?? [];
|
|
158
|
+
const impl = composeLayers(layers, t, t.implementation);
|
|
159
|
+
return impl(validatedInput, ctx);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Create the execute function for a CLI command. */
|
|
163
|
+
const createExecute =
|
|
164
|
+
(
|
|
165
|
+
t: AnyTrail,
|
|
166
|
+
fields: readonly Field[],
|
|
167
|
+
_flags: CliFlag[],
|
|
168
|
+
options?: BuildCliCommandsOptions
|
|
169
|
+
) =>
|
|
170
|
+
async (
|
|
171
|
+
parsedArgs: Record<string, unknown>,
|
|
172
|
+
parsedFlags: Record<string, unknown>,
|
|
173
|
+
ctxOverrides?: Partial<TrailContext>
|
|
174
|
+
): Promise<Result<unknown, Error>> => {
|
|
175
|
+
const mergedInput = mergeArgsAndFlags(parsedArgs, parsedFlags);
|
|
176
|
+
await applyPrompting(fields, mergedInput, options);
|
|
177
|
+
|
|
178
|
+
const validated = validateInput(t.input, mergedInput);
|
|
179
|
+
if (validated.isErr()) {
|
|
180
|
+
const errorResult: Result<unknown, Error> = Result.err(validated.error);
|
|
181
|
+
await reportResult(options, {
|
|
182
|
+
args: parsedArgs,
|
|
183
|
+
flags: parsedFlags,
|
|
184
|
+
input: mergedInput,
|
|
185
|
+
result: errorResult,
|
|
186
|
+
trail: t,
|
|
187
|
+
});
|
|
188
|
+
return errorResult;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const result = await executeTrail(
|
|
192
|
+
t,
|
|
193
|
+
validated.value,
|
|
194
|
+
ctxOverrides,
|
|
195
|
+
options
|
|
196
|
+
);
|
|
197
|
+
await reportResult(options, {
|
|
198
|
+
args: parsedArgs,
|
|
199
|
+
flags: parsedFlags,
|
|
200
|
+
input: validated.value,
|
|
201
|
+
result,
|
|
202
|
+
trail: t,
|
|
203
|
+
});
|
|
204
|
+
return result;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** Derive and merge flags for a trail. */
|
|
208
|
+
const buildFlags = (
|
|
209
|
+
fields: readonly Field[],
|
|
210
|
+
destructive: boolean | undefined,
|
|
211
|
+
options?: BuildCliCommandsOptions
|
|
212
|
+
): CliFlag[] => {
|
|
213
|
+
let flags = toFlags(fields);
|
|
214
|
+
if (options?.presets) {
|
|
215
|
+
flags = mergeFlags(options.presets.flat(), flags);
|
|
216
|
+
}
|
|
217
|
+
if (destructive) {
|
|
218
|
+
flags = mergeFlags(dryRunPreset(), flags);
|
|
219
|
+
}
|
|
220
|
+
return flags;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/** Convert a trail or route into a CLI command when it is publicly exposed. */
|
|
224
|
+
const toCliCommand = (
|
|
225
|
+
t: AnyTrail,
|
|
226
|
+
options?: BuildCliCommandsOptions
|
|
227
|
+
): CliCommand => {
|
|
228
|
+
const { group, name } = parseTrailId(t.id);
|
|
229
|
+
const fields = deriveFields(t.input, t.fields);
|
|
230
|
+
const flags = buildFlags(fields, t.destructive, options);
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
args: [],
|
|
234
|
+
description: t.description,
|
|
235
|
+
destructive: t.destructive,
|
|
236
|
+
execute: createExecute(t, fields, flags, options),
|
|
237
|
+
flags,
|
|
238
|
+
group,
|
|
239
|
+
idempotent: t.idempotent,
|
|
240
|
+
layers: options?.layers,
|
|
241
|
+
name,
|
|
242
|
+
readOnly: t.readOnly,
|
|
243
|
+
trail: t,
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
export const buildCliCommands = (
|
|
248
|
+
app: Topo,
|
|
249
|
+
options?: BuildCliCommandsOptions
|
|
250
|
+
): CliCommand[] => {
|
|
251
|
+
const commands: CliCommand[] = [];
|
|
252
|
+
|
|
253
|
+
for (const item of app.list()) {
|
|
254
|
+
if (item.kind !== 'trail' && item.kind !== 'hike') {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
259
|
+
const t = item as AnyTrail;
|
|
260
|
+
if (t.markers?.['internal'] === true) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
commands.push(toCliCommand(t, options));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return commands;
|
|
267
|
+
};
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic CLI command model.
|
|
3
|
+
*
|
|
4
|
+
* These interfaces are the intermediate representation that
|
|
5
|
+
* `buildCliCommands()` produces and framework adapters consume.
|
|
6
|
+
* No Commander (or any other framework) imports here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Layer, Result, Trail, TrailContext } from '@ontrails/core';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// AnyTrail -- type-erased trail for the CLI boundary
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Type-erased trail reference. At the CLI adapter boundary we lose
|
|
17
|
+
* generic type information since flags/args are parsed as strings.
|
|
18
|
+
* Using `any` here is intentional -- the Zod schema validates at runtime.
|
|
19
|
+
*/
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
export type AnyTrail = Trail<any, any>;
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// CliFlag
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** A single CLI flag derived from a Zod schema field or preset. */
|
|
28
|
+
export interface CliFlag {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly short?: string | undefined;
|
|
31
|
+
readonly description?: string | undefined;
|
|
32
|
+
readonly type: 'string' | 'number' | 'boolean' | 'string[]' | 'number[]';
|
|
33
|
+
readonly required: boolean;
|
|
34
|
+
readonly default?: unknown | undefined;
|
|
35
|
+
readonly choices?: string[] | undefined;
|
|
36
|
+
readonly variadic: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// CliArg
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/** A positional CLI argument. */
|
|
44
|
+
export interface CliArg {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly description?: string | undefined;
|
|
47
|
+
readonly required: boolean;
|
|
48
|
+
readonly variadic: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// CliCommand
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/** A framework-agnostic representation of a CLI command. */
|
|
56
|
+
export interface CliCommand {
|
|
57
|
+
readonly name: string;
|
|
58
|
+
readonly description?: string | undefined;
|
|
59
|
+
readonly group?: string | undefined;
|
|
60
|
+
readonly flags: CliFlag[];
|
|
61
|
+
readonly args: CliArg[];
|
|
62
|
+
readonly trail: AnyTrail;
|
|
63
|
+
readonly layers?: Layer[] | undefined;
|
|
64
|
+
readonly readOnly?: boolean | undefined;
|
|
65
|
+
readonly destructive?: boolean | undefined;
|
|
66
|
+
readonly idempotent?: boolean | undefined;
|
|
67
|
+
|
|
68
|
+
execute(
|
|
69
|
+
parsedArgs: Record<string, unknown>,
|
|
70
|
+
parsedFlags: Record<string, unknown>,
|
|
71
|
+
ctx?: Partial<TrailContext>
|
|
72
|
+
): Promise<Result<unknown, Error>>;
|
|
73
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one-liner convenience for wiring an App to Commander.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Layer, Topo, TrailContext } from '@ontrails/core';
|
|
6
|
+
|
|
7
|
+
import type { ActionResultContext } from '../build.js';
|
|
8
|
+
import { buildCliCommands } from '../build.js';
|
|
9
|
+
import type { CliFlag } from '../command.js';
|
|
10
|
+
import { defaultOnResult } from '../on-result.js';
|
|
11
|
+
import type { InputResolver } from '../prompt.js';
|
|
12
|
+
import type { ToCommanderOptions } from './to-commander.js';
|
|
13
|
+
import { toCommander } from './to-commander.js';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Options
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export interface BlazeCliOptions {
|
|
20
|
+
createContext?: (() => TrailContext | Promise<TrailContext>) | undefined;
|
|
21
|
+
description?: string | undefined;
|
|
22
|
+
layers?: Layer[] | undefined;
|
|
23
|
+
name?: string | undefined;
|
|
24
|
+
onResult?: ((ctx: ActionResultContext) => Promise<void>) | undefined;
|
|
25
|
+
presets?: CliFlag[][] | undefined;
|
|
26
|
+
resolveInput?: InputResolver | undefined;
|
|
27
|
+
version?: string | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// blaze
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Wire an App to Commander and parse argv in one call.
|
|
36
|
+
*
|
|
37
|
+
* ```ts
|
|
38
|
+
* import { topo } from "@ontrails/core";
|
|
39
|
+
* import { blaze } from "@ontrails/cli/commander";
|
|
40
|
+
* import * as entity from "./trails/entity.ts";
|
|
41
|
+
*
|
|
42
|
+
* const app = topo("myapp", entity);
|
|
43
|
+
* blaze(app);
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export const blaze = (app: Topo, options: BlazeCliOptions = {}): void => {
|
|
47
|
+
const commands = buildCliCommands(app, {
|
|
48
|
+
createContext: options.createContext,
|
|
49
|
+
layers: options.layers,
|
|
50
|
+
onResult: options.onResult ?? defaultOnResult,
|
|
51
|
+
presets: options.presets,
|
|
52
|
+
resolveInput: options.resolveInput,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const commanderOpts: ToCommanderOptions = {
|
|
56
|
+
name: options.name ?? app.name,
|
|
57
|
+
};
|
|
58
|
+
if (options.version !== undefined) {
|
|
59
|
+
commanderOpts.version = options.version;
|
|
60
|
+
}
|
|
61
|
+
if (options.description !== undefined) {
|
|
62
|
+
commanderOpts.description = options.description;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const program = toCommander(commands, commanderOpts);
|
|
66
|
+
program.parse();
|
|
67
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapt framework-agnostic CliCommand[] to a Commander program.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { exitCodeMap, isTrailsError } from '@ontrails/core';
|
|
6
|
+
import { Command, Option } from 'commander';
|
|
7
|
+
|
|
8
|
+
import type { CliCommand, CliFlag } from '../command.js';
|
|
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
|
+
/** Build a Commander Option from a CliFlag. */
|
|
45
|
+
const buildOption = (flag: CliFlag): Option => {
|
|
46
|
+
const opt = new Option(buildFlagString(flag), flag.description);
|
|
47
|
+
if (flag.choices) {
|
|
48
|
+
opt.choices(flag.choices);
|
|
49
|
+
}
|
|
50
|
+
if (flag.default !== undefined) {
|
|
51
|
+
opt.default(flag.default);
|
|
52
|
+
}
|
|
53
|
+
if (flag.type === 'number' || flag.type === 'number[]') {
|
|
54
|
+
opt.argParser(parseFloat);
|
|
55
|
+
}
|
|
56
|
+
return opt;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Add positional args to a Commander subcommand. */
|
|
60
|
+
const buildArgTemplate = (arg: CliCommand['args'][number]): string => {
|
|
61
|
+
if (arg.variadic) {
|
|
62
|
+
return arg.required ? `<${arg.name}...>` : `[${arg.name}...]`;
|
|
63
|
+
}
|
|
64
|
+
return arg.required ? `<${arg.name}>` : `[${arg.name}]`;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const addArgs = (sub: Command, cmd: CliCommand): void => {
|
|
68
|
+
for (const arg of cmd.args) {
|
|
69
|
+
const template = buildArgTemplate(arg);
|
|
70
|
+
sub.argument(template, arg.description);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Collect positional args from Commander's action callback into a record. */
|
|
75
|
+
const collectPositionalArgs = (
|
|
76
|
+
cmd: CliCommand,
|
|
77
|
+
actionArgs: unknown[]
|
|
78
|
+
): Record<string, unknown> => {
|
|
79
|
+
const parsedArgs: Record<string, unknown> = {};
|
|
80
|
+
for (let i = 0; i < cmd.args.length; i += 1) {
|
|
81
|
+
const argDef = cmd.args[i];
|
|
82
|
+
if (argDef) {
|
|
83
|
+
parsedArgs[argDef.name] = actionArgs[i];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return parsedArgs;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Handle execution errors with appropriate exit codes. */
|
|
90
|
+
const handleError = (error: unknown): void => {
|
|
91
|
+
if (error instanceof Error) {
|
|
92
|
+
process.stderr.write(`Error: ${error.message}\n`);
|
|
93
|
+
if (isTrailsError(error)) {
|
|
94
|
+
process.exit(exitCodeMap[error.category]);
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
process.stderr.write(`Error: ${String(error)}\n`);
|
|
98
|
+
}
|
|
99
|
+
process.exit(8);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Wire a CliCommand's action to a Commander subcommand. */
|
|
103
|
+
const wireAction = (sub: Command, cmd: CliCommand): void => {
|
|
104
|
+
sub.action(async (...actionArgs: unknown[]) => {
|
|
105
|
+
const opts = sub.opts() as Record<string, unknown>;
|
|
106
|
+
const parsedArgs = collectPositionalArgs(cmd, actionArgs);
|
|
107
|
+
try {
|
|
108
|
+
await cmd.execute(parsedArgs, opts);
|
|
109
|
+
} catch (error: unknown) {
|
|
110
|
+
handleError(error);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Attach a subcommand to its group or to the top-level program. */
|
|
116
|
+
const attachToGroup = (
|
|
117
|
+
sub: Command,
|
|
118
|
+
cmd: CliCommand,
|
|
119
|
+
program: Command,
|
|
120
|
+
groups: Map<string, Command>
|
|
121
|
+
): void => {
|
|
122
|
+
if (cmd.group) {
|
|
123
|
+
let groupCmd = groups.get(cmd.group);
|
|
124
|
+
if (!groupCmd) {
|
|
125
|
+
groupCmd = new Command(cmd.group);
|
|
126
|
+
groups.set(cmd.group, groupCmd);
|
|
127
|
+
program.addCommand(groupCmd);
|
|
128
|
+
}
|
|
129
|
+
groupCmd.addCommand(sub);
|
|
130
|
+
} else {
|
|
131
|
+
program.addCommand(sub);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** Apply options to a Commander program. */
|
|
136
|
+
const applyOptions = (program: Command, options?: ToCommanderOptions): void => {
|
|
137
|
+
if (options?.name) {
|
|
138
|
+
program.name(options.name);
|
|
139
|
+
}
|
|
140
|
+
if (options?.version) {
|
|
141
|
+
program.version(options.version);
|
|
142
|
+
}
|
|
143
|
+
if (options?.description) {
|
|
144
|
+
program.description(options.description);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// toCommander
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Convert CliCommand[] into a configured Commander program.
|
|
154
|
+
*
|
|
155
|
+
* Groups commands by their `group` field into parent/subcommand structure.
|
|
156
|
+
* Wires each command's `.action()` to call `execute()` and handle errors.
|
|
157
|
+
*/
|
|
158
|
+
/** Build a Commander subcommand from a CliCommand. */
|
|
159
|
+
const buildSubcommand = (cmd: CliCommand): Command => {
|
|
160
|
+
const sub = new Command(cmd.name);
|
|
161
|
+
if (cmd.description) {
|
|
162
|
+
sub.description(cmd.description);
|
|
163
|
+
}
|
|
164
|
+
for (const flag of cmd.flags) {
|
|
165
|
+
sub.addOption(buildOption(flag));
|
|
166
|
+
}
|
|
167
|
+
addArgs(sub, cmd);
|
|
168
|
+
wireAction(sub, cmd);
|
|
169
|
+
return sub;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const toCommander = (
|
|
173
|
+
commands: CliCommand[],
|
|
174
|
+
options?: ToCommanderOptions
|
|
175
|
+
): Command => {
|
|
176
|
+
const program = new Command();
|
|
177
|
+
applyOptions(program, options);
|
|
178
|
+
const groups = new Map<string, Command>();
|
|
179
|
+
|
|
180
|
+
for (const cmd of commands) {
|
|
181
|
+
const sub = buildSubcommand(cmd);
|
|
182
|
+
attachToGroup(sub, cmd, program, groups);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return program;
|
|
186
|
+
};
|