@ontrails/commander 1.0.0-beta.41 → 1.0.0-beta.43
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 +16 -0
- package/README.md +11 -0
- package/package.json +3 -3
- package/src/multiselect-argv.ts +182 -0
- package/src/surface.ts +1 -1
- package/src/to-commander.ts +24 -103
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @ontrails/commander
|
|
2
2
|
|
|
3
|
+
## 1.0.0-beta.43
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [`88a6a62`](https://github.com/outfitter-dev/trails/commit/88a6a62a9e9e230ca6d368fa78dc3ece6c816204): Complete the v1 classification-first cutover from projection/project vocabulary
|
|
8
|
+
to derive/derived for contract-owned fact production and render/rendered for
|
|
9
|
+
surface presentation. Public type, helper, rule, relation, and report names move
|
|
10
|
+
without compatibility aliases; ordinary repository/project nouns remain
|
|
11
|
+
explicit preserves or structured review inventory.
|
|
12
|
+
|
|
13
|
+
## 1.0.0-beta.42
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- [`454e935`](https://github.com/outfitter-dev/trails/commit/454e935088782a181df89a205c0ff6f2eb936434): Define bounded multiselect argv normalization in the framework CLI model and apply it automatically in the Commander adapter, accepting both contiguous and repeated forms while preserving child routes after the first explicit value.
|
|
18
|
+
|
|
3
19
|
## 1.0.0-beta.41
|
|
4
20
|
|
|
5
21
|
## 1.0.0-beta.40
|
package/README.md
CHANGED
|
@@ -35,6 +35,17 @@ if (commands.isErr()) {
|
|
|
35
35
|
const program = toCommander(commands.value, { name: 'myapp' });
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
## Multiselect flags
|
|
39
|
+
|
|
40
|
+
Schema fields such as `z.array(z.enum(['cli', 'mcp', 'http']))` derive a bounded multiselect flag. The shared CLI argv normalizer lets adapters accept both contiguous and repeated forms; Commander applies it automatically:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
myapp create --surfaces cli mcp http
|
|
44
|
+
myapp create --surfaces cli --surfaces mcp --surfaces http
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The first matching token after the flag is its explicit value. After that first value, additional collection stops before known child routes or values outside the declared choices. Adopters do not need custom parsing or surface configuration for either form.
|
|
48
|
+
|
|
38
49
|
## Installation
|
|
39
50
|
|
|
40
51
|
```bash
|
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.43",
|
|
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.43",
|
|
26
|
+
"@ontrails/core": "^1.0.0-beta.43",
|
|
27
27
|
"commander": "^14.0.3"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import type { ParseOptions, Option } from 'commander';
|
|
3
|
+
import { normalizeCliArgv } from '@ontrails/cli';
|
|
4
|
+
import type { CliCommand } from '@ontrails/cli';
|
|
5
|
+
|
|
6
|
+
type EffectiveParseOptions = Omit<ParseOptions, 'from'> & {
|
|
7
|
+
readonly from?: ParseOptions['from'] | 'eval' | undefined;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const visibleOptionsFor = (command: Command): readonly Option[] => {
|
|
11
|
+
const commands: Command[] = [];
|
|
12
|
+
let current: Command | null = command;
|
|
13
|
+
while (current !== null) {
|
|
14
|
+
commands.push(current);
|
|
15
|
+
current = current.parent;
|
|
16
|
+
}
|
|
17
|
+
return commands.toReversed().flatMap((owner) => owner.options);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface InvocationOptionMatch {
|
|
21
|
+
readonly inlineValue: boolean;
|
|
22
|
+
readonly option: Option;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const findShortOption = (
|
|
26
|
+
options: readonly Option[],
|
|
27
|
+
short: string
|
|
28
|
+
): Option | undefined => options.find((candidate) => candidate.short === short);
|
|
29
|
+
|
|
30
|
+
export const invocationOptionMatches = (
|
|
31
|
+
options: readonly Option[],
|
|
32
|
+
token: string
|
|
33
|
+
): readonly InvocationOptionMatch[] => {
|
|
34
|
+
const exact = options.filter(
|
|
35
|
+
(option) => token === option.long || token === option.short
|
|
36
|
+
);
|
|
37
|
+
if (exact.length > 0) {
|
|
38
|
+
return exact.map((option) => ({ inlineValue: false, option }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const longWithValue = options.filter(
|
|
42
|
+
(option) =>
|
|
43
|
+
option.long !== undefined &&
|
|
44
|
+
(option.required || option.optional) &&
|
|
45
|
+
token.startsWith(`${option.long}=`)
|
|
46
|
+
);
|
|
47
|
+
if (longWithValue.length > 0) {
|
|
48
|
+
return longWithValue.map((option) => ({ inlineValue: true, option }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (token.length <= 2 || token[0] !== '-' || token[1] === '-') {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const matches: InvocationOptionMatch[] = [];
|
|
56
|
+
let group = token.slice(1);
|
|
57
|
+
while (group.length > 0) {
|
|
58
|
+
const option = findShortOption(options, `-${group[0]}`);
|
|
59
|
+
if (option === undefined) {
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
const inlineValue =
|
|
63
|
+
(option.required || option.optional) && group.length > 1;
|
|
64
|
+
matches.push({ inlineValue, option });
|
|
65
|
+
if (option.required || option.optional) {
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
group = group.slice(1);
|
|
69
|
+
}
|
|
70
|
+
return matches;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const isNegativeNumberArg = (
|
|
74
|
+
command: Command,
|
|
75
|
+
token: string
|
|
76
|
+
): boolean => {
|
|
77
|
+
if (!/^-(\d+|\d*\.\d+)(e[+-]?\d+)?$/.test(token)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (
|
|
82
|
+
let current: Command | null = command;
|
|
83
|
+
current !== null;
|
|
84
|
+
current = current.parent
|
|
85
|
+
) {
|
|
86
|
+
if (current.options.some((option) => /^-\d$/.test(option.short ?? ''))) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return true;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const optionConsumesFollowingValue = (
|
|
95
|
+
command: Command,
|
|
96
|
+
match: InvocationOptionMatch,
|
|
97
|
+
nextToken: string | undefined
|
|
98
|
+
): boolean =>
|
|
99
|
+
!match.inlineValue &&
|
|
100
|
+
(match.option.required ||
|
|
101
|
+
(match.option.optional &&
|
|
102
|
+
nextToken !== undefined &&
|
|
103
|
+
(!nextToken.startsWith('-') || isNegativeNumberArg(command, nextToken))));
|
|
104
|
+
|
|
105
|
+
const argvUserStart = (
|
|
106
|
+
parseOptions?: EffectiveParseOptions | undefined
|
|
107
|
+
): number => {
|
|
108
|
+
if (parseOptions?.from === 'user') {
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
if (parseOptions?.from === 'eval') {
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
if (parseOptions?.from === 'electron') {
|
|
115
|
+
const electronProcess = process as NodeJS.Process & {
|
|
116
|
+
readonly defaultApp?: boolean | undefined;
|
|
117
|
+
};
|
|
118
|
+
return electronProcess.defaultApp ? 2 : 1;
|
|
119
|
+
}
|
|
120
|
+
return 2;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const effectiveParseOptions = (
|
|
124
|
+
argv: readonly string[] | undefined,
|
|
125
|
+
parseOptions: ParseOptions | undefined
|
|
126
|
+
): EffectiveParseOptions | undefined => {
|
|
127
|
+
if (
|
|
128
|
+
argv === undefined &&
|
|
129
|
+
parseOptions?.from === undefined &&
|
|
130
|
+
process.execArgv.some((arg) =>
|
|
131
|
+
['-e', '--eval', '-p', '--print'].includes(arg)
|
|
132
|
+
)
|
|
133
|
+
) {
|
|
134
|
+
// Commander supports this origin internally but does not publish it in
|
|
135
|
+
// ParseOptions. Preserve its one-token offset while normalizing argv.
|
|
136
|
+
return { from: 'eval' };
|
|
137
|
+
}
|
|
138
|
+
if (
|
|
139
|
+
argv === undefined &&
|
|
140
|
+
parseOptions?.from === undefined &&
|
|
141
|
+
process.versions['electron'] !== undefined
|
|
142
|
+
) {
|
|
143
|
+
return { from: 'electron' };
|
|
144
|
+
}
|
|
145
|
+
return parseOptions;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export class TrailsCommanderProgram extends Command {
|
|
149
|
+
readonly #commands: readonly CliCommand[];
|
|
150
|
+
|
|
151
|
+
constructor(commands: readonly CliCommand[]) {
|
|
152
|
+
super();
|
|
153
|
+
this.#commands = commands;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#normalizeArgv(
|
|
157
|
+
argv: readonly string[] | undefined,
|
|
158
|
+
parseOptions: EffectiveParseOptions | undefined
|
|
159
|
+
): readonly string[] {
|
|
160
|
+
const input = argv ?? process.argv;
|
|
161
|
+
const start = argvUserStart(parseOptions);
|
|
162
|
+
return [
|
|
163
|
+
...input.slice(0, start),
|
|
164
|
+
...normalizeCliArgv(this.#commands, input.slice(start)),
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
override parse(argv?: readonly string[], parseOptions?: ParseOptions): this {
|
|
169
|
+
const options = effectiveParseOptions(argv, parseOptions);
|
|
170
|
+
const normalized = this.#normalizeArgv(argv, options);
|
|
171
|
+
return super.parse(normalized, options as ParseOptions | undefined);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
override parseAsync(
|
|
175
|
+
argv?: readonly string[],
|
|
176
|
+
parseOptions?: ParseOptions
|
|
177
|
+
): Promise<this> {
|
|
178
|
+
const options = effectiveParseOptions(argv, parseOptions);
|
|
179
|
+
const normalized = this.#normalizeArgv(argv, options);
|
|
180
|
+
return super.parseAsync(normalized, options as ParseOptions | undefined);
|
|
181
|
+
}
|
|
182
|
+
}
|
package/src/surface.ts
CHANGED
|
@@ -38,7 +38,7 @@ export interface CreateProgramOptions extends BaseSurfaceOptions {
|
|
|
38
38
|
/**
|
|
39
39
|
* App-authored overlay envelopes (conventionally the app module's
|
|
40
40
|
* `trailsOverlays` export); the `surfaces` envelope's `cli` bindings
|
|
41
|
-
*
|
|
41
|
+
* render synonym and command-group routes onto the program.
|
|
42
42
|
*/
|
|
43
43
|
readonly overlays?: readonly OverlayEnvelopeLike[] | undefined;
|
|
44
44
|
readonly presets?: CliFlag[][] | undefined;
|
package/src/to-commander.ts
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
isTrailsError,
|
|
7
|
-
|
|
7
|
+
renderPublicSurfaceError,
|
|
8
8
|
redactErrorContext,
|
|
9
9
|
redactErrorString,
|
|
10
10
|
ValidationError,
|
|
11
11
|
} from '@ontrails/core';
|
|
12
|
-
import type {
|
|
12
|
+
import type { SurfaceErrorRendering } from '@ontrails/core';
|
|
13
13
|
import type { CliCommand, CliFlag } from '@ontrails/cli';
|
|
14
14
|
import {
|
|
15
15
|
applyCliFlagValueAliases,
|
|
@@ -18,6 +18,14 @@ import {
|
|
|
18
18
|
} from '@ontrails/cli';
|
|
19
19
|
import { Command, InvalidArgumentError, Option } from 'commander';
|
|
20
20
|
|
|
21
|
+
import {
|
|
22
|
+
invocationOptionMatches,
|
|
23
|
+
isNegativeNumberArg,
|
|
24
|
+
optionConsumesFollowingValue,
|
|
25
|
+
TrailsCommanderProgram,
|
|
26
|
+
visibleOptionsFor,
|
|
27
|
+
} from './multiselect-argv.js';
|
|
28
|
+
|
|
21
29
|
// ---------------------------------------------------------------------------
|
|
22
30
|
// Options
|
|
23
31
|
// ---------------------------------------------------------------------------
|
|
@@ -224,87 +232,6 @@ const rootCommandFor = (command: Command): Command => {
|
|
|
224
232
|
const invocationArgsFor = (command: Command): readonly string[] =>
|
|
225
233
|
rootCommandFor(command).args;
|
|
226
234
|
|
|
227
|
-
const visibleOptionsFor = (command: Command): readonly Option[] => {
|
|
228
|
-
const commands: Command[] = [];
|
|
229
|
-
let current: Command | null = command;
|
|
230
|
-
while (current !== null) {
|
|
231
|
-
commands.push(current);
|
|
232
|
-
current = current.parent;
|
|
233
|
-
}
|
|
234
|
-
return commands.toReversed().flatMap((owner) => owner.options);
|
|
235
|
-
};
|
|
236
|
-
|
|
237
|
-
interface InvocationOptionMatch {
|
|
238
|
-
readonly inlineValue: boolean;
|
|
239
|
-
readonly option: Option;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const findShortOption = (
|
|
243
|
-
options: readonly Option[],
|
|
244
|
-
short: string
|
|
245
|
-
): Option | undefined => options.find((candidate) => candidate.short === short);
|
|
246
|
-
|
|
247
|
-
const invocationOptionMatches = (
|
|
248
|
-
options: readonly Option[],
|
|
249
|
-
token: string
|
|
250
|
-
): readonly InvocationOptionMatch[] => {
|
|
251
|
-
const exact = options.filter(
|
|
252
|
-
(option) => token === option.long || token === option.short
|
|
253
|
-
);
|
|
254
|
-
if (exact.length > 0) {
|
|
255
|
-
return exact.map((option) => ({ inlineValue: false, option }));
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const longWithValue = options.filter(
|
|
259
|
-
(option) =>
|
|
260
|
-
option.long !== undefined &&
|
|
261
|
-
(option.required || option.optional) &&
|
|
262
|
-
token.startsWith(`${option.long}=`)
|
|
263
|
-
);
|
|
264
|
-
if (longWithValue.length > 0) {
|
|
265
|
-
return longWithValue.map((option) => ({ inlineValue: true, option }));
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (token.length <= 2 || token[0] !== '-' || token[1] === '-') {
|
|
269
|
-
return [];
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const matches: InvocationOptionMatch[] = [];
|
|
273
|
-
let group = token.slice(1);
|
|
274
|
-
while (group.length > 0) {
|
|
275
|
-
const option = findShortOption(options, `-${group[0]}`);
|
|
276
|
-
if (option === undefined) {
|
|
277
|
-
break;
|
|
278
|
-
}
|
|
279
|
-
const inlineValue =
|
|
280
|
-
(option.required || option.optional) && group.length > 1;
|
|
281
|
-
matches.push({ inlineValue, option });
|
|
282
|
-
if (option.required || option.optional) {
|
|
283
|
-
break;
|
|
284
|
-
}
|
|
285
|
-
group = group.slice(1);
|
|
286
|
-
}
|
|
287
|
-
return matches;
|
|
288
|
-
};
|
|
289
|
-
|
|
290
|
-
const isNegativeNumberArg = (command: Command, token: string): boolean => {
|
|
291
|
-
if (!/^-(\d+|\d*\.\d+)(e[+-]?\d+)?$/.test(token)) {
|
|
292
|
-
return false;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
for (
|
|
296
|
-
let current: Command | null = command;
|
|
297
|
-
current !== null;
|
|
298
|
-
current = current.parent
|
|
299
|
-
) {
|
|
300
|
-
if (current.options.some((option) => /^-\d$/.test(option.short ?? ''))) {
|
|
301
|
-
return false;
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
return true;
|
|
306
|
-
};
|
|
307
|
-
|
|
308
235
|
const isVariadicOptionValue = (
|
|
309
236
|
command: Command,
|
|
310
237
|
option: Option | undefined,
|
|
@@ -313,17 +240,6 @@ const isVariadicOptionValue = (
|
|
|
313
240
|
option !== undefined &&
|
|
314
241
|
(!token.startsWith('-') || isNegativeNumberArg(command, token));
|
|
315
242
|
|
|
316
|
-
const optionConsumesFollowingValue = (
|
|
317
|
-
command: Command,
|
|
318
|
-
match: InvocationOptionMatch,
|
|
319
|
-
nextToken: string | undefined
|
|
320
|
-
): boolean =>
|
|
321
|
-
!match.inlineValue &&
|
|
322
|
-
(match.option.required ||
|
|
323
|
-
(match.option.optional &&
|
|
324
|
-
nextToken !== undefined &&
|
|
325
|
-
(!nextToken.startsWith('-') || isNegativeNumberArg(command, nextToken))));
|
|
326
|
-
|
|
327
243
|
interface InvocationScan {
|
|
328
244
|
readonly pathEnd: number | undefined;
|
|
329
245
|
readonly structuredInputIndexes: ReadonlySet<number>;
|
|
@@ -506,8 +422,13 @@ const getUserSuppliedFlagKeys = (
|
|
|
506
422
|
): ReadonlySet<string> => {
|
|
507
423
|
const userSupplied = new Set<string>();
|
|
508
424
|
for (const key of getFlagOptionKeys(flags)) {
|
|
509
|
-
|
|
510
|
-
|
|
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;
|
|
511
432
|
}
|
|
512
433
|
}
|
|
513
434
|
return userSupplied;
|
|
@@ -590,7 +511,7 @@ const collectPermitScopeLines = (
|
|
|
590
511
|
/**
|
|
591
512
|
* Collect operator-facing detail lines for an execution error.
|
|
592
513
|
*
|
|
593
|
-
* The public surface
|
|
514
|
+
* The public surface rendering intentionally drops structured context, but
|
|
594
515
|
* the CLI is an operator surface: validation issues and permit scope
|
|
595
516
|
* requirements are what the operator needs to act, so they are re-rendered
|
|
596
517
|
* here (through the shared redactor) for non-internal Trails errors.
|
|
@@ -621,7 +542,7 @@ const collectErrorContext = (
|
|
|
621
542
|
interface CliErrorEnvelope {
|
|
622
543
|
readonly ok: false;
|
|
623
544
|
readonly context?: Record<string, unknown> | undefined;
|
|
624
|
-
readonly error:
|
|
545
|
+
readonly error: SurfaceErrorRendering;
|
|
625
546
|
readonly details?: readonly string[] | undefined;
|
|
626
547
|
}
|
|
627
548
|
|
|
@@ -659,12 +580,12 @@ const handleError = (
|
|
|
659
580
|
userSuppliedFlagKeys: ReadonlySet<string>
|
|
660
581
|
): void => {
|
|
661
582
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
662
|
-
const
|
|
583
|
+
const rendering = renderPublicSurfaceError('cli', err);
|
|
663
584
|
const context = collectErrorContext(err);
|
|
664
585
|
const details = collectErrorDetailLines(err);
|
|
665
586
|
const mode = structuredErrorMode(flags, topoName, userSuppliedFlagKeys);
|
|
666
587
|
if (mode === undefined) {
|
|
667
|
-
process.stderr.write(`Error: ${
|
|
588
|
+
process.stderr.write(`Error: ${rendering.message}\n`);
|
|
668
589
|
for (const line of details) {
|
|
669
590
|
process.stderr.write(` ${line}\n`);
|
|
670
591
|
}
|
|
@@ -672,14 +593,14 @@ const handleError = (
|
|
|
672
593
|
writeStructuredError(
|
|
673
594
|
{
|
|
674
595
|
...(context === undefined ? {} : { context }),
|
|
675
|
-
error:
|
|
596
|
+
error: rendering,
|
|
676
597
|
...(details.length === 0 ? {} : { details }),
|
|
677
598
|
ok: false,
|
|
678
599
|
},
|
|
679
600
|
mode
|
|
680
601
|
);
|
|
681
602
|
}
|
|
682
|
-
process.exit(
|
|
603
|
+
process.exit(rendering.code);
|
|
683
604
|
};
|
|
684
605
|
|
|
685
606
|
const collectDisallowedAncestorOptions = (
|
|
@@ -999,7 +920,7 @@ export const toCommander = (
|
|
|
999
920
|
options?: ToCommanderOptions
|
|
1000
921
|
): Command => {
|
|
1001
922
|
validateCliCommands(commands);
|
|
1002
|
-
const program = new
|
|
923
|
+
const program = new TrailsCommanderProgram(commands);
|
|
1003
924
|
applyOptions(program, options);
|
|
1004
925
|
const topoName = options?.topoName ?? options?.name ?? program.name();
|
|
1005
926
|
const nodes = new Map<string, CommandNodeState>();
|