@ontrails/trails 1.0.0-beta.14 → 1.0.0-beta.16

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 (197) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +27 -0
  3. package/package.json +19 -8
  4. package/src/app.ts +17 -7
  5. package/src/clack.ts +1 -1
  6. package/src/cli.ts +304 -10
  7. package/src/completions.ts +240 -0
  8. package/src/load-app-mirror.ts +160 -0
  9. package/src/local-state-io.ts +153 -0
  10. package/src/project-writes.ts +320 -0
  11. package/src/run-collision.ts +125 -0
  12. package/src/run-completions-install.ts +179 -0
  13. package/src/run-example.ts +149 -0
  14. package/src/run-examples.ts +148 -0
  15. package/src/run-quiet.ts +75 -0
  16. package/src/run-trace.ts +273 -0
  17. package/src/run-warden.ts +39 -0
  18. package/src/run-watch.ts +432 -0
  19. package/src/scaffold-versions.generated.ts +12 -0
  20. package/src/trails/add-surface.ts +172 -0
  21. package/src/trails/add-trail.ts +73 -27
  22. package/src/trails/add-verify.ts +68 -23
  23. package/src/trails/completions-complete.ts +165 -0
  24. package/src/trails/completions.ts +47 -0
  25. package/src/trails/create-scaffold.ts +101 -35
  26. package/src/trails/create.ts +87 -74
  27. package/src/trails/dev-clean.ts +31 -22
  28. package/src/trails/dev-reset.ts +9 -3
  29. package/src/trails/dev-stats.ts +28 -20
  30. package/src/trails/dev-support.ts +109 -95
  31. package/src/trails/draft-promote.ts +351 -107
  32. package/src/trails/guide.ts +55 -38
  33. package/src/trails/load-app.ts +712 -38
  34. package/src/trails/root-dir.ts +21 -0
  35. package/src/trails/run-example.ts +482 -0
  36. package/src/trails/run-examples.ts +141 -0
  37. package/src/trails/run.ts +403 -0
  38. package/src/trails/survey.ts +517 -186
  39. package/src/trails/topo-activation.ts +385 -0
  40. package/src/trails/topo-compile.ts +55 -0
  41. package/src/trails/topo-history.ts +14 -11
  42. package/src/trails/topo-output-schemas.ts +175 -0
  43. package/src/trails/topo-pin.ts +25 -16
  44. package/src/trails/topo-read-support.ts +178 -238
  45. package/src/trails/topo-reports.ts +445 -63
  46. package/src/trails/topo-store-support.ts +67 -35
  47. package/src/trails/topo-support.ts +93 -147
  48. package/src/trails/topo-unpin.ts +17 -7
  49. package/src/trails/topo-verify.ts +19 -10
  50. package/src/trails/topo.ts +64 -31
  51. package/src/trails/warden-guide.ts +121 -0
  52. package/src/trails/warden.ts +137 -47
  53. package/src/versions.ts +28 -0
  54. package/.turbo/turbo-build.log +0 -1
  55. package/.turbo/turbo-lint.log +0 -3
  56. package/.turbo/turbo-typecheck.log +0 -1
  57. package/__tests__/examples.test.ts +0 -20
  58. package/dist/bin/trails.d.ts +0 -3
  59. package/dist/bin/trails.d.ts.map +0 -1
  60. package/dist/bin/trails.js +0 -4
  61. package/dist/bin/trails.js.map +0 -1
  62. package/dist/src/app.d.ts +0 -2
  63. package/dist/src/app.d.ts.map +0 -1
  64. package/dist/src/app.js +0 -22
  65. package/dist/src/app.js.map +0 -1
  66. package/dist/src/clack.d.ts +0 -9
  67. package/dist/src/clack.d.ts.map +0 -1
  68. package/dist/src/clack.js +0 -84
  69. package/dist/src/clack.js.map +0 -1
  70. package/dist/src/cli.d.ts +0 -2
  71. package/dist/src/cli.d.ts.map +0 -1
  72. package/dist/src/cli.js +0 -13
  73. package/dist/src/cli.js.map +0 -1
  74. package/dist/src/trails/add-surface.d.ts +0 -13
  75. package/dist/src/trails/add-surface.d.ts.map +0 -1
  76. package/dist/src/trails/add-surface.js +0 -88
  77. package/dist/src/trails/add-surface.js.map +0 -1
  78. package/dist/src/trails/add-trail.d.ts +0 -10
  79. package/dist/src/trails/add-trail.d.ts.map +0 -1
  80. package/dist/src/trails/add-trail.js +0 -77
  81. package/dist/src/trails/add-trail.js.map +0 -1
  82. package/dist/src/trails/add-trailhead.d.ts +0 -13
  83. package/dist/src/trails/add-trailhead.d.ts.map +0 -1
  84. package/dist/src/trails/add-trailhead.js +0 -88
  85. package/dist/src/trails/add-trailhead.js.map +0 -1
  86. package/dist/src/trails/add-verify.d.ts +0 -10
  87. package/dist/src/trails/add-verify.d.ts.map +0 -1
  88. package/dist/src/trails/add-verify.js +0 -67
  89. package/dist/src/trails/add-verify.js.map +0 -1
  90. package/dist/src/trails/create-scaffold.d.ts +0 -15
  91. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  92. package/dist/src/trails/create-scaffold.js +0 -288
  93. package/dist/src/trails/create-scaffold.js.map +0 -1
  94. package/dist/src/trails/create.d.ts +0 -22
  95. package/dist/src/trails/create.d.ts.map +0 -1
  96. package/dist/src/trails/create.js +0 -121
  97. package/dist/src/trails/create.js.map +0 -1
  98. package/dist/src/trails/dev-clean.d.ts +0 -9
  99. package/dist/src/trails/dev-clean.d.ts.map +0 -1
  100. package/dist/src/trails/dev-clean.js +0 -65
  101. package/dist/src/trails/dev-clean.js.map +0 -1
  102. package/dist/src/trails/dev-reset.d.ts +0 -6
  103. package/dist/src/trails/dev-reset.d.ts.map +0 -1
  104. package/dist/src/trails/dev-reset.js +0 -38
  105. package/dist/src/trails/dev-reset.js.map +0 -1
  106. package/dist/src/trails/dev-stats.d.ts +0 -7
  107. package/dist/src/trails/dev-stats.d.ts.map +0 -1
  108. package/dist/src/trails/dev-stats.js +0 -61
  109. package/dist/src/trails/dev-stats.js.map +0 -1
  110. package/dist/src/trails/dev-support.d.ts +0 -64
  111. package/dist/src/trails/dev-support.d.ts.map +0 -1
  112. package/dist/src/trails/dev-support.js +0 -178
  113. package/dist/src/trails/dev-support.js.map +0 -1
  114. package/dist/src/trails/draft-promote.d.ts +0 -18
  115. package/dist/src/trails/draft-promote.d.ts.map +0 -1
  116. package/dist/src/trails/draft-promote.js +0 -386
  117. package/dist/src/trails/draft-promote.js.map +0 -1
  118. package/dist/src/trails/guide.d.ts +0 -21
  119. package/dist/src/trails/guide.d.ts.map +0 -1
  120. package/dist/src/trails/guide.js +0 -64
  121. package/dist/src/trails/guide.js.map +0 -1
  122. package/dist/src/trails/load-app.d.ts +0 -6
  123. package/dist/src/trails/load-app.d.ts.map +0 -1
  124. package/dist/src/trails/load-app.js +0 -67
  125. package/dist/src/trails/load-app.js.map +0 -1
  126. package/dist/src/trails/project.d.ts +0 -8
  127. package/dist/src/trails/project.d.ts.map +0 -1
  128. package/dist/src/trails/project.js +0 -54
  129. package/dist/src/trails/project.js.map +0 -1
  130. package/dist/src/trails/survey.d.ts +0 -18
  131. package/dist/src/trails/survey.d.ts.map +0 -1
  132. package/dist/src/trails/survey.js +0 -212
  133. package/dist/src/trails/survey.js.map +0 -1
  134. package/dist/src/trails/topo-constants.d.ts +0 -3
  135. package/dist/src/trails/topo-constants.d.ts.map +0 -1
  136. package/dist/src/trails/topo-constants.js +0 -3
  137. package/dist/src/trails/topo-constants.js.map +0 -1
  138. package/dist/src/trails/topo-export.d.ts +0 -18
  139. package/dist/src/trails/topo-export.d.ts.map +0 -1
  140. package/dist/src/trails/topo-export.js +0 -34
  141. package/dist/src/trails/topo-export.js.map +0 -1
  142. package/dist/src/trails/topo-history.d.ts +0 -24
  143. package/dist/src/trails/topo-history.d.ts.map +0 -1
  144. package/dist/src/trails/topo-history.js +0 -33
  145. package/dist/src/trails/topo-history.js.map +0 -1
  146. package/dist/src/trails/topo-pin.d.ts +0 -21
  147. package/dist/src/trails/topo-pin.d.ts.map +0 -1
  148. package/dist/src/trails/topo-pin.js +0 -35
  149. package/dist/src/trails/topo-pin.js.map +0 -1
  150. package/dist/src/trails/topo-read-support.d.ts +0 -54
  151. package/dist/src/trails/topo-read-support.d.ts.map +0 -1
  152. package/dist/src/trails/topo-read-support.js +0 -178
  153. package/dist/src/trails/topo-read-support.js.map +0 -1
  154. package/dist/src/trails/topo-reports.d.ts +0 -50
  155. package/dist/src/trails/topo-reports.d.ts.map +0 -1
  156. package/dist/src/trails/topo-reports.js +0 -122
  157. package/dist/src/trails/topo-reports.js.map +0 -1
  158. package/dist/src/trails/topo-show.d.ts +0 -23
  159. package/dist/src/trails/topo-show.d.ts.map +0 -1
  160. package/dist/src/trails/topo-show.js +0 -53
  161. package/dist/src/trails/topo-show.js.map +0 -1
  162. package/dist/src/trails/topo-store-support.d.ts +0 -13
  163. package/dist/src/trails/topo-store-support.d.ts.map +0 -1
  164. package/dist/src/trails/topo-store-support.js +0 -55
  165. package/dist/src/trails/topo-store-support.js.map +0 -1
  166. package/dist/src/trails/topo-support.d.ts +0 -87
  167. package/dist/src/trails/topo-support.d.ts.map +0 -1
  168. package/dist/src/trails/topo-support.js +0 -165
  169. package/dist/src/trails/topo-support.js.map +0 -1
  170. package/dist/src/trails/topo-unpin.d.ts +0 -15
  171. package/dist/src/trails/topo-unpin.d.ts.map +0 -1
  172. package/dist/src/trails/topo-unpin.js +0 -39
  173. package/dist/src/trails/topo-unpin.js.map +0 -1
  174. package/dist/src/trails/topo-verify.d.ts +0 -5
  175. package/dist/src/trails/topo-verify.d.ts.map +0 -1
  176. package/dist/src/trails/topo-verify.js +0 -28
  177. package/dist/src/trails/topo-verify.js.map +0 -1
  178. package/dist/src/trails/topo.d.ts +0 -5
  179. package/dist/src/trails/topo.d.ts.map +0 -1
  180. package/dist/src/trails/topo.js +0 -67
  181. package/dist/src/trails/topo.js.map +0 -1
  182. package/dist/src/trails/warden.d.ts +0 -19
  183. package/dist/src/trails/warden.d.ts.map +0 -1
  184. package/dist/src/trails/warden.js +0 -89
  185. package/dist/src/trails/warden.js.map +0 -1
  186. package/dist/tsconfig.tsbuildinfo +0 -1
  187. package/src/__tests__/create.test.ts +0 -351
  188. package/src/__tests__/draft-promote.test.ts +0 -144
  189. package/src/__tests__/guide.test.ts +0 -91
  190. package/src/__tests__/load-app.test.ts +0 -58
  191. package/src/__tests__/survey.test.ts +0 -301
  192. package/src/__tests__/topo-dev.test.ts +0 -424
  193. package/src/__tests__/warden.test.ts +0 -74
  194. package/src/trails/add-trailhead.ts +0 -121
  195. package/src/trails/topo-export.ts +0 -39
  196. package/src/trails/topo-show.ts +0 -58
  197. package/tsconfig.json +0 -9
package/src/cli.ts CHANGED
@@ -1,14 +1,308 @@
1
- import { outputModePreset } from '@ontrails/cli';
2
- import { trailhead } from '@ontrails/cli/commander';
1
+ import { existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+
5
+ import {
6
+ defaultOnResult,
7
+ devPermitPreset,
8
+ outputModePreset,
9
+ permitPreset,
10
+ tokenPreset,
11
+ tracePreset,
12
+ watchPreset,
13
+ } from '@ontrails/cli';
14
+ import type {
15
+ ActionResultContext,
16
+ ResolveCliPermitFromToken,
17
+ } from '@ontrails/cli';
18
+ import { createProgram } from '@ontrails/commander';
19
+ import { resolvePermitFromBearerToken } from '@ontrails/permits';
20
+ import { deriveTopoGraph } from '@ontrails/topographer';
3
21
 
4
22
  import { app } from './app.js';
5
23
  import { resolveInputWithClack } from './clack.js';
24
+ import { attachCompletionsInstallCommand } from './run-completions-install.js';
25
+ import { tryRecoverFromRunCollision } from './run-collision.js';
26
+ import { tryExampleRunOutput } from './run-example.js';
27
+ import { tryExamplesRunOutput } from './run-examples.js';
28
+ import { tryQuietRunOutput } from './run-quiet.js';
29
+ import {
30
+ argvHasTraceFlag,
31
+ installTraceSink,
32
+ tryTraceJsonOutput,
33
+ writeTraceTreeToStderr,
34
+ } from './run-trace.js';
35
+ import type { TraceSession } from './run-trace.js';
36
+ import {
37
+ argvHasWatchFlag,
38
+ hashTopoGraphEntry,
39
+ readRunTrailId,
40
+ runWatchLoop,
41
+ } from './run-watch.js';
42
+ import { tryWardenOutput } from './run-warden.js';
43
+ import { tryLoadFreshAppLease } from './trails/load-app.js';
44
+ import { resolveRunModulePath } from './trails/run.js';
45
+ import { resolveTrailRootDir } from './trails/root-dir.js';
46
+ import { trailsPackageVersion } from './versions.js';
47
+
48
+ const buildOnResult =
49
+ (session: TraceSession | undefined) =>
50
+ async (ctx: ActionResultContext): Promise<void> => {
51
+ const recovered = await tryRecoverFromRunCollision(ctx, { graph: app });
52
+ const resolvedCtx: ActionResultContext =
53
+ recovered === undefined
54
+ ? ctx
55
+ : {
56
+ ...ctx,
57
+ input: recovered.isOk()
58
+ ? (ctx.trail.input.safeParse(ctx.input).data ?? ctx.input)
59
+ : ctx.input,
60
+ result: recovered,
61
+ };
62
+
63
+ // `--trace --json` (without `--quiet`) emits a single Result-shaped
64
+ // envelope on stdout that includes the captured records under
65
+ // `tracing`. Hand that case off before the regular chain so the
66
+ // existing handlers do not also write to stdout.
67
+ if (session !== undefined && tryTraceJsonOutput(resolvedCtx, session)) {
68
+ return;
69
+ }
70
+
71
+ if (tryExampleRunOutput(resolvedCtx)) {
72
+ return;
73
+ }
74
+ if (tryExamplesRunOutput(resolvedCtx)) {
75
+ return;
76
+ }
77
+ if (await tryQuietRunOutput(resolvedCtx)) {
78
+ return;
79
+ }
80
+ if (tryWardenOutput(resolvedCtx)) {
81
+ return;
82
+ }
83
+ await defaultOnResult(resolvedCtx);
84
+ };
85
+
86
+ const traceEnabled = argvHasTraceFlag(process.argv);
87
+ const maybeInstallTraceSession = (): TraceSession | undefined =>
88
+ traceEnabled ? installTraceSink() : undefined;
89
+
90
+ const resolveCliPermitFromToken: ResolveCliPermitFromToken = (input) =>
91
+ resolvePermitFromBearerToken({
92
+ bearerToken: input.token,
93
+ configValues: input.configValues,
94
+ env: process.env as Record<string, string | undefined>,
95
+ graph: input.graph,
96
+ missingAuthResourceMessage:
97
+ '--token requires an auth adapter. Register authResource from @ontrails/permits in your topo.',
98
+ nullPermitMessage: 'Auth adapter did not produce a permit for --token',
99
+ requestId: input.requestId,
100
+ resources: input.resources,
101
+ surface: 'cli',
102
+ });
103
+
104
+ interface WatchRunTarget {
105
+ readonly app?: string | undefined;
106
+ readonly id: string;
107
+ readonly module?: string | undefined;
108
+ readonly rootDir?: string | undefined;
109
+ }
110
+
111
+ const readFlagValue = (
112
+ args: readonly string[],
113
+ flagName: string
114
+ ): string | undefined => {
115
+ const longFlag = `--${flagName}`;
116
+ const prefixedFlag = `${longFlag}=`;
117
+ for (let i = 0; i < args.length; i += 1) {
118
+ const arg = args[i];
119
+ if (arg === longFlag) {
120
+ return args[i + 1];
121
+ }
122
+ if (arg?.startsWith(prefixedFlag)) {
123
+ return arg.slice(prefixedFlag.length);
124
+ }
125
+ }
126
+ return undefined;
127
+ };
128
+
129
+ const resolveWatchRunTarget = (
130
+ argv: readonly string[]
131
+ ): WatchRunTarget | null => {
132
+ const args = argv.slice(2);
133
+ const id = readRunTrailId(args);
134
+ if (id === undefined) {
135
+ return null;
136
+ }
137
+ return {
138
+ app: readFlagValue(args, 'app'),
139
+ id,
140
+ module: readFlagValue(args, 'module'),
141
+ rootDir: readFlagValue(args, 'root-dir'),
142
+ };
143
+ };
144
+
145
+ /**
146
+ * Resolve the directory whose source-file events wake the `--watch` loop.
147
+ * Reruns still depend on the resolved TopoGraph entry hash; this path is
148
+ * only the cheap filesystem event source.
149
+ */
150
+ const toWatchSourcePath = (rootDir: string, modulePath: string): string => {
151
+ if (modulePath.startsWith('file:')) {
152
+ return fileURLToPath(modulePath);
153
+ }
154
+ return isAbsolute(modulePath) ? modulePath : resolve(rootDir, modulePath);
155
+ };
156
+
157
+ const resolveWatchDirectorySourcePath = async (
158
+ target: WatchRunTarget | null
159
+ ): Promise<string> => {
160
+ if (target !== null) {
161
+ const rootDirResult = resolveTrailRootDir(target.rootDir, process.cwd());
162
+ if (rootDirResult.isErr()) {
163
+ throw rootDirResult.error;
164
+ }
165
+ const moduleResult = await resolveRunModulePath(
166
+ rootDirResult.value,
167
+ target.module,
168
+ target.id,
169
+ target.app
170
+ );
171
+ if (moduleResult.isOk()) {
172
+ return toWatchSourcePath(rootDirResult.value, moduleResult.value);
173
+ }
174
+ }
175
+ const cwd = process.cwd();
176
+ const srcDir = join(cwd, 'src');
177
+ if (existsSync(srcDir)) {
178
+ return join(srcDir, 'app.ts');
179
+ }
180
+ return join(cwd, 'app.ts');
181
+ };
182
+
183
+ const readWatchTopoGraphEntryHash = async (
184
+ target: WatchRunTarget | null
185
+ ): Promise<string | null> => {
186
+ if (target === null) {
187
+ return null;
188
+ }
189
+ const rootDirResult = resolveTrailRootDir(target.rootDir, process.cwd());
190
+ if (rootDirResult.isErr()) {
191
+ throw rootDirResult.error;
192
+ }
193
+ const rootDir = rootDirResult.value;
194
+ const moduleResult = await resolveRunModulePath(
195
+ rootDir,
196
+ target.module,
197
+ target.id,
198
+ target.app
199
+ );
200
+ if (moduleResult.isErr()) {
201
+ throw moduleResult.error;
202
+ }
203
+ const leaseResult = await tryLoadFreshAppLease(moduleResult.value, rootDir);
204
+ if (leaseResult.isErr()) {
205
+ throw leaseResult.error;
206
+ }
207
+ const lease = leaseResult.value;
208
+ try {
209
+ const topoGraph = deriveTopoGraph(lease.app);
210
+ const entry = topoGraph.entries.find(
211
+ (candidate) => candidate.kind === 'trail' && candidate.id === target.id
212
+ );
213
+ return entry === undefined ? null : hashTopoGraphEntry(entry);
214
+ } finally {
215
+ lease.release();
216
+ }
217
+ };
218
+
219
+ const wardenValueFlags = new Set([
220
+ '--apps',
221
+ '--config-path',
222
+ '--depth',
223
+ '--drafts',
224
+ '--fail-on',
225
+ '--format',
226
+ '--lock',
227
+ '--root-dir',
228
+ ]);
229
+
230
+ const normalizeWardenArgv = (argv: readonly string[]): string[] => {
231
+ if (argv[2] !== 'warden') {
232
+ return [...argv];
233
+ }
234
+
235
+ const normalized = [...argv];
236
+ let previousFlagConsumesValue = false;
237
+ for (let index = 3; index < normalized.length; index += 1) {
238
+ const arg = normalized[index];
239
+ if (arg === undefined) {
240
+ continue;
241
+ }
242
+
243
+ if (previousFlagConsumesValue) {
244
+ previousFlagConsumesValue = false;
245
+ continue;
246
+ }
247
+
248
+ if (arg === '-a') {
249
+ normalized[index] = '--apps';
250
+ previousFlagConsumesValue = true;
251
+ continue;
252
+ }
253
+
254
+ previousFlagConsumesValue = wardenValueFlags.has(arg);
255
+ }
256
+
257
+ return normalized;
258
+ };
259
+
260
+ /**
261
+ * Invoke `surface()` once with an optional fresh trace session.
262
+ *
263
+ * When `--trace` is set, a fresh {@link TraceSession} is installed for the
264
+ * duration of the call and finalized in the `finally` block. Under
265
+ * `--watch`, this produces a fresh sink (and a fresh stderr tree) per
266
+ * rerun rather than letting records accumulate in a single
267
+ * process-lifetime sink.
268
+ */
269
+ const runSurfaceOnce = async (): Promise<void> => {
270
+ const session = maybeInstallTraceSession();
271
+ try {
272
+ const program = createProgram(app, {
273
+ description: 'Agent-native, contract-first TypeScript framework',
274
+ name: 'trails',
275
+ onResult: buildOnResult(session),
276
+ presets: [
277
+ outputModePreset(),
278
+ tracePreset(),
279
+ permitPreset(),
280
+ tokenPreset(),
281
+ devPermitPreset(),
282
+ watchPreset(),
283
+ ],
284
+ resolveInput: resolveInputWithClack,
285
+ resolvePermitFromToken: resolveCliPermitFromToken,
286
+ version: trailsPackageVersion,
287
+ });
288
+ attachCompletionsInstallCommand(program);
289
+ await program.parseAsync(normalizeWardenArgv(process.argv));
290
+ } finally {
291
+ if (session !== undefined) {
292
+ const records = session.finalize();
293
+ writeTraceTreeToStderr(records);
294
+ }
295
+ }
296
+ };
297
+
298
+ const watchTarget = argvHasWatchFlag(process.argv)
299
+ ? resolveWatchRunTarget(process.argv)
300
+ : null;
6
301
 
7
- // oxlint-disable-next-line require-hook -- CLI entry point
8
- trailhead(app, {
9
- description: 'Agent-native, contract-first TypeScript framework',
10
- name: 'trails',
11
- presets: [outputModePreset()],
12
- resolveInput: resolveInputWithClack,
13
- version: '0.1.0',
14
- });
302
+ await (argvHasWatchFlag(process.argv)
303
+ ? runWatchLoop({
304
+ readTopoGraphEntryHash: () => readWatchTopoGraphEntryHash(watchTarget),
305
+ run: runSurfaceOnce,
306
+ sourcePath: await resolveWatchDirectorySourcePath(watchTarget),
307
+ })
308
+ : runSurfaceOnce());
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Shell completion infrastructure for the `trails` CLI.
3
+ *
4
+ * The completion model is a two-part system:
5
+ *
6
+ * 1. A small, static **shell script** registered with the user's shell. The
7
+ * script's only job is to invoke the binary's internal completion subcommand
8
+ * (`trails completions __complete`) with the partial argv at tab-press time
9
+ * and feed the resulting lines back to the shell.
10
+ * 2. A dynamic **`__complete` trail** that parses the partial argv and emits
11
+ * a sorted list of suggestions (e.g. trail IDs).
12
+ *
13
+ * This split keeps the shell-side script tiny and standardish (no rich shell
14
+ * DSL), while the heavy lifting stays in TypeScript where it can reuse the
15
+ * workspace trail index for accurate, live suggestions.
16
+ */
17
+
18
+ import {
19
+ deriveStructuredTrailExamples,
20
+ RecoverableCompletionError,
21
+ Result,
22
+ ValidationError,
23
+ } from '@ontrails/core';
24
+ import { buildWorkspaceTrailIndex } from '@ontrails/topographer';
25
+
26
+ import { tryLoadFreshAppLease } from './trails/load-app.js';
27
+
28
+ /** Shells supported by the completion generator. */
29
+ export type CompletionShell = 'bash' | 'zsh' | 'fish';
30
+
31
+ type ScriptRenderer = (binName: string) => string;
32
+
33
+ const renderBashScript: ScriptRenderer = (binName) =>
34
+ `# ${binName} bash completion
35
+ _${binName}_complete() {
36
+ local cur words
37
+ cur="\${COMP_WORDS[COMP_CWORD]}"
38
+ words=("\${COMP_WORDS[@]:1:COMP_CWORD}")
39
+ COMPREPLY=()
40
+ while IFS= read -r suggestion; do
41
+ COMPREPLY+=("$suggestion")
42
+ done < <(${binName} completions __complete "\${words[@]}" 2>/dev/null)
43
+ return 0
44
+ }
45
+ complete -F _${binName}_complete ${binName}
46
+ `;
47
+
48
+ const renderZshScript: ScriptRenderer = (binName) =>
49
+ `#compdef ${binName}
50
+ # ${binName} zsh completion
51
+ _${binName}_complete() {
52
+ local -a suggestions trail_words
53
+ local output
54
+ trail_words=("\${(@)words[2,CURRENT]}")
55
+ output="$(${binName} completions __complete "\${trail_words[@]}" 2>/dev/null)"
56
+ if [[ -n "$output" ]]; then
57
+ suggestions=("\${(@f)output}")
58
+ if (( \${#suggestions} )); then
59
+ compadd -- "\${suggestions[@]}"
60
+ fi
61
+ fi
62
+ }
63
+ compdef _${binName}_complete ${binName}
64
+ `;
65
+
66
+ const renderFishScript: ScriptRenderer = (binName) =>
67
+ `# ${binName} fish completion
68
+ function __${binName}_complete
69
+ set -l tokens (commandline -opc) (commandline -ct)
70
+ set -e tokens[1]
71
+ ${binName} completions __complete $tokens 2>/dev/null
72
+ end
73
+ complete -c ${binName} -f -a '(__${binName}_complete)'
74
+ `;
75
+
76
+ const SCRIPT_RENDERERS: Readonly<Record<CompletionShell, ScriptRenderer>> = {
77
+ bash: renderBashScript,
78
+ fish: renderFishScript,
79
+ zsh: renderZshScript,
80
+ };
81
+
82
+ /** Pattern that `binName` must match — alphanumerics, underscore, hyphen. */
83
+ const BIN_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
84
+
85
+ const recoverableCompletionError = (
86
+ message: string,
87
+ context: Record<string, unknown>,
88
+ cause?: unknown
89
+ ): RecoverableCompletionError =>
90
+ new RecoverableCompletionError(message, {
91
+ ...(cause instanceof Error ? { cause } : {}),
92
+ context,
93
+ });
94
+
95
+ /**
96
+ * Render a static shell completion script that delegates dynamic completion
97
+ * to `<binName> completions __complete <args...>`.
98
+ *
99
+ * @param shell - target shell flavor.
100
+ * @param binName - binary name to register the completion against (typically
101
+ * `'trails'`). Used both as the registered command and as the prefix for the
102
+ * shell function name. Must match `/^[a-zA-Z0-9_-]+$/` — the value is
103
+ * interpolated verbatim into shell source, so any non-trivial input would
104
+ * be a shell-injection vector. We validate at the boundary per
105
+ * "validate at the boundary, trust internally" (docs/tenets.md).
106
+ */
107
+ export const renderCompletionScript = (
108
+ shell: CompletionShell,
109
+ binName: string
110
+ ): Result<string, ValidationError> => {
111
+ if (!BIN_NAME_PATTERN.test(binName)) {
112
+ return Result.err(
113
+ new ValidationError(
114
+ `renderCompletionScript: binName must match /^[a-zA-Z0-9_-]+$/ (got: ${JSON.stringify(binName)})`
115
+ )
116
+ );
117
+ }
118
+ return Result.ok(SCRIPT_RENDERERS[shell](binName));
119
+ };
120
+
121
+ /**
122
+ * Read trail IDs from the live workspace topo and return those matching
123
+ * `prefix`, sorted lexicographically. Includes IDs that collide across multiple
124
+ * apps — the shell only needs the unique set of identifiers, not their owners.
125
+ *
126
+ * @param workspaceRoot - workspace root directory used to resolve apps.
127
+ * @param prefix - prefix to filter by; an empty prefix returns every ID.
128
+ */
129
+ export const renderTrailIdCompletions = async (
130
+ workspaceRoot: string,
131
+ prefix: string
132
+ ): Promise<readonly string[]> => {
133
+ let result: Awaited<ReturnType<typeof buildWorkspaceTrailIndex>>;
134
+ try {
135
+ result = await buildWorkspaceTrailIndex({ cwd: workspaceRoot });
136
+ } catch {
137
+ return [];
138
+ }
139
+ const ids = new Set<string>(Object.keys(result.index));
140
+ for (const collision of result.collisions) {
141
+ ids.add(collision.trailId);
142
+ }
143
+ const matching: string[] = [];
144
+ for (const id of ids) {
145
+ if (id.startsWith(prefix)) {
146
+ matching.push(id);
147
+ }
148
+ }
149
+ matching.sort((a, b) => {
150
+ if (a < b) {
151
+ return -1;
152
+ }
153
+ if (a > b) {
154
+ return 1;
155
+ }
156
+ return 0;
157
+ });
158
+ return matching;
159
+ };
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Example name completion
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Return example names for `trailId` matching `prefix`, sorted lexicographically.
167
+ *
168
+ * Looks the trail up via the workspace index (TRL-404), resolves its owning
169
+ * app module from the enriched index, loads the app's topo, and reads the
170
+ * `name` of every structured example.
171
+ *
172
+ * Completion is best-effort for shell callers, but this helper preserves
173
+ * load-time failures as `RecoverableCompletionError` so the internal bridge can
174
+ * decide whether to suppress them for prompt safety.
175
+ */
176
+ export const renderTrailExampleCompletions = async (
177
+ workspaceRoot: string,
178
+ trailId: string,
179
+ prefix: string
180
+ ): Promise<Result<readonly string[], RecoverableCompletionError>> => {
181
+ try {
182
+ const { index } = await buildWorkspaceTrailIndex({ cwd: workspaceRoot });
183
+ const owner = index[trailId];
184
+ if (owner === undefined) {
185
+ return Result.ok([]);
186
+ }
187
+ const leaseResult = await tryLoadFreshAppLease(
188
+ owner.modulePath,
189
+ workspaceRoot
190
+ );
191
+ if (leaseResult.isErr()) {
192
+ return Result.err(
193
+ recoverableCompletionError(
194
+ 'Cannot load app while completing example names',
195
+ { modulePath: owner.modulePath, trailId, workspaceRoot },
196
+ leaseResult.error
197
+ )
198
+ );
199
+ }
200
+ const lease = leaseResult.value;
201
+ try {
202
+ const target = lease.app.get(trailId);
203
+ if (target === undefined) {
204
+ return Result.err(
205
+ recoverableCompletionError(
206
+ 'Indexed trail was not found in loaded app while completing example names',
207
+ { modulePath: owner.modulePath, trailId, workspaceRoot }
208
+ )
209
+ );
210
+ }
211
+ const structured = deriveStructuredTrailExamples(target.examples) ?? [];
212
+ const matching: string[] = [];
213
+ for (const example of structured) {
214
+ if (example.name.startsWith(prefix)) {
215
+ matching.push(example.name);
216
+ }
217
+ }
218
+ matching.sort((a, b) => {
219
+ if (a < b) {
220
+ return -1;
221
+ }
222
+ if (a > b) {
223
+ return 1;
224
+ }
225
+ return 0;
226
+ });
227
+ return Result.ok(matching);
228
+ } finally {
229
+ lease.release();
230
+ }
231
+ } catch (error) {
232
+ return Result.err(
233
+ recoverableCompletionError(
234
+ 'Cannot resolve workspace while completing example names',
235
+ { trailId, workspaceRoot },
236
+ error
237
+ )
238
+ );
239
+ }
240
+ };
@@ -0,0 +1,160 @@
1
+ import { mkdirSync, rmSync } from 'node:fs';
2
+ import {
3
+ basename,
4
+ dirname,
5
+ isAbsolute,
6
+ join,
7
+ parse as parsePath,
8
+ relative,
9
+ resolve,
10
+ } from 'node:path';
11
+
12
+ import {
13
+ deriveSafePath,
14
+ InternalError,
15
+ PermissionError,
16
+ Result,
17
+ ValidationError,
18
+ } from '@ontrails/core';
19
+ // Result is imported as a value for factories above; this alias keeps returned
20
+ // Result types readable without colliding with the value import.
21
+ import type { Result as TrailsResult } from '@ontrails/core';
22
+
23
+ export const LOAD_APP_MIRROR_PARENT_DIRNAME = '.trails-tmp';
24
+
25
+ export const LOAD_APP_MIRROR_ENTRY_PREFIX = 'load-app-fresh-';
26
+
27
+ const asError = (error: unknown): Error =>
28
+ error instanceof Error ? error : new Error(String(error));
29
+
30
+ const validateMirrorRoot = (
31
+ mirrorRoot: string
32
+ ): TrailsResult<string, PermissionError> => {
33
+ const resolved = resolve(mirrorRoot);
34
+ const mirrorParent = dirname(resolved);
35
+
36
+ return basename(mirrorParent) === LOAD_APP_MIRROR_PARENT_DIRNAME &&
37
+ basename(resolved).startsWith(LOAD_APP_MIRROR_ENTRY_PREFIX)
38
+ ? Result.ok(resolved)
39
+ : Result.err(
40
+ new PermissionError(
41
+ `Refusing to write or remove non-load-app mirror path "${mirrorRoot}"`,
42
+ { context: { mirrorRoot: resolved } }
43
+ )
44
+ );
45
+ };
46
+
47
+ const resolveAbsoluteSourcePath = (
48
+ sourcePath: string
49
+ ): TrailsResult<string, ValidationError> =>
50
+ isAbsolute(sourcePath)
51
+ ? Result.ok(sourcePath)
52
+ : Result.err(
53
+ new ValidationError(
54
+ `Load-app mirror source path must be absolute: "${sourcePath}"`,
55
+ { context: { sourcePath } }
56
+ )
57
+ );
58
+
59
+ /**
60
+ * Convert an absolute source path to the deterministic location inside a
61
+ * load-app fresh mirror.
62
+ */
63
+ export const resolveLoadAppMirrorFilePath = (
64
+ sourcePath: string,
65
+ mirrorRoot: string
66
+ ): TrailsResult<string, Error> => {
67
+ const root = validateMirrorRoot(mirrorRoot);
68
+ if (root.isErr()) {
69
+ return root;
70
+ }
71
+
72
+ const source = resolveAbsoluteSourcePath(sourcePath);
73
+ if (source.isErr()) {
74
+ return source;
75
+ }
76
+
77
+ const mirrorRelativePath = relative(
78
+ parsePath(source.value).root,
79
+ source.value
80
+ );
81
+ return deriveSafePath(root.value, mirrorRelativePath);
82
+ };
83
+
84
+ /**
85
+ * Copy a source file into its load-app fresh mirror by raw bytes.
86
+ *
87
+ * @remarks
88
+ * Reading via `.bytes()` rather than `.text()` preserves binary payloads
89
+ * (`.wasm`, `.node`, compiled assets) that may sit alongside source files in
90
+ * the app's graph. Text decoding would corrupt them on the way through the
91
+ * mirror.
92
+ */
93
+ export const writeLoadAppMirrorFile = async (
94
+ sourcePath: string,
95
+ mirrorRoot: string
96
+ ): Promise<TrailsResult<string, Error>> => {
97
+ const mirrorPath = resolveLoadAppMirrorFilePath(sourcePath, mirrorRoot);
98
+ if (mirrorPath.isErr()) {
99
+ return mirrorPath;
100
+ }
101
+
102
+ try {
103
+ mkdirSync(dirname(mirrorPath.value), { recursive: true });
104
+ const bytes = await Bun.file(sourcePath).bytes();
105
+ await Bun.write(mirrorPath.value, bytes);
106
+ return Result.ok(mirrorPath.value);
107
+ } catch (error) {
108
+ return Result.err(
109
+ new InternalError(`Failed to mirror load-app file "${sourcePath}"`, {
110
+ cause: asError(error),
111
+ context: { mirrorPath: mirrorPath.value, mirrorRoot, sourcePath },
112
+ })
113
+ );
114
+ }
115
+ };
116
+
117
+ export const removeLoadAppMirrorRoot = (
118
+ mirrorRoot: string
119
+ ): TrailsResult<void, Error> => {
120
+ const root = validateMirrorRoot(mirrorRoot);
121
+ if (root.isErr()) {
122
+ return root;
123
+ }
124
+
125
+ try {
126
+ rmSync(root.value, { force: true, recursive: true });
127
+ return Result.ok();
128
+ } catch (error) {
129
+ return Result.err(
130
+ new InternalError(`Failed to remove load-app mirror "${mirrorRoot}"`, {
131
+ cause: asError(error),
132
+ context: { mirrorRoot: root.value },
133
+ })
134
+ );
135
+ }
136
+ };
137
+
138
+ /**
139
+ * Best-effort cleanup for process-exit and stale-sweep paths.
140
+ *
141
+ * This intentionally suppresses validation and filesystem failures because the
142
+ * caller is already abandoning a temporary mirror and cleanup must not turn
143
+ * into an application-load failure.
144
+ */
145
+ export const removeLoadAppMirrorRootQuietly = (mirrorRoot: string): void => {
146
+ try {
147
+ removeLoadAppMirrorRoot(mirrorRoot);
148
+ } catch {
149
+ // Best-effort cleanup must never become the failure path.
150
+ }
151
+ };
152
+
153
+ export const createLoadAppMirrorRootPath = (cwd: string): string =>
154
+ join(
155
+ resolve(cwd),
156
+ LOAD_APP_MIRROR_PARENT_DIRNAME,
157
+ `${LOAD_APP_MIRROR_ENTRY_PREFIX}${Date.now()}-${Math.random()
158
+ .toString(36)
159
+ .slice(2)}`
160
+ );