@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.
- package/CHANGELOG.md +208 -0
- package/README.md +27 -0
- package/package.json +19 -8
- package/src/app.ts +17 -7
- package/src/clack.ts +1 -1
- package/src/cli.ts +304 -10
- package/src/completions.ts +240 -0
- package/src/load-app-mirror.ts +160 -0
- package/src/local-state-io.ts +153 -0
- package/src/project-writes.ts +320 -0
- package/src/run-collision.ts +125 -0
- package/src/run-completions-install.ts +179 -0
- package/src/run-example.ts +149 -0
- package/src/run-examples.ts +148 -0
- package/src/run-quiet.ts +75 -0
- package/src/run-trace.ts +273 -0
- package/src/run-warden.ts +39 -0
- package/src/run-watch.ts +432 -0
- package/src/scaffold-versions.generated.ts +12 -0
- package/src/trails/add-surface.ts +172 -0
- package/src/trails/add-trail.ts +73 -27
- package/src/trails/add-verify.ts +68 -23
- package/src/trails/completions-complete.ts +165 -0
- package/src/trails/completions.ts +47 -0
- package/src/trails/create-scaffold.ts +101 -35
- package/src/trails/create.ts +87 -74
- package/src/trails/dev-clean.ts +31 -22
- package/src/trails/dev-reset.ts +9 -3
- package/src/trails/dev-stats.ts +28 -20
- package/src/trails/dev-support.ts +109 -95
- package/src/trails/draft-promote.ts +351 -107
- package/src/trails/guide.ts +55 -38
- package/src/trails/load-app.ts +712 -38
- package/src/trails/root-dir.ts +21 -0
- package/src/trails/run-example.ts +482 -0
- package/src/trails/run-examples.ts +141 -0
- package/src/trails/run.ts +403 -0
- package/src/trails/survey.ts +517 -186
- package/src/trails/topo-activation.ts +385 -0
- package/src/trails/topo-compile.ts +55 -0
- package/src/trails/topo-history.ts +14 -11
- package/src/trails/topo-output-schemas.ts +175 -0
- package/src/trails/topo-pin.ts +25 -16
- package/src/trails/topo-read-support.ts +178 -238
- package/src/trails/topo-reports.ts +445 -63
- package/src/trails/topo-store-support.ts +67 -35
- package/src/trails/topo-support.ts +93 -147
- package/src/trails/topo-unpin.ts +17 -7
- package/src/trails/topo-verify.ts +19 -10
- package/src/trails/topo.ts +64 -31
- package/src/trails/warden-guide.ts +121 -0
- package/src/trails/warden.ts +137 -47
- package/src/versions.ts +28 -0
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/__tests__/examples.test.ts +0 -20
- package/dist/bin/trails.d.ts +0 -3
- package/dist/bin/trails.d.ts.map +0 -1
- package/dist/bin/trails.js +0 -4
- package/dist/bin/trails.js.map +0 -1
- package/dist/src/app.d.ts +0 -2
- package/dist/src/app.d.ts.map +0 -1
- package/dist/src/app.js +0 -22
- package/dist/src/app.js.map +0 -1
- package/dist/src/clack.d.ts +0 -9
- package/dist/src/clack.d.ts.map +0 -1
- package/dist/src/clack.js +0 -84
- package/dist/src/clack.js.map +0 -1
- package/dist/src/cli.d.ts +0 -2
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js +0 -13
- package/dist/src/cli.js.map +0 -1
- package/dist/src/trails/add-surface.d.ts +0 -13
- package/dist/src/trails/add-surface.d.ts.map +0 -1
- package/dist/src/trails/add-surface.js +0 -88
- package/dist/src/trails/add-surface.js.map +0 -1
- package/dist/src/trails/add-trail.d.ts +0 -10
- package/dist/src/trails/add-trail.d.ts.map +0 -1
- package/dist/src/trails/add-trail.js +0 -77
- package/dist/src/trails/add-trail.js.map +0 -1
- package/dist/src/trails/add-trailhead.d.ts +0 -13
- package/dist/src/trails/add-trailhead.d.ts.map +0 -1
- package/dist/src/trails/add-trailhead.js +0 -88
- package/dist/src/trails/add-trailhead.js.map +0 -1
- package/dist/src/trails/add-verify.d.ts +0 -10
- package/dist/src/trails/add-verify.d.ts.map +0 -1
- package/dist/src/trails/add-verify.js +0 -67
- package/dist/src/trails/add-verify.js.map +0 -1
- package/dist/src/trails/create-scaffold.d.ts +0 -15
- package/dist/src/trails/create-scaffold.d.ts.map +0 -1
- package/dist/src/trails/create-scaffold.js +0 -288
- package/dist/src/trails/create-scaffold.js.map +0 -1
- package/dist/src/trails/create.d.ts +0 -22
- package/dist/src/trails/create.d.ts.map +0 -1
- package/dist/src/trails/create.js +0 -121
- package/dist/src/trails/create.js.map +0 -1
- package/dist/src/trails/dev-clean.d.ts +0 -9
- package/dist/src/trails/dev-clean.d.ts.map +0 -1
- package/dist/src/trails/dev-clean.js +0 -65
- package/dist/src/trails/dev-clean.js.map +0 -1
- package/dist/src/trails/dev-reset.d.ts +0 -6
- package/dist/src/trails/dev-reset.d.ts.map +0 -1
- package/dist/src/trails/dev-reset.js +0 -38
- package/dist/src/trails/dev-reset.js.map +0 -1
- package/dist/src/trails/dev-stats.d.ts +0 -7
- package/dist/src/trails/dev-stats.d.ts.map +0 -1
- package/dist/src/trails/dev-stats.js +0 -61
- package/dist/src/trails/dev-stats.js.map +0 -1
- package/dist/src/trails/dev-support.d.ts +0 -64
- package/dist/src/trails/dev-support.d.ts.map +0 -1
- package/dist/src/trails/dev-support.js +0 -178
- package/dist/src/trails/dev-support.js.map +0 -1
- package/dist/src/trails/draft-promote.d.ts +0 -18
- package/dist/src/trails/draft-promote.d.ts.map +0 -1
- package/dist/src/trails/draft-promote.js +0 -386
- package/dist/src/trails/draft-promote.js.map +0 -1
- package/dist/src/trails/guide.d.ts +0 -21
- package/dist/src/trails/guide.d.ts.map +0 -1
- package/dist/src/trails/guide.js +0 -64
- package/dist/src/trails/guide.js.map +0 -1
- package/dist/src/trails/load-app.d.ts +0 -6
- package/dist/src/trails/load-app.d.ts.map +0 -1
- package/dist/src/trails/load-app.js +0 -67
- package/dist/src/trails/load-app.js.map +0 -1
- package/dist/src/trails/project.d.ts +0 -8
- package/dist/src/trails/project.d.ts.map +0 -1
- package/dist/src/trails/project.js +0 -54
- package/dist/src/trails/project.js.map +0 -1
- package/dist/src/trails/survey.d.ts +0 -18
- package/dist/src/trails/survey.d.ts.map +0 -1
- package/dist/src/trails/survey.js +0 -212
- package/dist/src/trails/survey.js.map +0 -1
- package/dist/src/trails/topo-constants.d.ts +0 -3
- package/dist/src/trails/topo-constants.d.ts.map +0 -1
- package/dist/src/trails/topo-constants.js +0 -3
- package/dist/src/trails/topo-constants.js.map +0 -1
- package/dist/src/trails/topo-export.d.ts +0 -18
- package/dist/src/trails/topo-export.d.ts.map +0 -1
- package/dist/src/trails/topo-export.js +0 -34
- package/dist/src/trails/topo-export.js.map +0 -1
- package/dist/src/trails/topo-history.d.ts +0 -24
- package/dist/src/trails/topo-history.d.ts.map +0 -1
- package/dist/src/trails/topo-history.js +0 -33
- package/dist/src/trails/topo-history.js.map +0 -1
- package/dist/src/trails/topo-pin.d.ts +0 -21
- package/dist/src/trails/topo-pin.d.ts.map +0 -1
- package/dist/src/trails/topo-pin.js +0 -35
- package/dist/src/trails/topo-pin.js.map +0 -1
- package/dist/src/trails/topo-read-support.d.ts +0 -54
- package/dist/src/trails/topo-read-support.d.ts.map +0 -1
- package/dist/src/trails/topo-read-support.js +0 -178
- package/dist/src/trails/topo-read-support.js.map +0 -1
- package/dist/src/trails/topo-reports.d.ts +0 -50
- package/dist/src/trails/topo-reports.d.ts.map +0 -1
- package/dist/src/trails/topo-reports.js +0 -122
- package/dist/src/trails/topo-reports.js.map +0 -1
- package/dist/src/trails/topo-show.d.ts +0 -23
- package/dist/src/trails/topo-show.d.ts.map +0 -1
- package/dist/src/trails/topo-show.js +0 -53
- package/dist/src/trails/topo-show.js.map +0 -1
- package/dist/src/trails/topo-store-support.d.ts +0 -13
- package/dist/src/trails/topo-store-support.d.ts.map +0 -1
- package/dist/src/trails/topo-store-support.js +0 -55
- package/dist/src/trails/topo-store-support.js.map +0 -1
- package/dist/src/trails/topo-support.d.ts +0 -87
- package/dist/src/trails/topo-support.d.ts.map +0 -1
- package/dist/src/trails/topo-support.js +0 -165
- package/dist/src/trails/topo-support.js.map +0 -1
- package/dist/src/trails/topo-unpin.d.ts +0 -15
- package/dist/src/trails/topo-unpin.d.ts.map +0 -1
- package/dist/src/trails/topo-unpin.js +0 -39
- package/dist/src/trails/topo-unpin.js.map +0 -1
- package/dist/src/trails/topo-verify.d.ts +0 -5
- package/dist/src/trails/topo-verify.d.ts.map +0 -1
- package/dist/src/trails/topo-verify.js +0 -28
- package/dist/src/trails/topo-verify.js.map +0 -1
- package/dist/src/trails/topo.d.ts +0 -5
- package/dist/src/trails/topo.d.ts.map +0 -1
- package/dist/src/trails/topo.js +0 -67
- package/dist/src/trails/topo.js.map +0 -1
- package/dist/src/trails/warden.d.ts +0 -19
- package/dist/src/trails/warden.d.ts.map +0 -1
- package/dist/src/trails/warden.js +0 -89
- package/dist/src/trails/warden.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/__tests__/create.test.ts +0 -351
- package/src/__tests__/draft-promote.test.ts +0 -144
- package/src/__tests__/guide.test.ts +0 -91
- package/src/__tests__/load-app.test.ts +0 -58
- package/src/__tests__/survey.test.ts +0 -301
- package/src/__tests__/topo-dev.test.ts +0 -424
- package/src/__tests__/warden.test.ts +0 -74
- package/src/trails/add-trailhead.ts +0 -121
- package/src/trails/topo-export.ts +0 -39
- package/src/trails/topo-show.ts +0 -58
- package/tsconfig.json +0 -9
package/src/run-watch.ts
ADDED
|
@@ -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
|
+
});
|