@ontrails/trails 1.0.0-beta.2 → 1.0.0-beta.22

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 (150) hide show
  1. package/CHANGELOG.md +647 -0
  2. package/README.md +26 -0
  3. package/package.json +28 -7
  4. package/src/app.ts +86 -2
  5. package/src/clack.ts +22 -0
  6. package/src/cli.ts +330 -11
  7. package/src/completions.ts +240 -0
  8. package/src/lifecycle-source-io.ts +33 -0
  9. package/src/load-app-mirror.ts +202 -0
  10. package/src/local-state-io.ts +153 -0
  11. package/src/mcp-app.ts +30 -0
  12. package/src/mcp-options.ts +77 -0
  13. package/src/mcp.ts +8 -0
  14. package/src/project-writes.ts +377 -0
  15. package/src/release/bindings.ts +39 -0
  16. package/src/release/check.ts +818 -0
  17. package/src/release/config.ts +63 -0
  18. package/src/release/contract-facts.ts +425 -0
  19. package/src/release/index.ts +85 -0
  20. package/src/release/native-bun-publish.ts +651 -0
  21. package/src/release/native-bun-registry.ts +350 -0
  22. package/src/release/packed-artifacts-smoke.ts +236 -0
  23. package/src/release/smoke.ts +46 -0
  24. package/src/release/wayfinder-dogfood-smoke.ts +226 -0
  25. package/src/retired-topo-command.ts +36 -0
  26. package/src/run-adapter-check.ts +76 -0
  27. package/src/run-collision.ts +126 -0
  28. package/src/run-completions-install.ts +179 -0
  29. package/src/run-example.ts +149 -0
  30. package/src/run-examples.ts +148 -0
  31. package/src/run-quiet.ts +75 -0
  32. package/src/run-release-check.ts +74 -0
  33. package/src/run-trace.ts +273 -0
  34. package/src/run-warden.ts +39 -0
  35. package/src/run-watch.ts +432 -0
  36. package/src/scaffold-version-sync.ts +183 -0
  37. package/src/scaffold-versions.generated.ts +12 -0
  38. package/src/trails/adapter-check.ts +244 -0
  39. package/src/trails/add-surface.ts +94 -40
  40. package/src/trails/add-trail.ts +79 -41
  41. package/src/trails/add-verify.ts +95 -25
  42. package/src/trails/compile.ts +67 -0
  43. package/src/trails/completions-complete.ts +165 -0
  44. package/src/trails/completions.ts +47 -0
  45. package/src/trails/create-adapter.ts +1084 -0
  46. package/src/trails/create-scaffold.ts +399 -104
  47. package/src/trails/create-versions.ts +62 -0
  48. package/src/trails/create.ts +185 -71
  49. package/src/trails/deprecate.ts +59 -0
  50. package/src/trails/dev-clean.ts +82 -0
  51. package/src/trails/dev-reset.ts +50 -0
  52. package/src/trails/dev-stats.ts +72 -0
  53. package/src/trails/dev-support.ts +340 -0
  54. package/src/trails/doctor.ts +56 -0
  55. package/src/trails/draft-promote.ts +949 -0
  56. package/src/trails/guide.ts +74 -68
  57. package/src/trails/load-app.ts +1143 -15
  58. package/src/trails/project.ts +17 -3
  59. package/src/trails/release-check.ts +104 -0
  60. package/src/trails/release-smoke.ts +48 -0
  61. package/src/trails/revise.ts +53 -0
  62. package/src/trails/root-dir.ts +21 -0
  63. package/src/trails/run-example.ts +491 -0
  64. package/src/trails/run-examples.ts +145 -0
  65. package/src/trails/run.ts +410 -0
  66. package/src/trails/scaffold-json.ts +58 -0
  67. package/src/trails/survey.ts +881 -226
  68. package/src/trails/topo-activation.ts +385 -0
  69. package/src/trails/topo-constants.ts +2 -0
  70. package/src/trails/topo-history.ts +47 -0
  71. package/src/trails/topo-output-schemas.ts +248 -0
  72. package/src/trails/topo-pin.ts +52 -0
  73. package/src/trails/topo-read-support.ts +313 -0
  74. package/src/trails/topo-reports.ts +807 -0
  75. package/src/trails/topo-store-support.ts +174 -0
  76. package/src/trails/topo-support.ts +220 -0
  77. package/src/trails/topo-unpin.ts +61 -0
  78. package/src/trails/topo.ts +106 -0
  79. package/src/trails/validate.ts +38 -0
  80. package/src/trails/version-lifecycle-support.ts +945 -0
  81. package/src/trails/warden-guide.ts +129 -0
  82. package/src/trails/warden.ts +165 -58
  83. package/src/versions.ts +31 -0
  84. package/.turbo/turbo-build.log +0 -1
  85. package/.turbo/turbo-lint.log +0 -3
  86. package/.turbo/turbo-typecheck.log +0 -1
  87. package/__tests__/examples.test.ts +0 -6
  88. package/dist/bin/trails.d.ts +0 -3
  89. package/dist/bin/trails.d.ts.map +0 -1
  90. package/dist/bin/trails.js +0 -4
  91. package/dist/bin/trails.js.map +0 -1
  92. package/dist/src/app.d.ts +0 -2
  93. package/dist/src/app.d.ts.map +0 -1
  94. package/dist/src/app.js +0 -11
  95. package/dist/src/app.js.map +0 -1
  96. package/dist/src/clack.d.ts +0 -9
  97. package/dist/src/clack.d.ts.map +0 -1
  98. package/dist/src/clack.js +0 -62
  99. package/dist/src/clack.js.map +0 -1
  100. package/dist/src/cli.d.ts +0 -2
  101. package/dist/src/cli.d.ts.map +0 -1
  102. package/dist/src/cli.js +0 -13
  103. package/dist/src/cli.js.map +0 -1
  104. package/dist/src/trails/add-surface.d.ts +0 -13
  105. package/dist/src/trails/add-surface.d.ts.map +0 -1
  106. package/dist/src/trails/add-surface.js +0 -88
  107. package/dist/src/trails/add-surface.js.map +0 -1
  108. package/dist/src/trails/add-trail.d.ts +0 -11
  109. package/dist/src/trails/add-trail.d.ts.map +0 -1
  110. package/dist/src/trails/add-trail.js +0 -85
  111. package/dist/src/trails/add-trail.js.map +0 -1
  112. package/dist/src/trails/add-verify.d.ts +0 -10
  113. package/dist/src/trails/add-verify.d.ts.map +0 -1
  114. package/dist/src/trails/add-verify.js +0 -67
  115. package/dist/src/trails/add-verify.js.map +0 -1
  116. package/dist/src/trails/create-scaffold.d.ts +0 -15
  117. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  118. package/dist/src/trails/create-scaffold.js +0 -288
  119. package/dist/src/trails/create-scaffold.js.map +0 -1
  120. package/dist/src/trails/create.d.ts +0 -22
  121. package/dist/src/trails/create.d.ts.map +0 -1
  122. package/dist/src/trails/create.js +0 -121
  123. package/dist/src/trails/create.js.map +0 -1
  124. package/dist/src/trails/guide.d.ts +0 -11
  125. package/dist/src/trails/guide.d.ts.map +0 -1
  126. package/dist/src/trails/guide.js +0 -80
  127. package/dist/src/trails/guide.js.map +0 -1
  128. package/dist/src/trails/load-app.d.ts +0 -4
  129. package/dist/src/trails/load-app.d.ts.map +0 -1
  130. package/dist/src/trails/load-app.js +0 -24
  131. package/dist/src/trails/load-app.js.map +0 -1
  132. package/dist/src/trails/project.d.ts +0 -8
  133. package/dist/src/trails/project.d.ts.map +0 -1
  134. package/dist/src/trails/project.js +0 -43
  135. package/dist/src/trails/project.js.map +0 -1
  136. package/dist/src/trails/survey.d.ts +0 -33
  137. package/dist/src/trails/survey.d.ts.map +0 -1
  138. package/dist/src/trails/survey.js +0 -225
  139. package/dist/src/trails/survey.js.map +0 -1
  140. package/dist/src/trails/warden.d.ts +0 -19
  141. package/dist/src/trails/warden.d.ts.map +0 -1
  142. package/dist/src/trails/warden.js +0 -88
  143. package/dist/src/trails/warden.js.map +0 -1
  144. package/dist/tsconfig.tsbuildinfo +0 -1
  145. package/src/__tests__/create.test.ts +0 -349
  146. package/src/__tests__/guide.test.ts +0 -91
  147. package/src/__tests__/load-app.test.ts +0 -15
  148. package/src/__tests__/survey.test.ts +0 -161
  149. package/src/__tests__/warden.test.ts +0 -74
  150. package/tsconfig.json +0 -9
@@ -0,0 +1,226 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+
5
+ const repoRoot = resolve(process.cwd());
6
+ const trailsBin = join(repoRoot, 'apps/trails/bin/trails.ts');
7
+
8
+ type JsonObject = Record<string, unknown>;
9
+
10
+ export interface WayfinderDogfoodSmokeResult {
11
+ readonly check: 'wayfinder-dogfood';
12
+ readonly message: string;
13
+ readonly passed: true;
14
+ readonly trailCount: number;
15
+ }
16
+
17
+ const assertObject = (value: unknown, label: string): JsonObject => {
18
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
19
+ throw new Error(`${label} did not return a JSON object`);
20
+ }
21
+ return value as JsonObject;
22
+ };
23
+
24
+ const assertFreshSource = (value: JsonObject, label: string): void => {
25
+ const freshness = assertObject(value['freshness'], `${label}.freshness`);
26
+ if (freshness['status'] !== 'fresh') {
27
+ throw new Error(`${label} did not read fresh artifacts`);
28
+ }
29
+ const source = assertObject(value['source'], `${label}.source`);
30
+ if (source['kind'] !== 'topoGraph') {
31
+ throw new Error(`${label} did not read the TopoGraph source`);
32
+ }
33
+ };
34
+
35
+ const parseJson = (stdout: string, label: string): JsonObject => {
36
+ try {
37
+ return assertObject(JSON.parse(stdout) as unknown, label);
38
+ } catch (error) {
39
+ throw new Error(`${label} did not produce valid JSON`, { cause: error });
40
+ }
41
+ };
42
+
43
+ const runWayfind = (tempRoot: string, args: readonly string[]): JsonObject => {
44
+ const command = [
45
+ process.execPath,
46
+ trailsBin,
47
+ 'wayfind',
48
+ ...args,
49
+ '--root-dir',
50
+ tempRoot,
51
+ '--json',
52
+ ];
53
+ const result = Bun.spawnSync({
54
+ cmd: command,
55
+ cwd: repoRoot,
56
+ env: { ...process.env, NO_COLOR: '1' } as Record<
57
+ string,
58
+ string | undefined
59
+ >,
60
+ stderr: 'pipe',
61
+ stdout: 'pipe',
62
+ });
63
+ const stdout = result.stdout.toString();
64
+ const stderr = result.stderr.toString();
65
+ if (result.exitCode !== 0) {
66
+ throw new Error(
67
+ [
68
+ `Wayfinder dogfood command failed: ${command.join(' ')}`,
69
+ `exitCode: ${result.exitCode}`,
70
+ `stdout: ${stdout}`,
71
+ `stderr: ${stderr}`,
72
+ ].join('\n')
73
+ );
74
+ }
75
+ return parseJson(stdout, `trails ${args.join(' ')}`);
76
+ };
77
+
78
+ const assertSearchFindsWayfinder = (search: JsonObject): void => {
79
+ const { matches } = search;
80
+ if (!Array.isArray(matches)) {
81
+ throw new TypeError('wayfind search did not return matches');
82
+ }
83
+ const ids = matches
84
+ .map((match) => assertObject(match, 'wayfind search match')['id'])
85
+ .filter((id): id is string => typeof id === 'string');
86
+ if (!ids.includes('wayfind.search')) {
87
+ throw new Error('wayfind search did not find wayfind.search');
88
+ }
89
+ };
90
+
91
+ const assertErrorsFindWayfinder = (errorsResult: JsonObject): void => {
92
+ const { errors } = errorsResult;
93
+ if (!Array.isArray(errors)) {
94
+ throw new TypeError('wayfind errors did not return errors');
95
+ }
96
+ const searchEntry = errors
97
+ .map((entry) => assertObject(entry, 'wayfind errors entry'))
98
+ .find((entry) => entry['trailId'] === 'wayfind.search');
99
+ if (searchEntry === undefined) {
100
+ throw new Error('wayfind errors did not include wayfind.search');
101
+ }
102
+ const completeness = assertObject(
103
+ searchEntry['completeness'],
104
+ 'wayfind errors completeness'
105
+ );
106
+ const emitted = assertObject(
107
+ completeness['emitted'],
108
+ 'wayfind errors emitted completeness'
109
+ );
110
+ if (emitted['status'] !== 'unknown') {
111
+ throw new Error('wayfind errors overclaimed emitted-error completeness');
112
+ }
113
+ };
114
+
115
+ const assertAdaptersFindHono = (adaptersResult: JsonObject): void => {
116
+ const counts = assertObject(
117
+ adaptersResult['counts'],
118
+ 'wayfind adapters counts'
119
+ );
120
+ if (counts['configured'] !== 1 || counts['used'] !== 1) {
121
+ throw new Error(
122
+ 'wayfind adapters did not report configured/used adapter facts'
123
+ );
124
+ }
125
+ if (counts['observed'] !== 0) {
126
+ throw new Error('wayfind adapters reported unsupported observed facts');
127
+ }
128
+ const { adapters } = adaptersResult;
129
+ if (!Array.isArray(adapters)) {
130
+ throw new TypeError('wayfind adapters did not return adapters');
131
+ }
132
+ const ids = adapters
133
+ .map((adapter) => assertObject(adapter, 'wayfind adapters fact')['key'])
134
+ .filter((key): key is string => typeof key === 'string');
135
+ if (!ids.includes('@ontrails/hono:http:used')) {
136
+ throw new Error('wayfind adapters did not find Hono conformance usage');
137
+ }
138
+ };
139
+
140
+ const assertContractForSearch = (contractResult: JsonObject): void => {
141
+ const contract = assertObject(contractResult['contract'], 'contract');
142
+ if (contract['id'] !== 'wayfind.search') {
143
+ throw new Error('wayfind contract did not inspect wayfind.search');
144
+ }
145
+ };
146
+
147
+ const assertResolvedTarget = (value: JsonObject, label: string): void => {
148
+ const target = assertObject(value['target'], `${label}.target`);
149
+ if (target['id'] !== 'wayfind.search') {
150
+ throw new Error(`${label} did not resolve wayfind.search`);
151
+ }
152
+ };
153
+
154
+ export const runWayfinderDogfoodSmoke =
155
+ async (): Promise<WayfinderDogfoodSmokeResult> => {
156
+ const tempRoot = await mkdtemp(join(tmpdir(), 'trails-wayfinder-dogfood-'));
157
+
158
+ try {
159
+ const [{ trailsMcpApp }, { exportCurrentTopo }] = await Promise.all([
160
+ import('../mcp-app.js'),
161
+ import('../trails/topo-store-support.js'),
162
+ ]);
163
+ const exported = await exportCurrentTopo(trailsMcpApp, {
164
+ rootDir: tempRoot,
165
+ });
166
+ if (exported.isErr()) {
167
+ throw exported.error;
168
+ }
169
+
170
+ const overview = runWayfind(tempRoot, ['overview']);
171
+ assertFreshSource(overview, 'wayfind overview');
172
+ const counts = assertObject(
173
+ overview['counts'],
174
+ 'wayfind overview counts'
175
+ );
176
+ const trailCount = counts['trails'];
177
+ if (typeof trailCount !== 'number' || trailCount < 1) {
178
+ throw new Error('wayfind overview did not report trail counts');
179
+ }
180
+
181
+ const search = runWayfind(tempRoot, [
182
+ 'search',
183
+ '--input-json',
184
+ '{"filters":{"kind":"trail","idPrefix":"wayfind."}}',
185
+ ]);
186
+ assertFreshSource(search, 'wayfind search');
187
+ assertSearchFindsWayfinder(search);
188
+
189
+ const errors = runWayfind(tempRoot, [
190
+ 'errors',
191
+ '--input-json',
192
+ '{"filters":{"kind":"trail","idPrefix":"wayfind."}}',
193
+ ]);
194
+ assertFreshSource(errors, 'wayfind errors');
195
+ assertErrorsFindWayfinder(errors);
196
+
197
+ const adapters = runWayfind(repoRoot, ['adapters']);
198
+ assertAdaptersFindHono(adapters);
199
+
200
+ const contract = runWayfind(tempRoot, ['contract', 'wayfind.search']);
201
+ assertFreshSource(contract, 'wayfind contract');
202
+ assertContractForSearch(contract);
203
+
204
+ const nearby = runWayfind(tempRoot, ['nearby', 'wayfind.search']);
205
+ assertFreshSource(nearby, 'wayfind nearby');
206
+ assertResolvedTarget(nearby, 'wayfind nearby');
207
+
208
+ const impact = runWayfind(tempRoot, ['impact', 'wayfind.search']);
209
+ assertFreshSource(impact, 'wayfind impact');
210
+ assertResolvedTarget(impact, 'wayfind impact');
211
+
212
+ return {
213
+ check: 'wayfinder-dogfood',
214
+ message: `Wayfinder dogfood smoke passed: ${String(trailCount)} trails inspected from saved operator topo artifacts.`,
215
+ passed: true,
216
+ trailCount,
217
+ };
218
+ } finally {
219
+ await rm(tempRoot, { force: true, recursive: true });
220
+ }
221
+ };
222
+
223
+ if (import.meta.main) {
224
+ const result = await runWayfinderDogfoodSmoke();
225
+ console.log(result.message);
226
+ }
@@ -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.\nTopographer 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 Result.err(shellResult.error);
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 Result.err(scriptResult.error);
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
+ };