@ontrails/trails 1.0.0-beta.4 → 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.
Files changed (165) hide show
  1. package/CHANGELOG.md +1104 -4
  2. package/README.md +27 -0
  3. package/package.json +31 -7
  4. package/src/app.ts +147 -3
  5. package/src/cli.ts +345 -11
  6. package/src/completions.ts +240 -0
  7. package/src/lifecycle-source-io.ts +33 -0
  8. package/src/load-app-mirror.ts +202 -0
  9. package/src/local-state-io.ts +173 -0
  10. package/src/mcp-app.ts +42 -0
  11. package/src/mcp-options.ts +91 -0
  12. package/src/mcp.ts +8 -0
  13. package/src/project-writes.ts +377 -0
  14. package/src/regrade/config.ts +152 -0
  15. package/src/regrade/history.ts +430 -0
  16. package/src/regrade/live-api-preserve.ts +8 -0
  17. package/src/regrade/plan-artifact.ts +285 -0
  18. package/src/release/bindings.ts +39 -0
  19. package/src/release/check.ts +844 -0
  20. package/src/release/config.ts +63 -0
  21. package/src/release/contract-facts.ts +425 -0
  22. package/src/release/index.ts +142 -0
  23. package/src/release/lock-roundtrip-smoke.ts +230 -0
  24. package/src/release/native-bun-publish.ts +651 -0
  25. package/src/release/native-bun-registry.ts +765 -0
  26. package/src/release/notes-cli.ts +171 -0
  27. package/src/release/notes.ts +390 -0
  28. package/src/release/pack-coherence.ts +455 -0
  29. package/src/release/packed-artifacts-smoke.ts +236 -0
  30. package/src/release/policy.ts +1687 -0
  31. package/src/release/semver.ts +104 -0
  32. package/src/release/smoke.ts +56 -0
  33. package/src/release/wayfinder-dogfood-smoke.ts +760 -0
  34. package/src/retired-topo-command.ts +36 -0
  35. package/src/run-adapter-check.ts +76 -0
  36. package/src/run-collision.ts +126 -0
  37. package/src/run-completions-install.ts +179 -0
  38. package/src/run-example.ts +149 -0
  39. package/src/run-examples.ts +148 -0
  40. package/src/run-quiet.ts +75 -0
  41. package/src/run-release-check.ts +74 -0
  42. package/src/run-schema.ts +74 -0
  43. package/src/run-trace.ts +273 -0
  44. package/src/run-warden.ts +39 -0
  45. package/src/run-watch.ts +432 -0
  46. package/src/run-wayfind-outline.ts +170 -0
  47. package/src/scaffold-version-sync.ts +183 -0
  48. package/src/scaffold-versions.generated.ts +12 -0
  49. package/src/trails/adapter-check.ts +244 -0
  50. package/src/trails/add-surface.ts +99 -45
  51. package/src/trails/add-trail.ts +84 -37
  52. package/src/trails/add-verify.ts +100 -30
  53. package/src/trails/compile.ts +58 -0
  54. package/src/trails/completions-complete.ts +165 -0
  55. package/src/trails/completions.ts +47 -0
  56. package/src/trails/create-adapter.ts +785 -0
  57. package/src/trails/create-scaffold.ts +401 -106
  58. package/src/trails/create-versions.ts +62 -0
  59. package/src/trails/create.ts +186 -72
  60. package/src/trails/deprecate.ts +59 -0
  61. package/src/trails/dev-clean.ts +82 -0
  62. package/src/trails/dev-reset.ts +50 -0
  63. package/src/trails/dev-stats.ts +72 -0
  64. package/src/trails/dev-support.ts +360 -0
  65. package/src/trails/doctor.ts +77 -0
  66. package/src/trails/draft-promote.ts +949 -0
  67. package/src/trails/guide.ts +70 -68
  68. package/src/trails/load-app.ts +1123 -15
  69. package/src/trails/operator-context.ts +66 -0
  70. package/src/trails/project.ts +17 -3
  71. package/src/trails/regrade.ts +3091 -0
  72. package/src/trails/release-check.ts +105 -0
  73. package/src/trails/release-smoke.ts +49 -0
  74. package/src/trails/revise.ts +53 -0
  75. package/src/trails/root-dir.ts +21 -0
  76. package/src/trails/run-example.ts +475 -0
  77. package/src/trails/run-examples.ts +129 -0
  78. package/src/trails/run.ts +434 -0
  79. package/src/trails/scaffold-json.ts +58 -0
  80. package/src/trails/survey.ts +877 -214
  81. package/src/trails/topo-activation.ts +14 -0
  82. package/src/trails/topo-constants.ts +2 -0
  83. package/src/trails/topo-history.ts +47 -0
  84. package/src/trails/topo-output-schemas.ts +259 -0
  85. package/src/trails/topo-pin.ts +38 -0
  86. package/src/trails/topo-read-support.ts +368 -0
  87. package/src/trails/topo-reports.ts +809 -0
  88. package/src/trails/topo-store-support.ts +325 -0
  89. package/src/trails/topo-support.ts +220 -0
  90. package/src/trails/topo-unpin.ts +61 -0
  91. package/src/trails/topo.ts +92 -0
  92. package/src/trails/validate.ts +27 -0
  93. package/src/trails/version-lifecycle-support.ts +936 -0
  94. package/src/trails/warden-guide.ts +134 -0
  95. package/src/trails/warden.ts +198 -58
  96. package/src/trails/wayfind-outline.ts +876 -0
  97. package/src/trails/wayfind.ts +1053 -0
  98. package/src/versions.ts +31 -0
  99. package/.turbo/turbo-build.log +0 -1
  100. package/.turbo/turbo-lint.log +0 -3
  101. package/.turbo/turbo-typecheck.log +0 -1
  102. package/__tests__/examples.test.ts +0 -6
  103. package/dist/bin/trails.d.ts +0 -3
  104. package/dist/bin/trails.d.ts.map +0 -1
  105. package/dist/bin/trails.js +0 -4
  106. package/dist/bin/trails.js.map +0 -1
  107. package/dist/src/app.d.ts +0 -2
  108. package/dist/src/app.d.ts.map +0 -1
  109. package/dist/src/app.js +0 -11
  110. package/dist/src/app.js.map +0 -1
  111. package/dist/src/clack.d.ts +0 -9
  112. package/dist/src/clack.d.ts.map +0 -1
  113. package/dist/src/clack.js +0 -84
  114. package/dist/src/clack.js.map +0 -1
  115. package/dist/src/cli.d.ts +0 -2
  116. package/dist/src/cli.d.ts.map +0 -1
  117. package/dist/src/cli.js +0 -13
  118. package/dist/src/cli.js.map +0 -1
  119. package/dist/src/trails/add-surface.d.ts +0 -13
  120. package/dist/src/trails/add-surface.d.ts.map +0 -1
  121. package/dist/src/trails/add-surface.js +0 -88
  122. package/dist/src/trails/add-surface.js.map +0 -1
  123. package/dist/src/trails/add-trail.d.ts +0 -10
  124. package/dist/src/trails/add-trail.d.ts.map +0 -1
  125. package/dist/src/trails/add-trail.js +0 -77
  126. package/dist/src/trails/add-trail.js.map +0 -1
  127. package/dist/src/trails/add-verify.d.ts +0 -10
  128. package/dist/src/trails/add-verify.d.ts.map +0 -1
  129. package/dist/src/trails/add-verify.js +0 -67
  130. package/dist/src/trails/add-verify.js.map +0 -1
  131. package/dist/src/trails/create-scaffold.d.ts +0 -15
  132. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  133. package/dist/src/trails/create-scaffold.js +0 -288
  134. package/dist/src/trails/create-scaffold.js.map +0 -1
  135. package/dist/src/trails/create.d.ts +0 -22
  136. package/dist/src/trails/create.d.ts.map +0 -1
  137. package/dist/src/trails/create.js +0 -121
  138. package/dist/src/trails/create.js.map +0 -1
  139. package/dist/src/trails/guide.d.ts +0 -11
  140. package/dist/src/trails/guide.d.ts.map +0 -1
  141. package/dist/src/trails/guide.js +0 -80
  142. package/dist/src/trails/guide.js.map +0 -1
  143. package/dist/src/trails/load-app.d.ts +0 -4
  144. package/dist/src/trails/load-app.d.ts.map +0 -1
  145. package/dist/src/trails/load-app.js +0 -24
  146. package/dist/src/trails/load-app.js.map +0 -1
  147. package/dist/src/trails/project.d.ts +0 -8
  148. package/dist/src/trails/project.d.ts.map +0 -1
  149. package/dist/src/trails/project.js +0 -43
  150. package/dist/src/trails/project.js.map +0 -1
  151. package/dist/src/trails/survey.d.ts +0 -31
  152. package/dist/src/trails/survey.d.ts.map +0 -1
  153. package/dist/src/trails/survey.js +0 -221
  154. package/dist/src/trails/survey.js.map +0 -1
  155. package/dist/src/trails/warden.d.ts +0 -19
  156. package/dist/src/trails/warden.d.ts.map +0 -1
  157. package/dist/src/trails/warden.js +0 -88
  158. package/dist/src/trails/warden.js.map +0 -1
  159. package/dist/tsconfig.tsbuildinfo +0 -1
  160. package/src/__tests__/create.test.ts +0 -349
  161. package/src/__tests__/guide.test.ts +0 -91
  162. package/src/__tests__/load-app.test.ts +0 -15
  163. package/src/__tests__/survey.test.ts +0 -159
  164. package/src/__tests__/warden.test.ts +0 -74
  165. package/tsconfig.json +0 -9
@@ -0,0 +1,36 @@
1
+ const retiredTopoCommandReplacements = {
2
+ check: 'trails validate',
3
+ compile: 'trails compile',
4
+ verify: 'trails validate',
5
+ } as const;
6
+
7
+ export type RetiredTopoCommand = keyof typeof retiredTopoCommandReplacements;
8
+
9
+ export interface RetiredTopoCommandDiagnostic {
10
+ readonly attempted: `trails topo ${RetiredTopoCommand}`;
11
+ readonly message: string;
12
+ readonly replacement: (typeof retiredTopoCommandReplacements)[RetiredTopoCommand];
13
+ }
14
+
15
+ const isRetiredTopoCommand = (
16
+ command: string | undefined
17
+ ): command is RetiredTopoCommand =>
18
+ command !== undefined && command in retiredTopoCommandReplacements;
19
+
20
+ export const getRetiredTopoCommandDiagnostic = (
21
+ argv: readonly string[]
22
+ ): RetiredTopoCommandDiagnostic | null => {
23
+ const [command, subcommand] = argv.slice(2);
24
+ if (command !== 'topo' || !isRetiredTopoCommand(subcommand)) {
25
+ return null;
26
+ }
27
+
28
+ const replacement = retiredTopoCommandReplacements[subcommand];
29
+ const attempted = `trails topo ${subcommand}` as const;
30
+
31
+ return {
32
+ attempted,
33
+ message: `"${attempted}" was retired. Use "${replacement}" instead.\nTopography artifact commands now live at the top level: "trails compile", "trails validate", and "trails diff". "trails topo" is for topo-store history, pin, and unpin.`,
34
+ replacement,
35
+ };
36
+ };
@@ -0,0 +1,76 @@
1
+ import type { ActionResultContext } from '@ontrails/cli';
2
+ import { deriveOutputMode } from '@ontrails/cli';
3
+
4
+ interface AdapterCheckResultValue {
5
+ readonly formatted: string;
6
+ readonly passed: boolean;
7
+ }
8
+
9
+ const isAdapterCheckResultValue = (
10
+ value: unknown
11
+ ): value is AdapterCheckResultValue => {
12
+ if (typeof value !== 'object' || value === null) {
13
+ return false;
14
+ }
15
+ const candidate = value as Record<string, unknown>;
16
+ return (
17
+ typeof candidate['formatted'] === 'string' &&
18
+ typeof candidate['passed'] === 'boolean'
19
+ );
20
+ };
21
+
22
+ const wantsStructuredOutput = (ctx: ActionResultContext): boolean =>
23
+ deriveOutputMode(ctx.flags, ctx.topoName).mode !== 'text';
24
+
25
+ const isAdapterCheckTrail = (ctx: ActionResultContext): boolean =>
26
+ ctx.trail.id === 'adapter.check';
27
+
28
+ const readAdapterCheckResultValue = (
29
+ ctx: ActionResultContext
30
+ ): AdapterCheckResultValue | undefined => {
31
+ if (!isAdapterCheckTrail(ctx) || ctx.result.isErr()) {
32
+ return undefined;
33
+ }
34
+
35
+ return isAdapterCheckResultValue(ctx.result.value)
36
+ ? ctx.result.value
37
+ : undefined;
38
+ };
39
+
40
+ export const applyAdapterCheckExitCode = (
41
+ ctx: ActionResultContext
42
+ ): boolean => {
43
+ if (!isAdapterCheckTrail(ctx)) {
44
+ return false;
45
+ }
46
+
47
+ if (ctx.result.isErr()) {
48
+ process.exitCode = 1;
49
+ return true;
50
+ }
51
+
52
+ const value = readAdapterCheckResultValue(ctx);
53
+ if (!value) {
54
+ return false;
55
+ }
56
+
57
+ process.exitCode = value.passed ? 0 : 1;
58
+ return true;
59
+ };
60
+
61
+ export const tryAdapterCheckOutput = (ctx: ActionResultContext): boolean => {
62
+ const value = readAdapterCheckResultValue(ctx);
63
+ if (!value) {
64
+ return false;
65
+ }
66
+
67
+ applyAdapterCheckExitCode(ctx);
68
+ if (wantsStructuredOutput(ctx)) {
69
+ return false;
70
+ }
71
+
72
+ if (value.formatted.length > 0) {
73
+ process.stdout.write(`${value.formatted}\n`);
74
+ }
75
+ return true;
76
+ };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * CLI-surface bridge for the `run` trail's collision UX.
3
+ *
4
+ * The `run` trail is surface-agnostic: when a trail id collides across two or
5
+ * more workspace apps and no `--app` override is provided, the trail returns
6
+ * `Result.err(AmbiguousError)` with the candidate app names in `error.context`.
7
+ *
8
+ * The CLI surface decides whether to prompt the user (TTY) or surface the
9
+ * error verbatim (non-TTY). This module owns that surface decision so the
10
+ * trail itself never reads `process.stdin.isTTY` or imports a prompt library.
11
+ */
12
+
13
+ import type { ActionResultContext } from '@ontrails/cli';
14
+ import { AmbiguousError, executeTrail, isPlainObject } from '@ontrails/core';
15
+ import type { Result, Topo } from '@ontrails/core';
16
+ import * as clack from '@clack/prompts';
17
+
18
+ /** Runtime dependencies the wrapper resolves through; injectable for tests. */
19
+ export interface RunCollisionDeps {
20
+ readonly graph: Topo;
21
+ readonly isTTY?: () => boolean;
22
+ readonly promptForApp?: (
23
+ candidates: readonly string[],
24
+ trailId: string
25
+ ) => Promise<string | undefined>;
26
+ }
27
+
28
+ const defaultIsTTY = (): boolean => process.stdin.isTTY === true;
29
+
30
+ const defaultPromptForApp = async (
31
+ candidates: readonly string[],
32
+ trailId: string
33
+ ): Promise<string | undefined> => {
34
+ const choice = await clack.select({
35
+ message: `Trail ID '${trailId}' is exposed by multiple apps. Choose one:`,
36
+ options: candidates.map((appName) => ({
37
+ label: appName,
38
+ value: appName,
39
+ })),
40
+ });
41
+ return clack.isCancel(choice) ? undefined : (choice as string);
42
+ };
43
+
44
+ const isAmbiguousCollision = (
45
+ ctx: ActionResultContext
46
+ ): ctx is ActionResultContext & {
47
+ readonly result: { readonly error: AmbiguousError };
48
+ } =>
49
+ ctx.trail.id === 'run' &&
50
+ ctx.result.isErr() &&
51
+ ctx.result.error instanceof AmbiguousError;
52
+
53
+ const readCandidates = (error: AmbiguousError): readonly string[] => {
54
+ const ctx = error.context;
55
+ if (!isPlainObject(ctx)) {
56
+ return [];
57
+ }
58
+ const raw = ctx['candidates'];
59
+ if (!Array.isArray(raw)) {
60
+ return [];
61
+ }
62
+ return raw.filter((entry): entry is string => typeof entry === 'string');
63
+ };
64
+
65
+ const readTrailId = (error: AmbiguousError): string | undefined => {
66
+ const ctx = error.context;
67
+ if (!isPlainObject(ctx)) {
68
+ return;
69
+ }
70
+ const raw = ctx['trailId'];
71
+ return typeof raw === 'string' ? raw : undefined;
72
+ };
73
+
74
+ const hasAppOverride = (input: unknown): boolean =>
75
+ isPlainObject(input) && typeof input['app'] === 'string';
76
+
77
+ const mergeAppOverride = (
78
+ input: unknown,
79
+ app: string
80
+ ): Record<string, unknown> => ({
81
+ ...(isPlainObject(input) ? input : {}),
82
+ app,
83
+ });
84
+
85
+ /**
86
+ * Try to recover from an ambiguous-trail-id collision on the run trail.
87
+ *
88
+ * Returns the re-execution result when a TTY prompt yielded a chosen app, or
89
+ * `undefined` when there is nothing to recover (non-TTY, non-collision, or the
90
+ * user cancelled). The caller forwards `undefined` to the default result
91
+ * handler, which surfaces the error verbatim and maps it to exit code 1.
92
+ */
93
+ export const tryRecoverFromRunCollision = async (
94
+ ctx: ActionResultContext,
95
+ deps: RunCollisionDeps
96
+ ): Promise<Result<unknown, Error> | undefined> => {
97
+ if (!isAmbiguousCollision(ctx)) {
98
+ return;
99
+ }
100
+ if (hasAppOverride(ctx.input)) {
101
+ return;
102
+ }
103
+
104
+ const isTTY = deps.isTTY ?? defaultIsTTY;
105
+ if (!isTTY()) {
106
+ return;
107
+ }
108
+
109
+ const { error } = ctx.result;
110
+ const candidates = readCandidates(error);
111
+ const trailId = readTrailId(error);
112
+ if (candidates.length === 0 || trailId === undefined) {
113
+ return;
114
+ }
115
+
116
+ const promptForApp = deps.promptForApp ?? defaultPromptForApp;
117
+ const chosen = await promptForApp(candidates, trailId);
118
+ if (chosen === undefined) {
119
+ return;
120
+ }
121
+
122
+ return await executeTrail(ctx.trail, mergeAppOverride(ctx.input, chosen), {
123
+ ctx: ctx.permit === undefined ? {} : { permit: ctx.permit },
124
+ topo: deps.graph,
125
+ });
126
+ };
@@ -0,0 +1,179 @@
1
+ /**
2
+ * CLI bridge for installing shell completion scripts.
3
+ *
4
+ * This is intentionally not a trail: it resolves CLI-local defaults such as
5
+ * `$SHELL` and the user's home directory, then writes to the user's completion
6
+ * directory. The surface-agnostic trail remains `completions`, which renders a
7
+ * script string for any caller.
8
+ */
9
+
10
+ import { mkdir } from 'node:fs/promises';
11
+ import { homedir } from 'node:os';
12
+ import { dirname, join } from 'node:path';
13
+
14
+ import {
15
+ projectPublicSurfaceError,
16
+ Result,
17
+ ValidationError,
18
+ } from '@ontrails/core';
19
+ import type { Command } from 'commander';
20
+
21
+ import { renderCompletionScript } from './completions.js';
22
+ import type { CompletionShell } from './completions.js';
23
+
24
+ export const COMPLETIONS_BIN_NAME = 'trails';
25
+
26
+ const SHELLS = new Set<CompletionShell>(['bash', 'fish', 'zsh']);
27
+
28
+ const INSTALL_PATH_BY_SHELL: Readonly<Record<CompletionShell, string>> = {
29
+ bash: '.local/share/bash-completion/completions/trails',
30
+ fish: '.config/fish/completions/trails.fish',
31
+ zsh: '.local/share/zsh/site-functions/_trails',
32
+ };
33
+
34
+ export interface CompletionsInstallOptions {
35
+ readonly binName?: string | undefined;
36
+ readonly homeDir?: string | undefined;
37
+ readonly shell?: string | undefined;
38
+ readonly shellEnv?: string | undefined;
39
+ }
40
+
41
+ export interface CompletionsInstallResult {
42
+ readonly created: boolean;
43
+ readonly message: string;
44
+ readonly path: string;
45
+ readonly shell: CompletionShell;
46
+ }
47
+
48
+ interface StdoutLike {
49
+ write(chunk: string): unknown;
50
+ }
51
+
52
+ export interface AttachCompletionsInstallOptions {
53
+ readonly binName?: string | undefined;
54
+ readonly homeDir?: string | undefined;
55
+ readonly shellEnv?: string | undefined;
56
+ readonly stdout?: StdoutLike | undefined;
57
+ }
58
+
59
+ const isCompletionShell = (value: string): value is CompletionShell =>
60
+ SHELLS.has(value as CompletionShell);
61
+
62
+ const detectShellFromEnv = (shellEnv: string): CompletionShell | null => {
63
+ if (shellEnv.length === 0) {
64
+ return null;
65
+ }
66
+ const slashIndex = shellEnv.lastIndexOf('/');
67
+ const base = slashIndex === -1 ? shellEnv : shellEnv.slice(slashIndex + 1);
68
+ return isCompletionShell(base) ? base : null;
69
+ };
70
+
71
+ const unsupportedShellMessage =
72
+ 'Could not detect shell from $SHELL. Pass --shell with one of: bash, zsh, fish.';
73
+
74
+ const resolveTargetShell = (input: {
75
+ readonly shell?: string | undefined;
76
+ readonly shellEnv?: string | undefined;
77
+ }): Result<CompletionShell, ValidationError> => {
78
+ if (input.shell !== undefined) {
79
+ if (isCompletionShell(input.shell)) {
80
+ return Result.ok(input.shell);
81
+ }
82
+ return Result.err(
83
+ new ValidationError(
84
+ `Unsupported shell "${input.shell}". Pass one of: bash, zsh, fish.`
85
+ )
86
+ );
87
+ }
88
+ const envValue = input.shellEnv ?? process.env['SHELL'] ?? '';
89
+ const detected = detectShellFromEnv(envValue);
90
+ return detected === null
91
+ ? Result.err(new ValidationError(unsupportedShellMessage))
92
+ : Result.ok(detected);
93
+ };
94
+
95
+ const fileExists = async (path: string): Promise<boolean> =>
96
+ await Bun.file(path).exists();
97
+
98
+ export const runCompletionsInstall = async (
99
+ options: CompletionsInstallOptions = {}
100
+ ): Promise<Result<CompletionsInstallResult, Error>> => {
101
+ const shellResult = resolveTargetShell(options);
102
+ if (shellResult.isErr()) {
103
+ return shellResult;
104
+ }
105
+
106
+ const shell = shellResult.value;
107
+ const home = options.homeDir ?? homedir();
108
+ const path = join(home, INSTALL_PATH_BY_SHELL[shell]);
109
+ const scriptResult = renderCompletionScript(
110
+ shell,
111
+ options.binName ?? COMPLETIONS_BIN_NAME
112
+ );
113
+ if (scriptResult.isErr()) {
114
+ return scriptResult;
115
+ }
116
+
117
+ let existed: boolean;
118
+ try {
119
+ existed = await fileExists(path);
120
+ await mkdir(dirname(path), { recursive: true });
121
+ await Bun.write(path, scriptResult.value);
122
+ } catch (error) {
123
+ return Result.err(
124
+ error instanceof Error ? error : new Error(String(error))
125
+ );
126
+ }
127
+ const created = !existed;
128
+
129
+ return Result.ok({
130
+ created,
131
+ message: created
132
+ ? `Installed ${shell} completions to ${path}. Run \`exec $SHELL\` or restart your shell to activate.`
133
+ : `Updated ${shell} completions at ${path}.`,
134
+ path,
135
+ shell,
136
+ });
137
+ };
138
+
139
+ const handleCliError = (error: unknown): void => {
140
+ const err = error instanceof Error ? error : new Error(String(error));
141
+ const projection = projectPublicSurfaceError('cli', err);
142
+ process.stderr.write(`Error: ${projection.message}\n`);
143
+ process.exit(projection.code);
144
+ };
145
+
146
+ const findCompletionsCommand = (program: Command): Command | undefined =>
147
+ program.commands.find((command) => command.name() === 'completions');
148
+
149
+ export const attachCompletionsInstallCommand = (
150
+ program: Command,
151
+ options: AttachCompletionsInstallOptions = {}
152
+ ): void => {
153
+ const completionsCommand =
154
+ findCompletionsCommand(program) ??
155
+ program
156
+ .command('completions')
157
+ .description('Render and install shell completion scripts');
158
+
159
+ completionsCommand
160
+ .command('install')
161
+ .description('Install a shell completion script for the trails CLI')
162
+ .option(
163
+ '-s, --shell <shell>',
164
+ 'Target shell; auto-detected from $SHELL when omitted.'
165
+ )
166
+ .action(async (flags: { readonly shell?: string | undefined }) => {
167
+ const result = await runCompletionsInstall({
168
+ binName: options.binName,
169
+ homeDir: options.homeDir,
170
+ shell: flags.shell,
171
+ shellEnv: options.shellEnv,
172
+ });
173
+ if (result.isErr()) {
174
+ handleCliError(result.error);
175
+ return;
176
+ }
177
+ (options.stdout ?? process.stdout).write(`${result.value.message}\n`);
178
+ });
179
+ };
@@ -0,0 +1,149 @@
1
+ /**
2
+ * CLI-surface bridge for the `run.example` trail.
3
+ *
4
+ * `run.example` resolves the named example on the target trail, executes it
5
+ * through the full pipeline, and packages an actual-vs-expected comparison
6
+ * into a structured envelope on the trail's outer `Result.ok(...)`. This module
7
+ * owns the surface decision of how to render that envelope:
8
+ *
9
+ * - Text mode (default): a compact summary on match, an `input / expected /
10
+ * actual / diff` block on mismatch.
11
+ * - JSON / JSONL: emits the full {@link RunExampleComparison} envelope so
12
+ * downstream consumers can parse the comparison shape directly.
13
+ *
14
+ * Match/mismatch is a comparison outcome, not an execution error: the trail
15
+ * always returns `Result.ok(envelope)`. This helper maps mismatch onto a
16
+ * non-zero exit code by throwing a `ValidationError` (category `validation`,
17
+ * exit 1) so Commander's error path runs.
18
+ *
19
+ * Outer Err on the run trail (NotFound, Ambiguous, Validation) is unaffected
20
+ * by `run.example`: this helper defers to the default handler so existing
21
+ * exit-code mapping and recovery hooks stay intact.
22
+ */
23
+
24
+ import type { ActionResultContext } from '@ontrails/cli';
25
+ import { deriveOutputMode, output } from '@ontrails/cli';
26
+ import { ValidationError } from '@ontrails/core';
27
+
28
+ import { runExampleComparisonSchema } from './trails/run-example.js';
29
+ import type { RunExampleComparison } from './trails/run-example.js';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Detection
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const isExampleRunCtx = (ctx: ActionResultContext): boolean =>
36
+ ctx.trail.id === 'run.example';
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Text formatting
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const formatJson = (value: unknown): string => {
43
+ try {
44
+ const encoded = JSON.stringify(value, null, 2);
45
+ return encoded === undefined ? String(value) : encoded;
46
+ } catch {
47
+ return String(value);
48
+ }
49
+ };
50
+
51
+ const formatMatchText = (envelope: RunExampleComparison): string =>
52
+ [
53
+ `OK ${envelope.trailId} :: ${envelope.exampleName}`,
54
+ `mode: ${envelope.mode}`,
55
+ 'actual matches expected.',
56
+ ].join('\n');
57
+
58
+ const formatMismatchText = (envelope: RunExampleComparison): string => {
59
+ const diffBlock =
60
+ envelope.diff !== undefined && envelope.diff.length > 0
61
+ ? envelope.diff.map((line) => ` - ${line}`).join('\n')
62
+ : ' - <no diff lines>';
63
+
64
+ return [
65
+ `MISMATCH ${envelope.trailId} :: ${envelope.exampleName}`,
66
+ `mode: ${envelope.mode}`,
67
+ 'input:',
68
+ formatJson(envelope.input),
69
+ 'expected:',
70
+ formatJson(envelope.expected),
71
+ 'actual:',
72
+ formatJson(envelope.actual),
73
+ 'diff:',
74
+ diffBlock,
75
+ ].join('\n');
76
+ };
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Entry point
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Return value:
84
+ * - `false` — `run.example` did not apply; caller should fall through to its
85
+ * default handler.
86
+ * - `true` — `run.example` handled the result and wrote output. Caller should
87
+ * not invoke the default handler.
88
+ *
89
+ * Throws a {@link ValidationError} on mismatch so Commander's error path runs
90
+ * and the run trail surface exits with the validation category exit code.
91
+ */
92
+ export const tryExampleRunOutput = (ctx: ActionResultContext): boolean => {
93
+ if (!isExampleRunCtx(ctx)) {
94
+ return false;
95
+ }
96
+
97
+ // Outer Err on the run.example trail (NotFound, Ambiguous, Validation) is not
98
+ // in scope here: defer to the default handler so existing
99
+ // exit-code mapping and recovery hooks stay intact.
100
+ if (ctx.result.isErr()) {
101
+ return false;
102
+ }
103
+
104
+ const envelope = runExampleComparisonSchema.safeParse(ctx.result.value);
105
+ if (!envelope.success) {
106
+ // Defensive fallback: the trail owns the output schema, so this branch is
107
+ // unreachable in practice. Defer to the default handler if anything else
108
+ // slips through.
109
+ return false;
110
+ }
111
+ const comparison = envelope.data;
112
+
113
+ const { mode } = deriveOutputMode(ctx.flags, ctx.topoName);
114
+
115
+ if (mode === 'text') {
116
+ if (comparison.match) {
117
+ output(formatMatchText(comparison), mode);
118
+ return true;
119
+ }
120
+ process.stderr.write(`${formatMismatchText(comparison)}\n`);
121
+ throw new ValidationError(
122
+ `Example '${comparison.exampleName}' on trail '${comparison.trailId}' did not match expected outcome.`,
123
+ {
124
+ context: {
125
+ exampleName: comparison.exampleName,
126
+ mode: comparison.mode,
127
+ trailId: comparison.trailId,
128
+ },
129
+ }
130
+ );
131
+ }
132
+
133
+ // JSON / JSONL: emit the full envelope so downstream consumers can parse
134
+ // the comparison shape directly.
135
+ output(comparison, mode);
136
+ if (!comparison.match) {
137
+ throw new ValidationError(
138
+ `Example '${comparison.exampleName}' on trail '${comparison.trailId}' did not match expected outcome.`,
139
+ {
140
+ context: {
141
+ exampleName: comparison.exampleName,
142
+ mode: comparison.mode,
143
+ trailId: comparison.trailId,
144
+ },
145
+ }
146
+ );
147
+ }
148
+ return true;
149
+ };