@ontrails/commander 1.0.0-beta.32 → 1.0.0-beta.41

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 CHANGED
@@ -1,5 +1,46 @@
1
1
  # @ontrails/commander
2
2
 
3
+ ## 1.0.0-beta.41
4
+
5
+ ## 1.0.0-beta.40
6
+
7
+ ### Patch Changes
8
+
9
+ - [`4030698`](https://github.com/outfitter-dev/trails/commit/40306984467625844564f0f84156530d7118a79c): Keep structured input on nested child commands from being reinterpreted as a
10
+ bare child-name positional fallback, while preserving schema-authored
11
+ `inputJson` flags as ordinary trail input, including through the public Trails
12
+ CLI. Optional numeric flags now consume negative values with Commander's own
13
+ parsing semantics, and variadic flags consume every following value, before
14
+ nested command routing is resolved.
15
+
16
+ ## 1.0.0-beta.39
17
+
18
+ ### Patch Changes
19
+
20
+ - [`f42ca6e`](https://github.com/outfitter-dev/trails/commit/f42ca6e40b29155acec446e5bf44e52e014466bd): Hard cutover: the CLI consumes `cli` bindings from the app-authored surfaces overlay. Scalar bindings behave identically to the removed cliAliases (parity-tested) — the binding name splits on `.` into a transparent synonym command path for exactly one trail. List bindings arrive as command groups: each expanded member trail gets a group-prefixed route that dispatches the member trail with its identity preserved, and a singleton list stays a group. Expansion is fail-fast boundary validation: a scalar binding resolving to zero or multiple trails, or a group with an empty member union, is a `ValidationError` naming the binding. `DeriveTopoGraphOptions.cliAliases`, the `cliAliases`/`trailsCliAliases` app-module export convention, and the per-kind compile lift are deleted; `deriveCliCommands`/`createProgram` take `overlays` instead of `aliases`, and both topo-graph derivation pipelines expand the same bindings through one shared helper so runtime CLI routes and lock routes come from one semantic. A leftover legacy export is now a Warden error (`no-legacy-cli-alias-export`) naming the `surfaceOverlay({ cli: { ... } })` rewrite.
21
+
22
+ This is a breaking API removal shipped under the lockstep beta patch convention (pre-1.0 hard-cutover posture, zero external adoption); the removed options have no deprecation window by design.
23
+
24
+ ## 1.0.0-beta.38
25
+
26
+ ## 1.0.0-beta.37
27
+
28
+ ## 1.0.0-beta.36
29
+
30
+ ## 1.0.0-beta.35
31
+
32
+ ## 1.0.0-beta.34
33
+
34
+ ## 1.0.0-beta.33
35
+
36
+ ### Patch Changes
37
+
38
+ - [`945cb4b`](https://github.com/outfitter-dev/trails/commit/945cb4b0bef40ddb098c2a3816f9adad6f135410): Honor explicit structured input values over CLI flag defaults when commands merge
39
+ `--input-json` or `--input` payloads.
40
+
41
+ Commander now forwards user-supplied flag metadata so explicit flag values that
42
+ match a default still keep normal CLI precedence over structured input.
43
+
3
44
  ## 1.0.0-beta.32
4
45
 
5
46
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/commander",
3
- "version": "1.0.0-beta.32",
3
+ "version": "1.0.0-beta.41",
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.32",
26
- "@ontrails/core": "^1.0.0-beta.32",
25
+ "@ontrails/cli": "^1.0.0-beta.41",
26
+ "@ontrails/core": "^1.0.0-beta.41",
27
27
  "commander": "^14.0.3"
28
28
  },
29
29
  "peerDependencies": {
package/src/surface.ts CHANGED
@@ -4,8 +4,8 @@
4
4
 
5
5
  import type {
6
6
  BaseSurfaceOptions,
7
- CliCommandAliasInput,
8
7
  Layer,
8
+ OverlayEnvelopeLike,
9
9
  ResourceOverrideMap,
10
10
  Topo,
11
11
  TrailContextInit,
@@ -28,9 +28,6 @@ import { toCommander } from './to-commander.js';
28
28
  * Options for creating Commander CLI surfaces from a Trails topo.
29
29
  */
30
30
  export interface CreateProgramOptions extends BaseSurfaceOptions {
31
- readonly aliases?:
32
- | Readonly<Record<string, readonly CliCommandAliasInput[]>>
33
- | undefined;
34
31
  readonly createContext?:
35
32
  | (() => TrailContextInit | Promise<TrailContextInit>)
36
33
  | undefined;
@@ -38,6 +35,12 @@ export interface CreateProgramOptions extends BaseSurfaceOptions {
38
35
  readonly layers?: readonly Layer[] | undefined;
39
36
  readonly name?: string | undefined;
40
37
  readonly onResult?: ((ctx: ActionResultContext) => Promise<void>) | undefined;
38
+ /**
39
+ * App-authored overlay envelopes (conventionally the app module's
40
+ * `trailsOverlays` export); the `surfaces` envelope's `cli` bindings
41
+ * project synonym and command-group routes onto the program.
42
+ */
43
+ readonly overlays?: readonly OverlayEnvelopeLike[] | undefined;
41
44
  readonly presets?: CliFlag[][] | undefined;
42
45
  readonly resources?: ResourceOverrideMap | undefined;
43
46
  readonly resolveInput?: InputResolver | undefined;
@@ -93,7 +96,6 @@ export const createProgram = (
93
96
  options: CreateProgramOptions = {}
94
97
  ) => {
95
98
  const commandsResult = deriveCliCommands(graph, {
96
- aliases: options.aliases,
97
99
  configValues: options.configValues,
98
100
  createContext: options.createContext,
99
101
  exclude: options.exclude,
@@ -101,6 +103,7 @@ export const createProgram = (
101
103
  intent: options.intent,
102
104
  layers: options.layers,
103
105
  onResult: options.onResult ?? defaultOnResult,
106
+ overlays: options.overlays,
104
107
  presets: options.presets,
105
108
  resolveInput: options.resolveInput,
106
109
  resolvePermitFromToken: options.resolvePermitFromToken,
@@ -84,6 +84,8 @@ const buildRepeatableArrayParser =
84
84
  return [...(Array.isArray(previous) ? previous : []), parsed];
85
85
  };
86
86
 
87
+ const structuredInputOptions = new WeakSet<Option>();
88
+
87
89
  /** Apply common modifiers (choices, default, arg parser) to a Commander Option. */
88
90
  const applyOptionModifiers = (opt: Option, flag: CliFlag): void => {
89
91
  if (isRepeatableArrayFlag(flag)) {
@@ -107,6 +109,9 @@ const applyOptionModifiers = (opt: Option, flag: CliFlag): void => {
107
109
  /** Build Commander Option(s) from a CliFlag. Returns one or two options. */
108
110
  const buildOptions = (flag: CliFlag): Option[] => {
109
111
  const opt = new Option(buildFlagString(flag), flag.description);
112
+ if (flag.role === 'structured-input') {
113
+ structuredInputOptions.add(opt);
114
+ }
110
115
  applyOptionModifiers(opt, flag);
111
116
  const valueAliasOptions = (flag.valueAliases ?? []).map(
112
117
  (alias) =>
@@ -198,6 +203,199 @@ const INHERITED_SURFACE_OPTION_KEYS = new Set([
198
203
  'trace',
199
204
  'watch',
200
205
  ]);
206
+ const commandPathCommands = (command: Command): readonly Command[] => {
207
+ const commands: Command[] = [];
208
+ let current: Command | null = command;
209
+ while (current !== null && current.parent !== null) {
210
+ commands.push(current);
211
+ current = current.parent;
212
+ }
213
+ return commands.toReversed();
214
+ };
215
+
216
+ const rootCommandFor = (command: Command): Command => {
217
+ let root = command;
218
+ while (root.parent !== null) {
219
+ root = root.parent;
220
+ }
221
+ return root;
222
+ };
223
+
224
+ const invocationArgsFor = (command: Command): readonly string[] =>
225
+ rootCommandFor(command).args;
226
+
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
+ const isVariadicOptionValue = (
309
+ command: Command,
310
+ option: Option | undefined,
311
+ token: string
312
+ ): boolean =>
313
+ option !== undefined &&
314
+ (!token.startsWith('-') || isNegativeNumberArg(command, token));
315
+
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
+ interface InvocationScan {
328
+ readonly pathEnd: number | undefined;
329
+ readonly structuredInputIndexes: ReadonlySet<number>;
330
+ }
331
+
332
+ const scanInvocation = (command: Command): InvocationScan => {
333
+ const rawArgs = invocationArgsFor(command);
334
+ const pathCommands = commandPathCommands(command);
335
+ const structuredInputIndexes = new Set<number>();
336
+ let activeCommand = rootCommandFor(command);
337
+ let activeVariadicOption: Option | undefined;
338
+ let optionsEnded = false;
339
+ let pathEnd: number | undefined;
340
+ let pathOffset = 0;
341
+ for (let index = 0; index < rawArgs.length; index += 1) {
342
+ const token = rawArgs[index];
343
+ if (token === undefined) {
344
+ continue;
345
+ }
346
+ if (!optionsEnded && token === '--') {
347
+ optionsEnded = true;
348
+ activeVariadicOption = undefined;
349
+ continue;
350
+ }
351
+ if (isVariadicOptionValue(command, activeVariadicOption, token)) {
352
+ continue;
353
+ }
354
+ activeVariadicOption = undefined;
355
+ const optionMatches = optionsEnded
356
+ ? []
357
+ : invocationOptionMatches(visibleOptionsFor(activeCommand), token);
358
+ const finalOptionMatch = optionMatches.at(-1);
359
+ if (finalOptionMatch !== undefined) {
360
+ if (
361
+ optionMatches.some((match) => structuredInputOptions.has(match.option))
362
+ ) {
363
+ structuredInputIndexes.add(index);
364
+ }
365
+ activeVariadicOption =
366
+ finalOptionMatch.option.variadic && !finalOptionMatch.inlineValue
367
+ ? finalOptionMatch.option
368
+ : undefined;
369
+ if (
370
+ optionConsumesFollowingValue(
371
+ command,
372
+ finalOptionMatch,
373
+ rawArgs[index + 1]
374
+ )
375
+ ) {
376
+ index += 1;
377
+ }
378
+ continue;
379
+ }
380
+ const nextPathCommand = pathCommands[pathOffset];
381
+ if (nextPathCommand?.name() === token) {
382
+ activeCommand = nextPathCommand;
383
+ pathOffset += 1;
384
+ if (pathOffset === pathCommands.length) {
385
+ pathEnd = index;
386
+ }
387
+ }
388
+ }
389
+ return { pathEnd, structuredInputIndexes };
390
+ };
391
+
392
+ const hasStructuredInputAfterCommandPath = (command: Command): boolean => {
393
+ const { pathEnd, structuredInputIndexes } = scanInvocation(command);
394
+ if (pathEnd === undefined) {
395
+ return false;
396
+ }
397
+ return [...structuredInputIndexes].some((index) => index > pathEnd);
398
+ };
201
399
 
202
400
  const hasUserSuppliedOptionOutside = (
203
401
  sourceCommand: Command,
@@ -217,6 +415,28 @@ const hasAnyPositionalValue = (
217
415
  parsedArgs: Readonly<Record<string, unknown>>
218
416
  ): boolean => cmd.args.some((arg) => parsedArgs[arg.name] !== undefined);
219
417
 
418
+ const hasUserSuppliedStructuredInput = (
419
+ command: Command,
420
+ fallback?: BareChildFallback | undefined,
421
+ ownOptionsOnly = false
422
+ ): boolean => {
423
+ for (const option of command.options) {
424
+ if (!structuredInputOptions.has(option)) {
425
+ continue;
426
+ }
427
+ const name = option.attributeName();
428
+ const source = ownOptionsOnly
429
+ ? command.getOptionValueSource(name)
430
+ : command.getOptionValueSourceWithGlobals(name);
431
+ if (source !== undefined && source !== 'default' && source !== 'implied') {
432
+ return (
433
+ fallback === undefined || hasStructuredInputAfterCommandPath(command)
434
+ );
435
+ }
436
+ }
437
+ return false;
438
+ };
439
+
220
440
  const getActionTarget = (fallbackTarget: Command, actionArgs: unknown[]) => {
221
441
  const candidate = actionArgs.at(-1);
222
442
  return candidate instanceof Command ? candidate : fallbackTarget;
@@ -510,12 +730,15 @@ const maybeUseBareChildFallback = (
510
730
  readonly parsedFlags: Record<string, unknown>;
511
731
  readonly userSuppliedFlagKeys: ReadonlySet<string>;
512
732
  } => {
513
- const hasParentOnlySignal = fallback
514
- ? hasUserSuppliedOptionOutside(fallback.parentTarget, target)
515
- : false;
733
+ const hasParentOnlySignal =
734
+ fallback !== undefined &&
735
+ (hasUserSuppliedOptionOutside(fallback.parentTarget, target) ||
736
+ (hasUserSuppliedStructuredInput(fallback.parentTarget, undefined, true) &&
737
+ !hasStructuredInputAfterCommandPath(target)));
516
738
  if (
517
739
  !fallback ||
518
740
  hasAnyPositionalValue(cmd, parsedArgs) ||
741
+ hasUserSuppliedStructuredInput(target, fallback) ||
519
742
  hasUserSuppliedOptionOutside(target, fallback.parentTarget) ||
520
743
  (fallback.requiresParentSignal && !hasParentOnlySignal)
521
744
  ) {
@@ -581,7 +804,12 @@ const wireAction = (
581
804
  action.parsedFlags,
582
805
  action.userSuppliedFlagKeys
583
806
  );
584
- await action.command.execute(action.parsedArgs, parsedFlags);
807
+ await action.command.execute(action.parsedArgs, parsedFlags, undefined, {
808
+ userSuppliedFlagKeys: getCanonicalUserSuppliedFlagKeys(
809
+ action.command.flags,
810
+ action.userSuppliedFlagKeys
811
+ ),
812
+ });
585
813
  } catch (error: unknown) {
586
814
  handleError(
587
815
  error,