@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
@@ -1,11 +1,141 @@
1
- import { existsSync, rmSync } from 'node:fs';
2
- import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
1
+ import {
2
+ existsSync,
3
+ readdirSync,
4
+ readFileSync,
5
+ realpathSync,
6
+ statSync,
7
+ } from 'node:fs';
8
+ import {
9
+ dirname,
10
+ extname,
11
+ isAbsolute,
12
+ join,
13
+ relative,
14
+ resolve,
15
+ } from 'node:path';
3
16
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
17
 
18
+ import {
19
+ deriveSafePath,
20
+ InternalError,
21
+ isTrailsError,
22
+ PermissionError,
23
+ Result,
24
+ ValidationError,
25
+ } from '@ontrails/core';
5
26
  import type { Topo } from '@ontrails/core';
27
+ import { findAppModule } from '@ontrails/cli';
28
+
29
+ import {
30
+ createLoadAppMirrorRootPath,
31
+ LOAD_APP_MIRROR_ENTRY_PREFIX,
32
+ LOAD_APP_MIRROR_PARENT_DIRNAME,
33
+ removeLoadAppMirrorRootQuietly,
34
+ resolveLoadAppMirrorFilePath,
35
+ writeLoadAppMirrorFile,
36
+ } from '../load-app-mirror.js';
6
37
 
7
38
  const URL_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
8
39
 
40
+ type TranspilerLoader = 'ts' | 'tsx' | 'js' | 'jsx';
41
+
42
+ /** Extension → Bun.Transpiler loader, so JSX-bearing files parse correctly. */
43
+ const LOADER_BY_EXTENSION: Record<string, TranspilerLoader> = {
44
+ '.cjs': 'js',
45
+ '.cts': 'ts',
46
+ '.js': 'js',
47
+ '.jsx': 'jsx',
48
+ '.mjs': 'js',
49
+ '.mts': 'ts',
50
+ '.ts': 'ts',
51
+ '.tsx': 'tsx',
52
+ };
53
+
54
+ const SCANNABLE_EXTENSIONS = new Set(Object.keys(LOADER_BY_EXTENSION));
55
+
56
+ const TRANSPILER_CACHE = new Map<TranspilerLoader, Bun.Transpiler>();
57
+
58
+ const getImportScanner = (loader: TranspilerLoader): Bun.Transpiler => {
59
+ const cached = TRANSPILER_CACHE.get(loader);
60
+ if (cached !== undefined) {
61
+ return cached;
62
+ }
63
+ const scanner = new Bun.Transpiler({ loader });
64
+ TRANSPILER_CACHE.set(loader, scanner);
65
+ return scanner;
66
+ };
67
+
68
+ /**
69
+ * Mirror roots kept alive for the lifetime of the process.
70
+ *
71
+ * @remarks
72
+ * A fresh-loaded module may expose functions whose deferred relative imports
73
+ * are resolved only when those functions run (for example inside a trail's
74
+ * `blaze`). If we deleted the mirror tree immediately after the initial
75
+ * `import()` resolved, those later resolutions would hit an ENOENT. We keep
76
+ * the mirrors on disk and clean them up once, on process exit.
77
+ */
78
+ const ACTIVE_MIRROR_ROOTS = new Set<string>();
79
+ const RETAINED_MIRROR_ROOTS = new Set<string>();
80
+
81
+ const cleanupAllMirrorRoots = (): void => {
82
+ for (const root of [...ACTIVE_MIRROR_ROOTS, ...RETAINED_MIRROR_ROOTS]) {
83
+ removeLoadAppMirrorRootQuietly(root);
84
+ }
85
+ ACTIVE_MIRROR_ROOTS.clear();
86
+ RETAINED_MIRROR_ROOTS.clear();
87
+ };
88
+
89
+ const mirrorCleanup = (() => {
90
+ let registered = false;
91
+ return {
92
+ ensureRegistered(): void {
93
+ if (registered) {
94
+ return;
95
+ }
96
+ registered = true;
97
+ process.once('exit', cleanupAllMirrorRoots);
98
+ },
99
+ };
100
+ })();
101
+
102
+ const ensureMirrorCleanupHook = (): void => {
103
+ mirrorCleanup.ensureRegistered();
104
+ };
105
+
106
+ /**
107
+ * Retain a fresh-import mirror for the lifetime of the process. A previously
108
+ * returned `loadApp` result may hold deferred relative `import()` calls whose
109
+ * resolution requires the mirror directory to still exist, so we cannot prune
110
+ * these by age without risking ENOENT in long-lived sessions (dev server,
111
+ * survey polling, concurrent fresh loads). Cleanup happens once on process
112
+ * exit via `cleanupAllMirrorRoots`.
113
+ */
114
+ const retainMirrorRoot = (mirrorRoot: string): void => {
115
+ if (RETAINED_MIRROR_ROOTS.has(mirrorRoot)) {
116
+ return;
117
+ }
118
+ RETAINED_MIRROR_ROOTS.add(mirrorRoot);
119
+ ensureMirrorCleanupHook();
120
+ };
121
+
122
+ const acquireMirrorLease = (mirrorRoot: string): (() => void) => {
123
+ ACTIVE_MIRROR_ROOTS.add(mirrorRoot);
124
+ ensureMirrorCleanupHook();
125
+
126
+ let released = false;
127
+
128
+ return () => {
129
+ if (released) {
130
+ return;
131
+ }
132
+
133
+ released = true;
134
+ ACTIVE_MIRROR_ROOTS.delete(mirrorRoot);
135
+ removeLoadAppMirrorRootQuietly(mirrorRoot);
136
+ };
137
+ };
138
+
9
139
  const resolveUrlModulePath = (modulePath: string): string => {
10
140
  const url = new URL(modulePath);
11
141
  return url.protocol === 'file:' ? fileURLToPath(url) : modulePath;
@@ -26,75 +156,619 @@ const resolveFilesystemModulePath = (
26
156
  return existsSync(tsPath) ? tsPath : absolutePath;
27
157
  };
28
158
 
29
- /** Resolve a module path from cwd so CLI defaults behave like shell paths. */
30
- const resolveAbsoluteModulePath = (modulePath: string, cwd: string): string =>
159
+ const trustBoundaryError = (reason: string): PermissionError =>
160
+ new PermissionError(
161
+ `Refusing to load an app module outside the workspace trust boundary (${reason}). Use a workspace-relative module path, or pass trustedModulePath: true from trusted code.`
162
+ );
163
+
164
+ const toLoadAppError = (error: unknown): Error =>
165
+ isTrailsError(error)
166
+ ? error
167
+ : new InternalError('Failed to load app module', {
168
+ cause: error instanceof Error ? error : new Error(String(error)),
169
+ });
170
+
171
+ const isPathInside = (root: string, target: string): boolean => {
172
+ const candidate = relative(root, target);
173
+ return (
174
+ candidate === '' || (!candidate.startsWith('..') && !isAbsolute(candidate))
175
+ );
176
+ };
177
+
178
+ const realpathIfPresent = (path: string): string | undefined => {
179
+ try {
180
+ return realpathSync(path);
181
+ } catch {
182
+ return undefined;
183
+ }
184
+ };
185
+
186
+ const ensureRealPathInsideCwd = (
187
+ resolvedModulePath: string,
188
+ cwd: string
189
+ ): string => {
190
+ const realRoot = realpathIfPresent(cwd);
191
+ const realModule = realpathIfPresent(resolvedModulePath);
192
+ if (
193
+ realRoot !== undefined &&
194
+ realModule !== undefined &&
195
+ !isPathInside(realRoot, realModule)
196
+ ) {
197
+ throw trustBoundaryError('symlink_escape');
198
+ }
199
+
200
+ return resolvedModulePath;
201
+ };
202
+
203
+ /** Resolve a caller-trusted module path using the legacy escape hatch policy. */
204
+ const resolveTrustedModulePath = (modulePath: string, cwd: string): string =>
31
205
  URL_SCHEME.test(modulePath)
32
206
  ? resolveUrlModulePath(modulePath)
33
207
  : resolveFilesystemModulePath(modulePath, cwd);
34
208
 
35
- const freshModuleCopyPath = (absolutePath: string): string =>
36
- join(
37
- dirname(absolutePath),
38
- `.__fresh-${Date.now()}-${Math.random().toString(36).slice(2)}-${basename(absolutePath)}`
209
+ /**
210
+ * Resolve the default app module path inside cwd.
211
+ *
212
+ * @remarks
213
+ * CLI and trail callers accept user-supplied module specifiers, so the default
214
+ * policy is deliberately narrower than `import()` itself: no URL schemes, no
215
+ * absolute paths, and no parent traversal. Internal callers that intentionally
216
+ * load a path outside cwd must opt into `trustedModulePath`.
217
+ */
218
+ const resolveContainedModulePath = (
219
+ modulePath: string,
220
+ cwd: string
221
+ ): string => {
222
+ if (URL_SCHEME.test(modulePath)) {
223
+ throw trustBoundaryError('url_scheme');
224
+ }
225
+ if (isAbsolute(modulePath)) {
226
+ throw trustBoundaryError('absolute_path');
227
+ }
228
+
229
+ const safePath = deriveSafePath(cwd, modulePath);
230
+ if (safePath.isErr()) {
231
+ throw trustBoundaryError('parent_escape');
232
+ }
233
+
234
+ return ensureRealPathInsideCwd(
235
+ resolveFilesystemModulePath(safePath.value, cwd),
236
+ cwd
39
237
  );
238
+ };
239
+
240
+ interface LoadAppTrustOptions {
241
+ readonly trustedModulePath?: boolean | undefined;
242
+ }
243
+
244
+ const resolveLoadAppModulePath = (
245
+ modulePath: string,
246
+ cwd: string,
247
+ options: LoadAppTrustOptions = {}
248
+ ): string =>
249
+ options.trustedModulePath === true
250
+ ? resolveTrustedModulePath(modulePath, cwd)
251
+ : resolveContainedModulePath(modulePath, cwd);
252
+
253
+ const findWorkspaceRelativeAppModule = (cwd: string): string => {
254
+ const discovered = findAppModule(cwd);
255
+ return isAbsolute(discovered) ? relative(cwd, discovered) : discovered;
256
+ };
257
+
258
+ const isLocalFilesystemImport = (importPath: string): boolean =>
259
+ importPath.startsWith('.') ||
260
+ importPath.startsWith('/') ||
261
+ importPath.startsWith('file:');
262
+
263
+ const readPackageName = (packagePath: string): string | undefined => {
264
+ try {
265
+ const parsed = JSON.parse(readFileSync(packagePath, 'utf8')) as
266
+ | { readonly name?: unknown }
267
+ | undefined;
268
+ return typeof parsed?.name === 'string' && parsed.name.length > 0
269
+ ? parsed.name
270
+ : undefined;
271
+ } catch {
272
+ return undefined;
273
+ }
274
+ };
275
+
276
+ const findNearestPackageName = (directoryPath: string): string | undefined => {
277
+ let current = directoryPath;
278
+ while (true) {
279
+ const name = readPackageName(join(current, 'package.json'));
280
+ if (name !== undefined) {
281
+ return name;
282
+ }
283
+
284
+ const parent = dirname(current);
285
+ if (parent === current) {
286
+ return undefined;
287
+ }
288
+ current = parent;
289
+ }
290
+ };
291
+
292
+ const isPackageLocalImport = (
293
+ importerPath: string,
294
+ importPath: string
295
+ ): boolean => {
296
+ if (importPath.startsWith('#')) {
297
+ return true;
298
+ }
299
+
300
+ const packageName = findNearestPackageName(dirname(importerPath));
301
+ return (
302
+ packageName !== undefined &&
303
+ (importPath === packageName || importPath.startsWith(`${packageName}/`))
304
+ );
305
+ };
306
+
307
+ const shouldMirrorImportSpecifier = (
308
+ importerPath: string,
309
+ importPath: string
310
+ ): boolean =>
311
+ isLocalFilesystemImport(importPath) ||
312
+ isPackageLocalImport(importerPath, importPath);
313
+
314
+ const isScannableModule = (modulePath: string): boolean =>
315
+ SCANNABLE_EXTENSIONS.has(extname(modulePath));
316
+
317
+ const resolveImportedModulePath = (
318
+ importerPath: string,
319
+ importPath: string
320
+ ): string => {
321
+ const resolved = import.meta.resolve(
322
+ importPath,
323
+ pathToFileURL(importerPath).href
324
+ );
325
+ return resolveFilesystemModulePath(
326
+ fileURLToPath(resolved),
327
+ dirname(importerPath)
328
+ );
329
+ };
330
+
331
+ const collectImportedModulePaths = (
332
+ modulePath: string,
333
+ source: string
334
+ ): readonly string[] => {
335
+ const extension = extname(modulePath);
336
+ const loader = LOADER_BY_EXTENSION[extension];
337
+ if (loader === undefined) {
338
+ return [];
339
+ }
340
+
341
+ return getImportScanner(loader)
342
+ .scanImports(source)
343
+ .map((entry) => entry.path)
344
+ .filter((importPath) => shouldMirrorImportSpecifier(modulePath, importPath))
345
+ .map((importPath) => resolveImportedModulePath(modulePath, importPath));
346
+ };
347
+
348
+ const copyFileToMirror = async (
349
+ sourcePath: string,
350
+ mirrorRoot: string,
351
+ copied: Set<string>
352
+ ): Promise<void> => {
353
+ if (copied.has(sourcePath)) {
354
+ return;
355
+ }
356
+ copied.add(sourcePath);
357
+
358
+ const written = await writeLoadAppMirrorFile(sourcePath, mirrorRoot);
359
+ if (written.isErr()) {
360
+ throw written.error;
361
+ }
362
+ };
40
363
 
41
364
  /**
42
- * Import a module bypassing the ESM cache for the entry file.
365
+ * Directory basenames that are never worth mirroring.
43
366
  *
44
367
  * @remarks
45
- * Cache-busting applies to the entry module only. Transitive imports resolved
46
- * by the entry file are still served from Bun's module cache. This is
47
- * acceptable for the draft promotion workflow (the only caller) because
48
- * promotion changes which modules the entry file imports, not the modules
49
- * themselves. If a deeper cache-bust is needed in the future, consider
50
- * Bun's `Loader.registry` or a full process restart.
368
+ * These directories are excluded because they can be large and are never
369
+ * sources of resolvable imports they hold VCS metadata, package installs,
370
+ * prior mirror artifacts, or build/tooling output that module resolution
371
+ * should not touch.
51
372
  */
52
- const importFreshModule = async (
373
+ const MIRROR_SKIP_DIRECTORIES = new Set([
374
+ '.cache',
375
+ '.git',
376
+ '.next',
377
+ '.nuxt',
378
+ '.output',
379
+ '.svelte-kit',
380
+ '.trails-tmp',
381
+ '.turbo',
382
+ 'build',
383
+ 'coverage',
384
+ 'dist',
385
+ 'node_modules',
386
+ ]);
387
+
388
+ /**
389
+ * Recursively copy every regular file inside `directoryPath` into the
390
+ * mirror, skipping well-known heavy directories.
391
+ *
392
+ * @remarks
393
+ * `Bun.Transpiler#scanImports` only surfaces statically analyzable import
394
+ * specifiers. Computed dynamic imports such as `import(\`./${name}.ts\`)`
395
+ * never appear, so their targets would otherwise be missing from the
396
+ * mirror. Shadowing each directory touched by the static walk with its
397
+ * full subtree keeps those sibling modules resolvable under the mirror
398
+ * root at runtime without pulling in package installs or nested mirror
399
+ * artifacts.
400
+ */
401
+ const readDirectoryEntries = (directoryPath: string): readonly string[] => {
402
+ try {
403
+ return readdirSync(directoryPath);
404
+ } catch {
405
+ return [];
406
+ }
407
+ };
408
+
409
+ const safeStat = (
410
+ entryPath: string
411
+ ): ReturnType<typeof statSync> | undefined => {
412
+ try {
413
+ return statSync(entryPath);
414
+ } catch {
415
+ return undefined;
416
+ }
417
+ };
418
+
419
+ /**
420
+ * Age threshold (ms) above which a mirror entry in `.trails-tmp/` is
421
+ * considered stale and safe to remove opportunistically.
422
+ *
423
+ * @remarks
424
+ * Fresh loads complete in seconds. Anything older than 10 minutes is almost
425
+ * certainly left over from a crashed or signal-killed process. We intentionally
426
+ * avoid registering SIGTERM/SIGINT handlers here because that would risk
427
+ * clobbering host-app signal handlers (and still wouldn't rescue SIGKILL).
428
+ * Opportunistic cleanup is self-healing across crashes from any cause.
429
+ */
430
+ const STALE_MIRROR_THRESHOLD_MS = 10 * 60 * 1000;
431
+
432
+ const isStaleMirrorEntry = (entryPath: string, now: number): boolean => {
433
+ if (
434
+ ACTIVE_MIRROR_ROOTS.has(entryPath) ||
435
+ RETAINED_MIRROR_ROOTS.has(entryPath)
436
+ ) {
437
+ return false;
438
+ }
439
+ const entryStat = safeStat(entryPath);
440
+ if (entryStat === undefined) {
441
+ return false;
442
+ }
443
+ const mtimeMs = Number(entryStat.mtimeMs);
444
+ return now - mtimeMs >= STALE_MIRROR_THRESHOLD_MS;
445
+ };
446
+
447
+ const removeStaleMirrorEntry = (entryPath: string): void => {
448
+ /*
449
+ * Another concurrent load may own it. Safe to ignore — the next sweep
450
+ * will retry.
451
+ */
452
+ removeLoadAppMirrorRootQuietly(entryPath);
453
+ };
454
+
455
+ /**
456
+ * Best-effort removal of stale mirror directories left by previous (crashed or
457
+ * signal-killed) processes. Called before creating a new mirror root.
458
+ */
459
+ const cleanupStaleMirrorRoots = (mirrorParent: string): void => {
460
+ const entries = readDirectoryEntries(mirrorParent);
461
+ if (entries.length === 0) {
462
+ return;
463
+ }
464
+ const now = Date.now();
465
+ for (const entry of entries) {
466
+ if (!entry.startsWith(LOAD_APP_MIRROR_ENTRY_PREFIX)) {
467
+ continue;
468
+ }
469
+ const entryPath = join(mirrorParent, entry);
470
+ if (isStaleMirrorEntry(entryPath, now)) {
471
+ removeStaleMirrorEntry(entryPath);
472
+ }
473
+ }
474
+ };
475
+
476
+ const freshMirrorRootPath = (cwd: string): string => {
477
+ const mirrorParent = join(cwd, LOAD_APP_MIRROR_PARENT_DIRNAME);
478
+ cleanupStaleMirrorRoots(mirrorParent);
479
+ return createLoadAppMirrorRootPath(cwd);
480
+ };
481
+
482
+ interface MirrorWalkContext {
483
+ readonly mirrorRoot: string;
484
+ readonly copied: Set<string>;
485
+ readonly visitedDirectories: Set<string>;
486
+ }
487
+
488
+ type DirectoryEntryKind = 'directory' | 'file' | 'skip';
489
+
490
+ const classifyDirectoryEntry = (
491
+ entry: string,
492
+ entryPath: string
493
+ ): DirectoryEntryKind => {
494
+ const entryStat = safeStat(entryPath);
495
+ if (entryStat === undefined) {
496
+ return 'skip';
497
+ }
498
+ if (entryStat.isDirectory()) {
499
+ return MIRROR_SKIP_DIRECTORIES.has(entry) ? 'skip' : 'directory';
500
+ }
501
+ return entryStat.isFile() ? 'file' : 'skip';
502
+ };
503
+
504
+ const copyDirectoryTreeToMirror = async (
505
+ directoryPath: string,
506
+ context: MirrorWalkContext
507
+ ): Promise<void> => {
508
+ if (context.visitedDirectories.has(directoryPath)) {
509
+ return;
510
+ }
511
+ context.visitedDirectories.add(directoryPath);
512
+
513
+ for (const entry of readDirectoryEntries(directoryPath)) {
514
+ const entryPath = join(directoryPath, entry);
515
+ const kind = classifyDirectoryEntry(entry, entryPath);
516
+ if (kind === 'directory') {
517
+ await copyDirectoryTreeToMirror(entryPath, context);
518
+ } else if (kind === 'file') {
519
+ await copyFileToMirror(entryPath, context.mirrorRoot, context.copied);
520
+ }
521
+ }
522
+ };
523
+
524
+ const copyNearestPackageJsonToMirror = async (
525
+ directoryPath: string,
526
+ context: MirrorWalkContext
527
+ ): Promise<void> => {
528
+ let current = directoryPath;
529
+ while (true) {
530
+ const packagePath = join(current, 'package.json');
531
+ const packageStat = safeStat(packagePath);
532
+ if (packageStat?.isFile()) {
533
+ await copyFileToMirror(packagePath, context.mirrorRoot, context.copied);
534
+ return;
535
+ }
536
+
537
+ const parent = dirname(current);
538
+ if (parent === current) {
539
+ return;
540
+ }
541
+ current = parent;
542
+ }
543
+ };
544
+
545
+ const mirrorImportedModule = async (
546
+ modulePath: string,
547
+ context: MirrorWalkContext
548
+ ): Promise<void> => {
549
+ const moduleDirectory = dirname(modulePath);
550
+ await copyNearestPackageJsonToMirror(moduleDirectory, context);
551
+ if (context.visitedDirectories.has(moduleDirectory)) {
552
+ await copyFileToMirror(modulePath, context.mirrorRoot, context.copied);
553
+ return;
554
+ }
555
+ await copyDirectoryTreeToMirror(moduleDirectory, context);
556
+ };
557
+
558
+ const scanAndVisitLocalImports = async (
53
559
  modulePath: string,
560
+ visit: (path: string) => Promise<void>
561
+ ): Promise<void> => {
562
+ if (!isScannableModule(modulePath)) {
563
+ return;
564
+ }
565
+ const source = await Bun.file(modulePath).text();
566
+ for (const importedPath of collectImportedModulePaths(modulePath, source)) {
567
+ await visit(importedPath);
568
+ }
569
+ };
570
+
571
+ const mirrorFreshImportGraph = async (
572
+ entryPath: string,
573
+ mirrorRoot: string
574
+ ): Promise<string> => {
575
+ const scanned = new Set<string>();
576
+ const context: MirrorWalkContext = {
577
+ copied: new Set<string>(),
578
+ mirrorRoot,
579
+ visitedDirectories: new Set<string>(),
580
+ };
581
+
582
+ const visit = async (modulePath: string): Promise<void> => {
583
+ if (scanned.has(modulePath)) {
584
+ return;
585
+ }
586
+ scanned.add(modulePath);
587
+ await scanAndVisitLocalImports(modulePath, visit);
588
+ await mirrorImportedModule(modulePath, context);
589
+ };
590
+
591
+ await visit(entryPath);
592
+ const freshPath = resolveLoadAppMirrorFilePath(entryPath, mirrorRoot);
593
+ if (freshPath.isErr()) {
594
+ throw freshPath.error;
595
+ }
596
+ return freshPath.value;
597
+ };
598
+
599
+ /**
600
+ * Import a module bypassing the ESM cache for the local filesystem import graph.
601
+ *
602
+ * @remarks
603
+ * External packages and built-in modules still resolve normally. Only local
604
+ * filesystem imports are mirrored into the fresh temp root. The mirror tree
605
+ * is retained for the lifetime of the process so that deferred relative
606
+ * `import()`/`require()` calls originating from the loaded module (e.g.
607
+ * inside a trail's `blaze`) can still resolve. If the graph walk itself
608
+ * fails, the partially-written mirror is removed immediately so failed
609
+ * loads do not leak disk space.
610
+ */
611
+ const importWithCacheBust = async (
612
+ absolutePath: string
613
+ ): Promise<Record<string, unknown>> => {
614
+ const url = new URL(absolutePath);
615
+ url.searchParams.set('t', Date.now().toString());
616
+ return (await import(url.href)) as Record<string, unknown>;
617
+ };
618
+
619
+ const prepareMirror = async (
620
+ absolutePath: string,
621
+ cwd: string
622
+ ): Promise<{ mirrorRoot: string; freshPath: string }> => {
623
+ const mirrorRoot = freshMirrorRootPath(cwd);
624
+ try {
625
+ const freshPath = await mirrorFreshImportGraph(absolutePath, mirrorRoot);
626
+ return { freshPath, mirrorRoot };
627
+ } catch (error) {
628
+ removeLoadAppMirrorRootQuietly(mirrorRoot);
629
+ throw error;
630
+ }
631
+ };
632
+
633
+ const importFreshModule = async (
634
+ resolvedModulePath: string,
54
635
  cwd: string
55
636
  ): Promise<Record<string, unknown>> => {
56
- const absolutePath = resolveAbsoluteModulePath(modulePath, cwd);
637
+ const absolutePath = resolvedModulePath;
57
638
  if (URL_SCHEME.test(absolutePath) && !absolutePath.startsWith('/')) {
58
- const url = new URL(absolutePath);
59
- url.searchParams.set('t', Date.now().toString());
60
- return (await import(url.href)) as Record<string, unknown>;
639
+ return await importWithCacheBust(absolutePath);
61
640
  }
62
641
 
63
- const freshPath = freshModuleCopyPath(absolutePath);
64
- await Bun.write(freshPath, await Bun.file(absolutePath).text());
642
+ const { mirrorRoot, freshPath } = await prepareMirror(absolutePath, cwd);
643
+ retainMirrorRoot(mirrorRoot);
644
+ return (await import(pathToFileURL(freshPath).href)) as Record<
645
+ string,
646
+ unknown
647
+ >;
648
+ };
649
+
650
+ const resolveLoadedTopo = (
651
+ effectivePath: string,
652
+ mod: Record<string, unknown>
653
+ ): Topo => {
654
+ const app = (mod['default'] ?? mod['graph'] ?? mod['app']) as
655
+ | Topo
656
+ | undefined;
657
+ if (!app?.trails) {
658
+ throw new ValidationError(
659
+ `Could not find a Topo export in "${effectivePath}". ` +
660
+ "Expected a default, 'graph', or 'app' named export created with topo()."
661
+ );
662
+ }
663
+ return app;
664
+ };
665
+
666
+ export interface FreshAppLease {
667
+ readonly app: Topo;
668
+ readonly mirrorRoot: string;
669
+ readonly release: () => void;
670
+ }
671
+
672
+ export type LoadAppLeaseOptions = LoadAppTrustOptions;
673
+
674
+ export interface LoadAppOptions extends LoadAppTrustOptions {
675
+ readonly fresh?: boolean | undefined;
676
+ }
677
+
678
+ const noopRelease = (): void => undefined;
679
+
680
+ const createUrlSchemeLease = async (
681
+ absolutePath: string,
682
+ effectivePath: string
683
+ ): Promise<FreshAppLease> => ({
684
+ app: resolveLoadedTopo(
685
+ effectivePath,
686
+ await importWithCacheBust(absolutePath)
687
+ ),
688
+ mirrorRoot: absolutePath,
689
+ release: noopRelease,
690
+ });
691
+
692
+ const createFilesystemLease = async (
693
+ absolutePath: string,
694
+ cwd: string,
695
+ effectivePath: string
696
+ ): Promise<FreshAppLease> => {
697
+ const { mirrorRoot, freshPath } = await prepareMirror(absolutePath, cwd);
698
+ const release = acquireMirrorLease(mirrorRoot);
65
699
 
66
700
  try {
67
- return (await import(pathToFileURL(freshPath).href)) as Record<
701
+ const mod = (await import(pathToFileURL(freshPath).href)) as Record<
68
702
  string,
69
703
  unknown
70
704
  >;
71
- } finally {
72
- rmSync(freshPath, { force: true });
705
+
706
+ return {
707
+ app: resolveLoadedTopo(effectivePath, mod),
708
+ mirrorRoot,
709
+ release,
710
+ };
711
+ } catch (error) {
712
+ release();
713
+ throw error;
73
714
  }
74
715
  };
75
716
 
76
- /** Load a Topo export from a module path relative to cwd. */
717
+ export const loadFreshAppLease = async (
718
+ modulePath: string | undefined,
719
+ cwd: string,
720
+ options: LoadAppLeaseOptions = {}
721
+ ): Promise<FreshAppLease> => {
722
+ const effectivePath =
723
+ modulePath === undefined ? findWorkspaceRelativeAppModule(cwd) : modulePath;
724
+ const absolutePath = resolveLoadAppModulePath(effectivePath, cwd, options);
725
+
726
+ return URL_SCHEME.test(absolutePath) && !absolutePath.startsWith('/')
727
+ ? await createUrlSchemeLease(absolutePath, effectivePath)
728
+ : await createFilesystemLease(absolutePath, cwd, effectivePath);
729
+ };
730
+
731
+ export const tryLoadFreshAppLease = async (
732
+ modulePath: string | undefined,
733
+ cwd: string,
734
+ options: LoadAppLeaseOptions = {}
735
+ ): Promise<Result<FreshAppLease, Error>> => {
736
+ try {
737
+ return Result.ok(await loadFreshAppLease(modulePath, cwd, options));
738
+ } catch (error) {
739
+ return Result.err(toLoadAppError(error));
740
+ }
741
+ };
742
+
743
+ /**
744
+ * Load a Topo export from a module path relative to cwd.
745
+ *
746
+ * @remarks
747
+ * By default, `modulePath` must be workspace-relative and stay under `cwd`.
748
+ * URL-shaped, absolute, and parent-escape paths are rejected. Trusted internal
749
+ * callers can pass `trustedModulePath: true` to deliberately use the broader
750
+ * dynamic-import escape hatch.
751
+ */
77
752
  export const loadApp = async (
78
- modulePath: string,
753
+ modulePath: string | undefined,
79
754
  cwd: string,
80
- options: { fresh?: boolean | undefined } = {}
755
+ options: LoadAppOptions = {}
81
756
  ): Promise<Topo> => {
82
- const resolvedModulePath = resolveAbsoluteModulePath(modulePath, cwd);
757
+ const effectivePath =
758
+ modulePath === undefined ? findWorkspaceRelativeAppModule(cwd) : modulePath;
759
+ const resolvedModulePath = resolveLoadAppModulePath(
760
+ effectivePath,
761
+ cwd,
762
+ options
763
+ );
83
764
  const mod =
84
765
  options.fresh === true
85
- ? await importFreshModule(modulePath, cwd)
766
+ ? await importFreshModule(resolvedModulePath, cwd)
86
767
  : ((await import(
87
768
  URL_SCHEME.test(resolvedModulePath) &&
88
769
  !resolvedModulePath.startsWith('/')
89
770
  ? new URL(resolvedModulePath).href
90
771
  : pathToFileURL(resolvedModulePath).href
91
772
  )) as Record<string, unknown>);
92
- const app = (mod['default'] ?? mod['app']) as Topo | undefined;
93
- if (!app?.trails) {
94
- throw new Error(
95
- `Could not find a Topo export in "${modulePath}". ` +
96
- "Expected a default or named 'app' export created with topo()."
97
- );
98
- }
99
- return app;
773
+ return resolveLoadedTopo(effectivePath, mod);
100
774
  };