@ontrails/trails 1.0.0-beta.15 → 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 (201) hide show
  1. package/CHANGELOG.md +197 -2
  2. package/README.md +27 -0
  3. package/package.json +19 -8
  4. package/src/app.ts +15 -5
  5. package/src/cli.ts +303 -10
  6. package/src/completions.ts +240 -0
  7. package/src/load-app-mirror.ts +160 -0
  8. package/src/local-state-io.ts +153 -0
  9. package/src/project-writes.ts +320 -0
  10. package/src/run-collision.ts +125 -0
  11. package/src/run-completions-install.ts +179 -0
  12. package/src/run-example.ts +149 -0
  13. package/src/run-examples.ts +148 -0
  14. package/src/run-quiet.ts +75 -0
  15. package/src/run-trace.ts +273 -0
  16. package/src/run-warden.ts +39 -0
  17. package/src/run-watch.ts +432 -0
  18. package/src/scaffold-versions.generated.ts +12 -0
  19. package/src/trails/add-surface.ts +45 -23
  20. package/src/trails/add-trail.ts +27 -17
  21. package/src/trails/add-verify.ts +57 -17
  22. package/src/trails/completions-complete.ts +165 -0
  23. package/src/trails/completions.ts +47 -0
  24. package/src/trails/create-scaffold.ts +86 -33
  25. package/src/trails/create.ts +11 -3
  26. package/src/trails/dev-clean.ts +6 -1
  27. package/src/trails/dev-reset.ts +6 -1
  28. package/src/trails/dev-stats.ts +6 -1
  29. package/src/trails/dev-support.ts +29 -17
  30. package/src/trails/draft-promote.ts +289 -80
  31. package/src/trails/guide.ts +54 -34
  32. package/src/trails/load-app.ts +251 -56
  33. package/src/trails/root-dir.ts +21 -0
  34. package/src/trails/run-example.ts +482 -0
  35. package/src/trails/run-examples.ts +141 -0
  36. package/src/trails/run.ts +403 -0
  37. package/src/trails/survey.ts +506 -200
  38. package/src/trails/topo-activation.ts +385 -0
  39. package/src/trails/topo-compile.ts +55 -0
  40. package/src/trails/topo-history.ts +6 -1
  41. package/src/trails/topo-output-schemas.ts +175 -0
  42. package/src/trails/topo-pin.ts +19 -6
  43. package/src/trails/topo-read-support.ts +171 -228
  44. package/src/trails/topo-reports.ts +400 -25
  45. package/src/trails/topo-store-support.ts +43 -19
  46. package/src/trails/topo-support.ts +18 -28
  47. package/src/trails/topo-unpin.ts +6 -1
  48. package/src/trails/topo-verify.ts +18 -5
  49. package/src/trails/topo.ts +60 -23
  50. package/src/trails/warden-guide.ts +121 -0
  51. package/src/trails/warden.ts +137 -56
  52. package/src/versions.ts +3 -18
  53. package/.turbo/turbo-build.log +0 -1
  54. package/.turbo/turbo-lint.log +0 -3
  55. package/.turbo/turbo-typecheck.log +0 -1
  56. package/__tests__/examples.test.ts +0 -45
  57. package/dist/bin/trails.d.ts +0 -3
  58. package/dist/bin/trails.d.ts.map +0 -1
  59. package/dist/bin/trails.js +0 -4
  60. package/dist/bin/trails.js.map +0 -1
  61. package/dist/src/app.d.ts +0 -2
  62. package/dist/src/app.d.ts.map +0 -1
  63. package/dist/src/app.js +0 -22
  64. package/dist/src/app.js.map +0 -1
  65. package/dist/src/clack.d.ts +0 -9
  66. package/dist/src/clack.d.ts.map +0 -1
  67. package/dist/src/clack.js +0 -84
  68. package/dist/src/clack.js.map +0 -1
  69. package/dist/src/cli.d.ts +0 -2
  70. package/dist/src/cli.d.ts.map +0 -1
  71. package/dist/src/cli.js +0 -14
  72. package/dist/src/cli.js.map +0 -1
  73. package/dist/src/trails/add-surface.d.ts +0 -13
  74. package/dist/src/trails/add-surface.d.ts.map +0 -1
  75. package/dist/src/trails/add-surface.js +0 -110
  76. package/dist/src/trails/add-surface.js.map +0 -1
  77. package/dist/src/trails/add-trail.d.ts +0 -12
  78. package/dist/src/trails/add-trail.d.ts.map +0 -1
  79. package/dist/src/trails/add-trail.js +0 -104
  80. package/dist/src/trails/add-trail.js.map +0 -1
  81. package/dist/src/trails/add-trailhead.d.ts +0 -13
  82. package/dist/src/trails/add-trailhead.d.ts.map +0 -1
  83. package/dist/src/trails/add-trailhead.js +0 -88
  84. package/dist/src/trails/add-trailhead.js.map +0 -1
  85. package/dist/src/trails/add-verify.d.ts +0 -10
  86. package/dist/src/trails/add-verify.d.ts.map +0 -1
  87. package/dist/src/trails/add-verify.js +0 -68
  88. package/dist/src/trails/add-verify.js.map +0 -1
  89. package/dist/src/trails/create-scaffold.d.ts +0 -15
  90. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  91. package/dist/src/trails/create-scaffold.js +0 -295
  92. package/dist/src/trails/create-scaffold.js.map +0 -1
  93. package/dist/src/trails/create.d.ts +0 -18
  94. package/dist/src/trails/create.d.ts.map +0 -1
  95. package/dist/src/trails/create.js +0 -126
  96. package/dist/src/trails/create.js.map +0 -1
  97. package/dist/src/trails/dev-clean.d.ts +0 -9
  98. package/dist/src/trails/dev-clean.d.ts.map +0 -1
  99. package/dist/src/trails/dev-clean.js +0 -66
  100. package/dist/src/trails/dev-clean.js.map +0 -1
  101. package/dist/src/trails/dev-reset.d.ts +0 -6
  102. package/dist/src/trails/dev-reset.d.ts.map +0 -1
  103. package/dist/src/trails/dev-reset.js +0 -39
  104. package/dist/src/trails/dev-reset.js.map +0 -1
  105. package/dist/src/trails/dev-stats.d.ts +0 -7
  106. package/dist/src/trails/dev-stats.d.ts.map +0 -1
  107. package/dist/src/trails/dev-stats.js +0 -61
  108. package/dist/src/trails/dev-stats.js.map +0 -1
  109. package/dist/src/trails/dev-support.d.ts +0 -64
  110. package/dist/src/trails/dev-support.d.ts.map +0 -1
  111. package/dist/src/trails/dev-support.js +0 -181
  112. package/dist/src/trails/dev-support.js.map +0 -1
  113. package/dist/src/trails/draft-promote.d.ts +0 -18
  114. package/dist/src/trails/draft-promote.d.ts.map +0 -1
  115. package/dist/src/trails/draft-promote.js +0 -400
  116. package/dist/src/trails/draft-promote.js.map +0 -1
  117. package/dist/src/trails/guide.d.ts +0 -21
  118. package/dist/src/trails/guide.d.ts.map +0 -1
  119. package/dist/src/trails/guide.js +0 -61
  120. package/dist/src/trails/guide.js.map +0 -1
  121. package/dist/src/trails/load-app.d.ts +0 -12
  122. package/dist/src/trails/load-app.d.ts.map +0 -1
  123. package/dist/src/trails/load-app.js +0 -415
  124. package/dist/src/trails/load-app.js.map +0 -1
  125. package/dist/src/trails/project.d.ts +0 -8
  126. package/dist/src/trails/project.d.ts.map +0 -1
  127. package/dist/src/trails/project.js +0 -54
  128. package/dist/src/trails/project.js.map +0 -1
  129. package/dist/src/trails/survey.d.ts +0 -18
  130. package/dist/src/trails/survey.d.ts.map +0 -1
  131. package/dist/src/trails/survey.js +0 -234
  132. package/dist/src/trails/survey.js.map +0 -1
  133. package/dist/src/trails/topo-constants.d.ts +0 -3
  134. package/dist/src/trails/topo-constants.d.ts.map +0 -1
  135. package/dist/src/trails/topo-constants.js +0 -3
  136. package/dist/src/trails/topo-constants.js.map +0 -1
  137. package/dist/src/trails/topo-export.d.ts +0 -19
  138. package/dist/src/trails/topo-export.d.ts.map +0 -1
  139. package/dist/src/trails/topo-export.js +0 -31
  140. package/dist/src/trails/topo-export.js.map +0 -1
  141. package/dist/src/trails/topo-history.d.ts +0 -20
  142. package/dist/src/trails/topo-history.d.ts.map +0 -1
  143. package/dist/src/trails/topo-history.js +0 -32
  144. package/dist/src/trails/topo-history.js.map +0 -1
  145. package/dist/src/trails/topo-pin.d.ts +0 -17
  146. package/dist/src/trails/topo-pin.d.ts.map +0 -1
  147. package/dist/src/trails/topo-pin.js +0 -31
  148. package/dist/src/trails/topo-pin.js.map +0 -1
  149. package/dist/src/trails/topo-read-support.d.ts +0 -58
  150. package/dist/src/trails/topo-read-support.d.ts.map +0 -1
  151. package/dist/src/trails/topo-read-support.js +0 -167
  152. package/dist/src/trails/topo-read-support.js.map +0 -1
  153. package/dist/src/trails/topo-reports.d.ts +0 -54
  154. package/dist/src/trails/topo-reports.d.ts.map +0 -1
  155. package/dist/src/trails/topo-reports.js +0 -128
  156. package/dist/src/trails/topo-reports.js.map +0 -1
  157. package/dist/src/trails/topo-show.d.ts +0 -23
  158. package/dist/src/trails/topo-show.d.ts.map +0 -1
  159. package/dist/src/trails/topo-show.js +0 -49
  160. package/dist/src/trails/topo-show.js.map +0 -1
  161. package/dist/src/trails/topo-store-support.d.ts +0 -13
  162. package/dist/src/trails/topo-store-support.d.ts.map +0 -1
  163. package/dist/src/trails/topo-store-support.js +0 -55
  164. package/dist/src/trails/topo-store-support.js.map +0 -1
  165. package/dist/src/trails/topo-support.d.ts +0 -76
  166. package/dist/src/trails/topo-support.d.ts.map +0 -1
  167. package/dist/src/trails/topo-support.js +0 -132
  168. package/dist/src/trails/topo-support.js.map +0 -1
  169. package/dist/src/trails/topo-unpin.d.ts +0 -20
  170. package/dist/src/trails/topo-unpin.d.ts.map +0 -1
  171. package/dist/src/trails/topo-unpin.js +0 -44
  172. package/dist/src/trails/topo-unpin.js.map +0 -1
  173. package/dist/src/trails/topo-verify.d.ts +0 -5
  174. package/dist/src/trails/topo-verify.d.ts.map +0 -1
  175. package/dist/src/trails/topo-verify.js +0 -24
  176. package/dist/src/trails/topo-verify.js.map +0 -1
  177. package/dist/src/trails/topo.d.ts +0 -5
  178. package/dist/src/trails/topo.d.ts.map +0 -1
  179. package/dist/src/trails/topo.js +0 -63
  180. package/dist/src/trails/topo.js.map +0 -1
  181. package/dist/src/trails/warden.d.ts +0 -20
  182. package/dist/src/trails/warden.d.ts.map +0 -1
  183. package/dist/src/trails/warden.js +0 -98
  184. package/dist/src/trails/warden.js.map +0 -1
  185. package/dist/src/versions.d.ts +0 -12
  186. package/dist/src/versions.d.ts.map +0 -1
  187. package/dist/src/versions.js +0 -23
  188. package/dist/src/versions.js.map +0 -1
  189. package/dist/tsconfig.tsbuildinfo +0 -1
  190. package/src/__tests__/add-trail.test.ts +0 -97
  191. package/src/__tests__/create.test.ts +0 -415
  192. package/src/__tests__/draft-promote.test.ts +0 -144
  193. package/src/__tests__/guide.test.ts +0 -96
  194. package/src/__tests__/load-app.test.ts +0 -419
  195. package/src/__tests__/survey.test.ts +0 -377
  196. package/src/__tests__/topo-dev.test.ts +0 -426
  197. package/src/__tests__/warden.test.ts +0 -74
  198. package/src/trails/topo-export.ts +0 -35
  199. package/src/trails/topo-show.ts +0 -54
  200. package/tsconfig.json +0 -9
  201. package/tsconfig.tests.json +0 -10
@@ -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;
@@ -4,22 +4,24 @@
4
4
  * Generates surface entry points and updates package.json dependencies.
5
5
  */
6
6
 
7
- import { existsSync, mkdirSync } from 'node:fs';
8
- import { basename, dirname, join, resolve } from 'node:path';
7
+ import { existsSync } from 'node:fs';
8
+ import { basename, resolve } from 'node:path';
9
9
 
10
- import { Result, trail } from '@ontrails/core';
10
+ import { AlreadyExistsError, Result, trail } from '@ontrails/core';
11
11
  import { z } from 'zod';
12
12
 
13
13
  import {
14
- ontrailsPackageRange,
15
- scaffoldDependencyVersions,
16
- } from '../versions.js';
14
+ projectPathExists,
15
+ resolveProjectPath,
16
+ writeProjectFile,
17
+ } from '../project-writes.js';
18
+ import { ontrailsPackageRange } from '../versions.js';
17
19
  import { findTopoPath } from './project.js';
18
20
 
19
21
  type Surface = 'cli' | 'http' | 'mcp';
20
22
 
21
23
  const generateCliEntry = (appImportPath: string): string =>
22
- `import { surface } from '@ontrails/cli/commander';
24
+ `import { surface } from '@ontrails/commander';
23
25
 
24
26
  import { app } from '${appImportPath}';
25
27
 
@@ -49,7 +51,7 @@ const surfaceEntryFiles = {
49
51
  } satisfies Record<Surface, string>;
50
52
 
51
53
  const surfaceDependencies = {
52
- cli: ['@ontrails/cli'],
54
+ cli: ['@ontrails/cli', '@ontrails/commander'],
53
55
  http: ['@ontrails/hono', '@ontrails/http'],
54
56
  mcp: ['@ontrails/mcp'],
55
57
  } satisfies Record<Surface, readonly string[]>;
@@ -73,7 +75,6 @@ const patchPkgDeps = (
73
75
  deps[dependency] = ontrailsPackageRange;
74
76
  }
75
77
  if (surface === 'cli') {
76
- deps['commander'] = scaffoldDependencyVersions.commander;
77
78
  pkg['bin'] = {
78
79
  [(pkg['name'] as string | undefined) ?? basename(cwd)]: './src/cli.ts',
79
80
  };
@@ -88,24 +89,32 @@ const patchPkgDeps = (
88
89
  const updatePkgJsonForSurface = async (
89
90
  cwd: string,
90
91
  surface: Surface
91
- ): Promise<string> => {
92
- const pkgPath = join(cwd, 'package.json');
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;
93
99
  if (!existsSync(pkgPath)) {
94
- return surfaceDependencies[surface][0] ?? '';
100
+ return Result.ok(surfaceDependencies[surface][0] ?? '');
95
101
  }
96
102
  const pkg = (await Bun.file(pkgPath).json()) as Record<string, unknown>;
97
103
  const depName = patchPkgDeps(pkg, surface, cwd);
98
- await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
99
- return depName;
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);
100
110
  };
101
111
 
102
112
  /** Create the entry file for a surface and return the relative path. */
103
113
  const writeSurfaceEntry = async (
104
114
  cwd: string,
105
115
  surface: Surface
106
- ): Promise<string> => {
116
+ ): Promise<Result<string, Error>> => {
107
117
  const entryFile = getEntryFile(surface);
108
- const fullEntryPath = join(cwd, entryFile);
109
118
  const appImport = (await findTopoPath(cwd)) ?? './app.js';
110
119
  const generators = {
111
120
  cli: generateCliEntry,
@@ -114,9 +123,8 @@ const writeSurfaceEntry = async (
114
123
  } satisfies Record<Surface, (appImportPath: string) => string>;
115
124
  const content = generators[surface](appImport);
116
125
 
117
- mkdirSync(dirname(fullEntryPath), { recursive: true });
118
- await Bun.write(fullEntryPath, content);
119
- return entryFile;
126
+ const written = await writeProjectFile(cwd, entryFile, content);
127
+ return written.isErr() ? Result.err(written.error) : Result.ok(entryFile);
120
128
  };
121
129
 
122
130
  export const addSurface = trail('add.surface', {
@@ -124,18 +132,32 @@ export const addSurface = trail('add.surface', {
124
132
  const cwd = resolve(input.dir ?? '.');
125
133
  const { surface } = input;
126
134
  const entryFile = getEntryFile(surface);
135
+ const entryExists = projectPathExists(cwd, entryFile);
136
+ if (entryExists.isErr()) {
137
+ return Result.err(entryExists.error);
138
+ }
127
139
 
128
- if (existsSync(join(cwd, entryFile))) {
140
+ if (entryExists.value) {
129
141
  return Result.err(
130
- new Error(
142
+ new AlreadyExistsError(
131
143
  `${surface.toUpperCase()} surface already exists. Nothing to do.`
132
144
  )
133
145
  );
134
146
  }
135
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
+
136
158
  return Result.ok({
137
- created: await writeSurfaceEntry(cwd, surface),
138
- dependency: await updatePkgJsonForSurface(cwd, surface),
159
+ created: created.value,
160
+ dependency: dependency.value,
139
161
  });
140
162
  },
141
163
  description: 'Add a surface to an existing project',
@@ -2,12 +2,20 @@
2
2
  * `add.trail` trail -- Scaffold a new trail file with tests.
3
3
  */
4
4
 
5
- import { mkdirSync } from 'node:fs';
6
- import { dirname, join, resolve } from 'node:path';
5
+ import { resolve } from 'node:path';
7
6
 
8
7
  import { Result, trail } from '@ontrails/core';
9
8
  import { z } from 'zod';
10
9
 
10
+ import {
11
+ trailIdToExportName,
12
+ trailIdToModuleName,
13
+ TRAIL_ID_MESSAGE,
14
+ TRAIL_ID_PATTERN,
15
+ validateTrailId,
16
+ writeProjectFile,
17
+ } from '../project-writes.js';
18
+
11
19
  // ---------------------------------------------------------------------------
12
20
  // Helpers
13
21
  // ---------------------------------------------------------------------------
@@ -22,13 +30,15 @@ const generateTrailFile = (
22
30
  exampleName: string,
23
31
  intent: 'read' | 'write' | 'destroy'
24
32
  ): string => {
25
- const intentLine = intent === 'write' ? '' : `\n intent: '${intent}',`;
33
+ const intentLine =
34
+ intent === 'write' ? '' : `\n intent: ${literal(intent)},`;
26
35
  const exampleMessage = deriveExampleMessage(id);
36
+ const trailName = trailIdToExportName(id);
27
37
 
28
38
  return `import { Result, trail } from '@ontrails/core';
29
39
  import { z } from 'zod';
30
40
 
31
- export const ${id.replaceAll('.', '_')} = trail('${id}', {
41
+ export const ${trailName} = trail(${literal(id)}, {
32
42
  blaze: async () => {
33
43
  return Result.ok({ message: ${literal(exampleMessage)} });
34
44
  },
@@ -47,8 +57,8 @@ export const ${id.replaceAll('.', '_')} = trail('${id}', {
47
57
  };
48
58
 
49
59
  const generateTestFile = (id: string, exampleName: string): string => {
50
- const moduleName = id.replaceAll('.', '-');
51
- const trailName = id.replaceAll('.', '_');
60
+ const moduleName = trailIdToModuleName(id);
61
+ const trailName = trailIdToExportName(id);
52
62
  const exampleMessage = deriveExampleMessage(id);
53
63
  return `import { testTrail } from '@ontrails/testing';
54
64
  import { ${trailName} } from '../src/trails/${moduleName}.js';
@@ -67,20 +77,16 @@ testTrail(${trailName}, [
67
77
  // Trail definition
68
78
  // ---------------------------------------------------------------------------
69
79
 
70
- /** Write a file, creating parent directories as needed. */
71
- const writeWithDirs = async (
72
- filePath: string,
73
- content: string
74
- ): Promise<void> => {
75
- mkdirSync(dirname(filePath), { recursive: true });
76
- await Bun.write(filePath, content);
77
- };
78
-
79
80
  export const addTrail = trail('add.trail', {
80
81
  args: ['id'],
81
82
  blaze: async (input, ctx) => {
82
83
  const { id } = input;
83
- const moduleName = id.replaceAll('.', '-');
84
+ const validated = validateTrailId(id);
85
+ if (validated.isErr()) {
86
+ return Result.err(validated.error);
87
+ }
88
+
89
+ const moduleName = trailIdToModuleName(validated.value);
84
90
  const cwd = resolve(ctx.cwd ?? '.');
85
91
 
86
92
  const files = new Map<string, string>([
@@ -100,7 +106,10 @@ export const addTrail = trail('add.trail', {
100
106
  ]);
101
107
 
102
108
  for (const [relativePath, content] of files) {
103
- await writeWithDirs(join(cwd, relativePath), content);
109
+ const written = await writeProjectFile(cwd, relativePath, content);
110
+ if (written.isErr()) {
111
+ return Result.err(written.error);
112
+ }
104
113
  }
105
114
 
106
115
  return Result.ok({ created: [...files.keys()] });
@@ -118,6 +127,7 @@ export const addTrail = trail('add.trail', {
118
127
  id: z
119
128
  .string()
120
129
  .min(1, 'Trail ID is required')
130
+ .regex(TRAIL_ID_PATTERN, TRAIL_ID_MESSAGE)
121
131
  .describe('Trail ID (e.g., entity.update)'),
122
132
  intent: z
123
133
  .enum(['read', 'write', 'destroy'])