@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
@@ -0,0 +1,432 @@
1
+ /**
2
+ * CLI-surface bridge for the `--watch` flag (`trails run --watch`).
3
+ *
4
+ * `--watch` is a local-development ergonomics affordance for `trails run`.
5
+ * After the first invocation completes, the CLI installs a filesystem
6
+ * watcher as a cheap event source. On each debounced event, the watcher
7
+ * re-derives the watched trail's resolved-contract hash from its TopoGraph
8
+ * entry and invokes the supplied `onRerun` callback only when that hash
9
+ * changes. The loop runs until the user sends `SIGINT`.
10
+ *
11
+ * Design notes:
12
+ *
13
+ * - **Scope.** Watching is intentionally narrow. Filesystem events only wake
14
+ * the loop; the rerun decision is the watched trail's TopoGraph entry.
15
+ * Comments, whitespace, and unrelated sibling trail changes wake the loop
16
+ * but do not rerun unless the resolved contract changes.
17
+ * - **Debounce.** Editor saves often produce multiple `fs.watch` events
18
+ * per logical change (write tmp, rename, touch mtime). The debounce
19
+ * coalesces these into a single rerun and dampens AFS / iCloud sync
20
+ * bursts.
21
+ * - **No external deps.** Uses `node:fs.watch` (re-exported by Bun) so
22
+ * we avoid pulling in `chokidar` or similar.
23
+ */
24
+
25
+ import { once } from 'node:events';
26
+ import { watch as nodeWatch } from 'node:fs';
27
+ import type { FSWatcher } from 'node:fs';
28
+ import { dirname, extname } from 'node:path';
29
+
30
+ import type { TopoGraphEntry } from '@ontrails/topographer';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Constants
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Debounce window (ms) for coalescing rapid filesystem events into a single
38
+ * rerun. Sized small enough to feel instantaneous but large enough to
39
+ * absorb editor save bursts and sync-driven duplicate notifications.
40
+ */
41
+ export const WATCH_DEBOUNCE_MS = 100;
42
+
43
+ /**
44
+ * Warmup window (ms) after the watcher is created during which incoming
45
+ * events are ignored.
46
+ *
47
+ * On macOS, `fs.watch` (FSEvents) routinely emits a phantom `rename`
48
+ * event for files that already existed in the watched directory shortly
49
+ * after the watcher is installed. Ignoring events within this short
50
+ * warmup prevents a spurious rerun on the first invocation without
51
+ * meaningfully delaying real edits.
52
+ *
53
+ * Applied uniformly across platforms — the cost is negligible (no human
54
+ * saves within ~150ms of starting `trails run --watch`), and a
55
+ * platform-specific branch isn't worth the complexity.
56
+ */
57
+ export const WATCH_WARMUP_MS = 150;
58
+
59
+ /** Extensions considered relevant to a trail rerun. */
60
+ const WATCHED_EXTENSIONS: ReadonlySet<string> = new Set([
61
+ '.ts',
62
+ '.tsx',
63
+ '.js',
64
+ '.mjs',
65
+ '.cjs',
66
+ ]);
67
+
68
+ const ANSI_CLEAR_SCREEN = '\u001B[2J\u001B[H';
69
+
70
+ const WATCH_SCHEMA_INVALID_MESSAGE =
71
+ '[watch] schema invalid; skipping rerun until valid\n';
72
+ const WATCH_TRAIL_REMOVED_MESSAGE = '[watch] trail removed; awaiting return\n';
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Argv detection
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * Detect whether `--watch` appears in argv.
80
+ *
81
+ * Pre-parsed argv detection lets the CLI install the watcher loop before
82
+ * `surface()` parses argv. The flag is also wired through the build
83
+ * pipeline as a meta flag, so trail input is unaffected.
84
+ */
85
+ export const argvHasWatchFlag = (argv: readonly string[]): boolean =>
86
+ argv.includes('--watch');
87
+
88
+ const RUN_FLAGS_WITH_VALUES: ReadonlySet<string> = new Set([
89
+ '--app',
90
+ '--input',
91
+ '--input-json',
92
+ '--module',
93
+ '--output',
94
+ '--root-dir',
95
+ '--token',
96
+ '--permit',
97
+ ]);
98
+
99
+ const RUN_SHORT_FLAGS_WITH_VALUES: ReadonlySet<string> = new Set(['-o']);
100
+
101
+ /**
102
+ * Read the target trail id from a `trails run ...` argv slice.
103
+ *
104
+ * Accepts args after the binary name (for example
105
+ * `['run', '-o', 'json', 'trail.id', '--watch']`). The parser is intentionally
106
+ * small and conservative: it skips known CLI meta flags and their values so the
107
+ * watch loop resolves the same trail the run command will execute.
108
+ */
109
+ export const readRunTrailId = (args: readonly string[]): string | undefined => {
110
+ const runIndex = args.indexOf('run');
111
+ if (runIndex === -1) {
112
+ return undefined;
113
+ }
114
+ const positionals: string[] = [];
115
+ for (let i = runIndex + 1; i < args.length; i += 1) {
116
+ const arg = args[i];
117
+ if (arg === undefined) {
118
+ continue;
119
+ }
120
+ if (arg.startsWith('--')) {
121
+ if (!arg.includes('=') && RUN_FLAGS_WITH_VALUES.has(arg)) {
122
+ i += 1;
123
+ }
124
+ continue;
125
+ }
126
+ if (arg.startsWith('-')) {
127
+ if (RUN_SHORT_FLAGS_WITH_VALUES.has(arg)) {
128
+ i += 1;
129
+ }
130
+ continue;
131
+ }
132
+ positionals.push(arg);
133
+ }
134
+ const [first, second] = positionals;
135
+ if (first === 'examples' || first === 'example') {
136
+ return second;
137
+ }
138
+ return first;
139
+ };
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Helpers
143
+ // ---------------------------------------------------------------------------
144
+
145
+ const formatError = (error: unknown): string => {
146
+ if (error instanceof Error) {
147
+ return error.message;
148
+ }
149
+ return String(error);
150
+ };
151
+
152
+ const isRelevantFilename = (filename: string | null): boolean => {
153
+ if (filename === null || filename.length === 0) {
154
+ return false;
155
+ }
156
+ return WATCHED_EXTENSIONS.has(extname(filename));
157
+ };
158
+
159
+ const canonicalize = (value: unknown): unknown => {
160
+ if (Array.isArray(value)) {
161
+ return value.map(canonicalize);
162
+ }
163
+ if (value !== null && typeof value === 'object') {
164
+ const sorted: Record<string, unknown> = {};
165
+ for (const key of Object.keys(value).toSorted()) {
166
+ sorted[key] = canonicalize((value as Record<string, unknown>)[key]);
167
+ }
168
+ return sorted;
169
+ }
170
+ return value;
171
+ };
172
+
173
+ export const hashTopoGraphEntry = (entry: TopoGraphEntry): string => {
174
+ const hasher = new Bun.CryptoHasher('sha256');
175
+ hasher.update(JSON.stringify(canonicalize(entry)));
176
+ return hasher.digest('hex');
177
+ };
178
+
179
+ export type ReadTopoGraphEntryHash = () =>
180
+ | Promise<string | null>
181
+ | string
182
+ | null;
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Watcher
186
+ // ---------------------------------------------------------------------------
187
+
188
+ /** Options for {@link createTrailWatcher}. */
189
+ export interface CreateTrailWatcherOptions {
190
+ /**
191
+ * Absolute path to the resolved trail's source file. The watcher targets
192
+ * the directory containing this file (non-recursive) and filters events
193
+ * to relevant extensions only.
194
+ */
195
+ readonly sourcePath: string;
196
+ /**
197
+ * Invoked once per debounced change burst. Errors thrown by the callback
198
+ * are caught and reported to stderr so a misbehaving handler does not
199
+ * tear down the watcher loop.
200
+ */
201
+ readonly onRerun: () => void | Promise<void>;
202
+ /**
203
+ * Derive the watched trail's current resolved-contract hash. Return `null`
204
+ * when the watched trail is temporarily absent. Throw when the current
205
+ * source state cannot produce a valid TopoGraph.
206
+ */
207
+ readonly readTopoGraphEntryHash: ReadTopoGraphEntryHash;
208
+ /**
209
+ * Last known good resolved-contract hash captured after the initial run.
210
+ * When omitted, the next valid changed hash becomes the first rerun signal.
211
+ */
212
+ readonly initialTopoGraphEntryHash?: string | null | undefined;
213
+ /**
214
+ * Override for the debounce window. Primarily a test seam; production
215
+ * callers should rely on {@link WATCH_DEBOUNCE_MS}.
216
+ */
217
+ readonly debounceMs?: number | undefined;
218
+ /**
219
+ * Override for the warmup window. Primarily a test seam; production
220
+ * callers should rely on {@link WATCH_WARMUP_MS}.
221
+ */
222
+ readonly warmupMs?: number | undefined;
223
+ }
224
+
225
+ /** Handle returned by {@link createTrailWatcher}. */
226
+ export interface TrailWatcher {
227
+ /**
228
+ * Stop the watcher and clear any pending debounce timer. Idempotent —
229
+ * subsequent calls are no-ops.
230
+ */
231
+ readonly close: () => void;
232
+ }
233
+
234
+ /**
235
+ * Create a filesystem watcher that triggers `onRerun` whenever a relevant
236
+ * filesystem event changes the watched trail's resolved contract.
237
+ *
238
+ * The watcher targets the directory of `sourcePath` (non-recursive). Events
239
+ * are filtered to TypeScript/JavaScript file extensions and coalesced through
240
+ * a short debounce window. Each debounced event re-reads the watched trail's
241
+ * TopoGraph entry hash; only a hash change reruns the trail.
242
+ *
243
+ * @remarks Reruns are not serialized. If a save lands while a previous
244
+ * rerun is still awaiting `onRerun`, the new debounce window can fire
245
+ * concurrently. In practice the {@link WATCH_DEBOUNCE_MS} window plus
246
+ * realistic save cadences make this uncommon, and each `surface()` call
247
+ * from the loop is independent. Callers that share mutable surface
248
+ * state (e.g. a global trace sink) must scope it per invocation —
249
+ * `runSurfaceOnce` in `apps/trails/src/cli.ts` does this for `--trace`.
250
+ */
251
+ export const createTrailWatcher = (
252
+ options: CreateTrailWatcherOptions
253
+ ): TrailWatcher => {
254
+ const debounceMs = options.debounceMs ?? WATCH_DEBOUNCE_MS;
255
+ const warmupMs = options.warmupMs ?? WATCH_WARMUP_MS;
256
+ const watchDir = dirname(options.sourcePath);
257
+ const startedAt = Date.now();
258
+
259
+ let closed = false;
260
+ let currentTopoGraphEntryHash = options.initialTopoGraphEntryHash ?? null;
261
+ let invalidTopoGraphWarned = false;
262
+ let trailRemovedWarned = false;
263
+ let pending: ReturnType<typeof setTimeout> | undefined;
264
+ let watcher: FSWatcher | undefined;
265
+
266
+ const readNextTopoGraphEntryHash = async (): Promise<
267
+ string | null | undefined
268
+ > => {
269
+ try {
270
+ const nextHash = await options.readTopoGraphEntryHash();
271
+ invalidTopoGraphWarned = false;
272
+ if (nextHash !== null) {
273
+ trailRemovedWarned = false;
274
+ } else if (!trailRemovedWarned) {
275
+ process.stderr.write(WATCH_TRAIL_REMOVED_MESSAGE);
276
+ trailRemovedWarned = true;
277
+ }
278
+ return nextHash;
279
+ } catch {
280
+ if (!invalidTopoGraphWarned) {
281
+ process.stderr.write(WATCH_SCHEMA_INVALID_MESSAGE);
282
+ invalidTopoGraphWarned = true;
283
+ }
284
+ return undefined;
285
+ }
286
+ };
287
+
288
+ const fireRerun = async (): Promise<void> => {
289
+ pending = undefined;
290
+ if (closed) {
291
+ return;
292
+ }
293
+ const nextHash = await readNextTopoGraphEntryHash();
294
+ if (closed) {
295
+ return;
296
+ }
297
+ if (nextHash === undefined || nextHash === null) {
298
+ return;
299
+ }
300
+ if (nextHash === currentTopoGraphEntryHash) {
301
+ return;
302
+ }
303
+ currentTopoGraphEntryHash = nextHash;
304
+ try {
305
+ await options.onRerun();
306
+ } catch (error: unknown) {
307
+ process.stderr.write(`watch: rerun failed: ${formatError(error)}\n`);
308
+ }
309
+ };
310
+
311
+ const scheduleRerun = (): void => {
312
+ if (closed) {
313
+ return;
314
+ }
315
+ if (pending !== undefined) {
316
+ clearTimeout(pending);
317
+ }
318
+ pending = setTimeout(() => {
319
+ void fireRerun();
320
+ }, debounceMs);
321
+ };
322
+
323
+ watcher = nodeWatch(
324
+ watchDir,
325
+ { persistent: true, recursive: false },
326
+ (_event, filename) => {
327
+ if (Date.now() - startedAt < warmupMs) {
328
+ // Suppress FSEvents replay of pre-existing files on macOS.
329
+ return;
330
+ }
331
+ if (!isRelevantFilename(filename)) {
332
+ return;
333
+ }
334
+ scheduleRerun();
335
+ }
336
+ );
337
+
338
+ watcher.on('error', (error: Error) => {
339
+ process.stderr.write(`watch: watcher error: ${error.message}\n`);
340
+ });
341
+
342
+ return {
343
+ close: () => {
344
+ if (closed) {
345
+ return;
346
+ }
347
+ closed = true;
348
+ if (pending !== undefined) {
349
+ clearTimeout(pending);
350
+ pending = undefined;
351
+ }
352
+ if (watcher !== undefined) {
353
+ watcher.close();
354
+ watcher = undefined;
355
+ }
356
+ },
357
+ };
358
+ };
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // Watch loop
362
+ // ---------------------------------------------------------------------------
363
+
364
+ /** Options for {@link runWatchLoop}. */
365
+ export interface RunWatchLoopOptions {
366
+ /** Absolute path to the resolved trail's source file. */
367
+ readonly sourcePath: string;
368
+ /** Invoked once per debounced change burst (and once initially). */
369
+ readonly run: () => Promise<void>;
370
+ /** Derive the watched trail's current resolved-contract hash. */
371
+ readonly readTopoGraphEntryHash: ReadTopoGraphEntryHash;
372
+ /**
373
+ * Override for the debounce window. Primarily a test seam.
374
+ */
375
+ readonly debounceMs?: number | undefined;
376
+ /**
377
+ * Whether to clear the terminal between reruns. Defaults to `true` for
378
+ * the standard interactive experience; tests pass `false` to keep
379
+ * captured output legible.
380
+ */
381
+ readonly clearScreen?: boolean | undefined;
382
+ }
383
+
384
+ /**
385
+ * Run the trail once, then install a watcher and re-run on changes until
386
+ * `SIGINT` is received. Returns the exit code (always `0` on a clean
387
+ * SIGINT shutdown).
388
+ *
389
+ * @remarks This is the high-level entry point used by the CLI binary.
390
+ * Tests should target {@link createTrailWatcher} directly rather than
391
+ * spawning a subprocess to drive this loop.
392
+ */
393
+ export const runWatchLoop = async (
394
+ options: RunWatchLoopOptions
395
+ ): Promise<number> => {
396
+ const clearScreen = options.clearScreen ?? true;
397
+
398
+ const performRun = async (): Promise<void> => {
399
+ if (clearScreen) {
400
+ process.stdout.write(ANSI_CLEAR_SCREEN);
401
+ }
402
+ try {
403
+ await options.run();
404
+ } catch (error: unknown) {
405
+ process.stderr.write(`watch: run failed: ${formatError(error)}\n`);
406
+ }
407
+ };
408
+
409
+ await performRun();
410
+
411
+ let initialTopoGraphEntryHash: string | null = null;
412
+ try {
413
+ initialTopoGraphEntryHash = await options.readTopoGraphEntryHash();
414
+ } catch {
415
+ process.stderr.write(WATCH_SCHEMA_INVALID_MESSAGE);
416
+ }
417
+
418
+ const watcher = createTrailWatcher({
419
+ debounceMs: options.debounceMs,
420
+ initialTopoGraphEntryHash,
421
+ onRerun: performRun,
422
+ readTopoGraphEntryHash: options.readTopoGraphEntryHash,
423
+ sourcePath: options.sourcePath,
424
+ });
425
+
426
+ // `once(emitter, 'event')` returns a Promise that resolves when the
427
+ // event fires. Cleaner than `new Promise(resolve => emitter.on(...))`
428
+ // and aligns with `eslint-plugin-promise/avoid-new`.
429
+ await once(process, 'SIGINT');
430
+ watcher.close();
431
+ return 0;
432
+ };
@@ -0,0 +1,12 @@
1
+ // GENERATED FILE — do not edit by hand. Run `bun scripts/sync-scaffold-versions.ts` to regenerate.
2
+
3
+ export const scaffoldDependencyVersions = {
4
+ bunTypes: '^1.3.11',
5
+ commander: '^14.0.3',
6
+ lefthook: '^2.1.1',
7
+ oxfmt: '0.47.0',
8
+ oxlint: '1.62.0',
9
+ typescript: '^5.9.3',
10
+ ultracite: '7.6.2',
11
+ zod: '^4.3.5',
12
+ } as const;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * `add.surface` trail -- Add a surface to an existing project.
3
+ *
4
+ * Generates surface entry points and updates package.json dependencies.
5
+ */
6
+
7
+ import { existsSync } from 'node:fs';
8
+ import { basename, resolve } from 'node:path';
9
+
10
+ import { AlreadyExistsError, Result, trail } from '@ontrails/core';
11
+ import { z } from 'zod';
12
+
13
+ import {
14
+ projectPathExists,
15
+ resolveProjectPath,
16
+ writeProjectFile,
17
+ } from '../project-writes.js';
18
+ import { ontrailsPackageRange } from '../versions.js';
19
+ import { findTopoPath } from './project.js';
20
+
21
+ type Surface = 'cli' | 'http' | 'mcp';
22
+
23
+ const generateCliEntry = (appImportPath: string): string =>
24
+ `import { surface } from '@ontrails/commander';
25
+
26
+ import { app } from '${appImportPath}';
27
+
28
+ await surface(app);
29
+ `;
30
+
31
+ const generateMcpEntry = (appImportPath: string): string =>
32
+ `import { surface } from '@ontrails/mcp';
33
+
34
+ import { app } from '${appImportPath}';
35
+
36
+ await surface(app);
37
+ `;
38
+
39
+ const generateHttpEntry = (appImportPath: string): string =>
40
+ `import { surface } from '@ontrails/hono';
41
+
42
+ import { app } from '${appImportPath}';
43
+
44
+ await surface(app, { port: 3000 });
45
+ `;
46
+
47
+ const surfaceEntryFiles = {
48
+ cli: 'src/cli.ts',
49
+ http: 'src/http.ts',
50
+ mcp: 'src/mcp.ts',
51
+ } satisfies Record<Surface, string>;
52
+
53
+ const surfaceDependencies = {
54
+ cli: ['@ontrails/cli', '@ontrails/commander'],
55
+ http: ['@ontrails/hono', '@ontrails/http'],
56
+ mcp: ['@ontrails/mcp'],
57
+ } satisfies Record<Surface, readonly string[]>;
58
+
59
+ /** Resolve the entry file for a surface. */
60
+ const getEntryFile = (surface: Surface): string => surfaceEntryFiles[surface];
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Trail definition
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /** Patch deps and optionally bin in a parsed package.json. */
67
+ const patchPkgDeps = (
68
+ pkg: Record<string, unknown>,
69
+ surface: Surface,
70
+ cwd: string
71
+ ): string => {
72
+ const [depName = ''] = surfaceDependencies[surface];
73
+ const deps = (pkg['dependencies'] ?? {}) as Record<string, string>;
74
+ for (const dependency of surfaceDependencies[surface]) {
75
+ deps[dependency] = ontrailsPackageRange;
76
+ }
77
+ if (surface === 'cli') {
78
+ pkg['bin'] = {
79
+ [(pkg['name'] as string | undefined) ?? basename(cwd)]: './src/cli.ts',
80
+ };
81
+ }
82
+ pkg['dependencies'] = Object.fromEntries(
83
+ Object.entries(deps).toSorted(([a], [b]) => a.localeCompare(b))
84
+ );
85
+ return depName;
86
+ };
87
+
88
+ /** Update package.json with surface dependency and CLI bin if needed. */
89
+ const updatePkgJsonForSurface = async (
90
+ cwd: string,
91
+ surface: Surface
92
+ ): Promise<Result<string, Error>> => {
93
+ const pkgPathResult = resolveProjectPath(cwd, 'package.json');
94
+ if (pkgPathResult.isErr()) {
95
+ return Result.err(pkgPathResult.error);
96
+ }
97
+
98
+ const pkgPath = pkgPathResult.value;
99
+ if (!existsSync(pkgPath)) {
100
+ return Result.ok(surfaceDependencies[surface][0] ?? '');
101
+ }
102
+ const pkg = (await Bun.file(pkgPath).json()) as Record<string, unknown>;
103
+ const depName = patchPkgDeps(pkg, surface, cwd);
104
+ const written = await writeProjectFile(
105
+ cwd,
106
+ 'package.json',
107
+ `${JSON.stringify(pkg, null, 2)}\n`
108
+ );
109
+ return written.isErr() ? Result.err(written.error) : Result.ok(depName);
110
+ };
111
+
112
+ /** Create the entry file for a surface and return the relative path. */
113
+ const writeSurfaceEntry = async (
114
+ cwd: string,
115
+ surface: Surface
116
+ ): Promise<Result<string, Error>> => {
117
+ const entryFile = getEntryFile(surface);
118
+ const appImport = (await findTopoPath(cwd)) ?? './app.js';
119
+ const generators = {
120
+ cli: generateCliEntry,
121
+ http: generateHttpEntry,
122
+ mcp: generateMcpEntry,
123
+ } satisfies Record<Surface, (appImportPath: string) => string>;
124
+ const content = generators[surface](appImport);
125
+
126
+ const written = await writeProjectFile(cwd, entryFile, content);
127
+ return written.isErr() ? Result.err(written.error) : Result.ok(entryFile);
128
+ };
129
+
130
+ export const addSurface = trail('add.surface', {
131
+ blaze: async (input) => {
132
+ const cwd = resolve(input.dir ?? '.');
133
+ const { surface } = input;
134
+ const entryFile = getEntryFile(surface);
135
+ const entryExists = projectPathExists(cwd, entryFile);
136
+ if (entryExists.isErr()) {
137
+ return Result.err(entryExists.error);
138
+ }
139
+
140
+ if (entryExists.value) {
141
+ return Result.err(
142
+ new AlreadyExistsError(
143
+ `${surface.toUpperCase()} surface already exists. Nothing to do.`
144
+ )
145
+ );
146
+ }
147
+
148
+ const created = await writeSurfaceEntry(cwd, surface);
149
+ if (created.isErr()) {
150
+ return Result.err(created.error);
151
+ }
152
+
153
+ const dependency = await updatePkgJsonForSurface(cwd, surface);
154
+ if (dependency.isErr()) {
155
+ return Result.err(dependency.error);
156
+ }
157
+
158
+ return Result.ok({
159
+ created: created.value,
160
+ dependency: dependency.value,
161
+ });
162
+ },
163
+ description: 'Add a surface to an existing project',
164
+ input: z.object({
165
+ dir: z.string().optional().describe('Project directory'),
166
+ surface: z.enum(['cli', 'http', 'mcp']).describe('Surface to add'),
167
+ }),
168
+ output: z.object({
169
+ created: z.string(),
170
+ dependency: z.string(),
171
+ }),
172
+ });