@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
@@ -7,11 +7,15 @@
7
7
  import { NotFoundError, Result, trail } from '@ontrails/core';
8
8
  import { z } from 'zod';
9
9
 
10
- import { loadApp } from './load-app.js';
10
+ import { tryLoadFreshAppLease } from './load-app.js';
11
+ import { trailDetailOutput } from './topo-output-schemas.js';
11
12
  import {
12
13
  buildCurrentGuideEntries,
13
14
  buildCurrentTopoDetail,
15
+ readSurfaceLayerNamesFromContext,
14
16
  } from './topo-read-support.js';
17
+ import { createIsolatedExampleInput } from './topo-support.js';
18
+ import { resolveTrailRootDir } from './root-dir.js';
15
19
 
16
20
  // ---------------------------------------------------------------------------
17
21
  // Types
@@ -21,7 +25,7 @@ interface GuideEntry {
21
25
  readonly description: string;
22
26
  readonly exampleCount: number;
23
27
  readonly id: string;
24
- readonly kind: string;
28
+ readonly kind: 'trail';
25
29
  }
26
30
 
27
31
  // ---------------------------------------------------------------------------
@@ -30,57 +34,73 @@ interface GuideEntry {
30
34
 
31
35
  export const guideTrail = trail('guide', {
32
36
  blaze: async (input, ctx) => {
33
- const rootDir = ctx.cwd ?? '.';
34
- const app = await loadApp(input.module, rootDir);
37
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
38
+ if (rootDirResult.isErr()) {
39
+ return Result.err(rootDirResult.error);
40
+ }
41
+ const rootDir = rootDirResult.value;
42
+ const leaseResult = await tryLoadFreshAppLease(input.module, rootDir);
43
+ if (leaseResult.isErr()) {
44
+ return Result.err(leaseResult.error);
45
+ }
46
+ const lease = leaseResult.value;
35
47
 
36
- if (input.trailId) {
37
- const detail = buildCurrentTopoDetail(app, input.trailId, { rootDir });
38
- if (detail === undefined || detail.kind !== 'trail') {
39
- return Result.err(
40
- new NotFoundError(`Trail not found: ${input.trailId}`)
41
- );
48
+ try {
49
+ if (input.trailId) {
50
+ const detail = buildCurrentTopoDetail(lease.app, input.trailId, {
51
+ rootDir,
52
+ surfaceLayerNames: readSurfaceLayerNamesFromContext(ctx),
53
+ });
54
+ if (detail === undefined || detail.kind !== 'trail') {
55
+ return Result.err(
56
+ new NotFoundError(`Trail not found: ${input.trailId}`)
57
+ );
58
+ }
59
+ return Result.ok({
60
+ detail,
61
+ mode: 'detail' as const,
62
+ });
42
63
  }
64
+
43
65
  return Result.ok({
44
- description: detail.description,
45
- detours: detail.detours,
46
- examples: detail.examples,
47
- id: detail.id,
48
- kind: detail.kind,
66
+ entries: buildCurrentGuideEntries(lease.app, {
67
+ rootDir,
68
+ }) as GuideEntry[],
69
+ mode: 'list' as const,
49
70
  });
71
+ } finally {
72
+ lease.release();
50
73
  }
51
-
52
- return Result.ok(
53
- buildCurrentGuideEntries(app, { rootDir }) as GuideEntry[]
54
- );
55
74
  },
56
75
  description: 'Runtime guidance for trails',
57
76
  examples: [
58
77
  {
59
78
  description: 'Lists all trails with descriptions and example counts',
60
- input: { module: './src/app.ts' },
79
+ input: createIsolatedExampleInput('guide-list'),
61
80
  name: 'List trail guidance',
62
81
  },
63
82
  ],
64
83
  input: z.object({
65
84
  module: z.string().optional().describe('Path to the app module'),
85
+ rootDir: z.string().optional().describe('Workspace root directory'),
66
86
  trailId: z.string().optional().describe('Trail ID for detailed guidance'),
67
87
  }),
68
88
  intent: 'read',
69
- output: z.union([
70
- z.array(
71
- z.object({
72
- description: z.string(),
73
- exampleCount: z.number(),
74
- id: z.string(),
75
- kind: z.string(),
76
- })
77
- ),
89
+ output: z.discriminatedUnion('mode', [
90
+ z.object({
91
+ entries: z.array(
92
+ z.object({
93
+ description: z.string(),
94
+ exampleCount: z.number(),
95
+ id: z.string(),
96
+ kind: z.literal('trail'),
97
+ })
98
+ ),
99
+ mode: z.literal('list'),
100
+ }),
78
101
  z.object({
79
- description: z.string().nullable(),
80
- detours: z.unknown().nullable(),
81
- examples: z.array(z.unknown()),
82
- id: z.string(),
83
- kind: z.string(),
102
+ detail: trailDetailOutput,
103
+ mode: z.literal('detail'),
84
104
  }),
85
105
  ]),
86
106
  });
@@ -1,18 +1,40 @@
1
- import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
1
+ import {
2
+ existsSync,
3
+ readdirSync,
4
+ readFileSync,
5
+ realpathSync,
6
+ statSync,
7
+ } from 'node:fs';
2
8
  import {
3
9
  dirname,
4
10
  extname,
5
11
  isAbsolute,
6
12
  join,
7
- parse as parsePath,
8
13
  relative,
9
14
  resolve,
10
15
  } from 'node:path';
11
16
  import { fileURLToPath, pathToFileURL } from 'node:url';
12
17
 
18
+ import {
19
+ deriveSafePath,
20
+ InternalError,
21
+ isTrailsError,
22
+ PermissionError,
23
+ Result,
24
+ ValidationError,
25
+ } from '@ontrails/core';
13
26
  import type { Topo } from '@ontrails/core';
14
27
  import { findAppModule } from '@ontrails/cli';
15
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';
37
+
16
38
  const URL_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
17
39
 
18
40
  type TranspilerLoader = 'ts' | 'tsx' | 'js' | 'jsx';
@@ -58,7 +80,7 @@ const RETAINED_MIRROR_ROOTS = new Set<string>();
58
80
 
59
81
  const cleanupAllMirrorRoots = (): void => {
60
82
  for (const root of [...ACTIVE_MIRROR_ROOTS, ...RETAINED_MIRROR_ROOTS]) {
61
- rmSync(root, { force: true, recursive: true });
83
+ removeLoadAppMirrorRootQuietly(root);
62
84
  }
63
85
  ACTIVE_MIRROR_ROOTS.clear();
64
86
  RETAINED_MIRROR_ROOTS.clear();
@@ -110,7 +132,7 @@ const acquireMirrorLease = (mirrorRoot: string): (() => void) => {
110
132
 
111
133
  released = true;
112
134
  ACTIVE_MIRROR_ROOTS.delete(mirrorRoot);
113
- rmSync(mirrorRoot, { force: true, recursive: true });
135
+ removeLoadAppMirrorRootQuietly(mirrorRoot);
114
136
  };
115
137
  };
116
138
 
@@ -134,30 +156,161 @@ const resolveFilesystemModulePath = (
134
156
  return existsSync(tsPath) ? tsPath : absolutePath;
135
157
  };
136
158
 
137
- /** Resolve a module path from cwd so CLI defaults behave like shell paths. */
138
- 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 =>
139
205
  URL_SCHEME.test(modulePath)
140
206
  ? resolveUrlModulePath(modulePath)
141
207
  : resolveFilesystemModulePath(modulePath, cwd);
142
208
 
143
- const MIRROR_PARENT_DIRNAME = '.trails-tmp';
144
-
145
- const MIRROR_ENTRY_PREFIX = 'load-app-fresh-';
146
-
147
209
  /**
148
- * Convert an absolute path to a drive-safe relative form before appending it
149
- * to the mirror root. `path.parse(...).root` returns `'/'` on POSIX and
150
- * `'C:\\'` (or similar) on Windows, so `relative` strips the platform root
151
- * in both cases.
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`.
152
217
  */
153
- const freshMirrorPath = (absolutePath: string, mirrorRoot: string): string =>
154
- join(mirrorRoot, relative(parsePath(absolutePath).root, absolutePath));
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
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
+ };
155
257
 
156
258
  const isLocalFilesystemImport = (importPath: string): boolean =>
157
259
  importPath.startsWith('.') ||
158
260
  importPath.startsWith('/') ||
159
261
  importPath.startsWith('file:');
160
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
+
161
314
  const isScannableModule = (modulePath: string): boolean =>
162
315
  SCANNABLE_EXTENSIONS.has(extname(modulePath));
163
316
 
@@ -188,19 +341,10 @@ const collectImportedModulePaths = (
188
341
  return getImportScanner(loader)
189
342
  .scanImports(source)
190
343
  .map((entry) => entry.path)
191
- .filter(isLocalFilesystemImport)
344
+ .filter((importPath) => shouldMirrorImportSpecifier(modulePath, importPath))
192
345
  .map((importPath) => resolveImportedModulePath(modulePath, importPath));
193
346
  };
194
347
 
195
- /**
196
- * Copy a single file into the mirror by raw bytes.
197
- *
198
- * @remarks
199
- * Reading via `.bytes()` rather than `.text()` preserves binary payloads
200
- * (`.wasm`, `.node`, compiled assets) that may sit alongside source files in
201
- * the app's graph. Text decoding would corrupt them on the way through the
202
- * mirror.
203
- */
204
348
  const copyFileToMirror = async (
205
349
  sourcePath: string,
206
350
  mirrorRoot: string,
@@ -211,10 +355,10 @@ const copyFileToMirror = async (
211
355
  }
212
356
  copied.add(sourcePath);
213
357
 
214
- const mirrorPath = freshMirrorPath(sourcePath, mirrorRoot);
215
- mkdirSync(dirname(mirrorPath), { recursive: true });
216
- const bytes = await Bun.file(sourcePath).bytes();
217
- await Bun.write(mirrorPath, bytes);
358
+ const written = await writeLoadAppMirrorFile(sourcePath, mirrorRoot);
359
+ if (written.isErr()) {
360
+ throw written.error;
361
+ }
218
362
  };
219
363
 
220
364
  /**
@@ -301,14 +445,11 @@ const isStaleMirrorEntry = (entryPath: string, now: number): boolean => {
301
445
  };
302
446
 
303
447
  const removeStaleMirrorEntry = (entryPath: string): void => {
304
- try {
305
- rmSync(entryPath, { force: true, recursive: true });
306
- } catch {
307
- /*
308
- * Another concurrent load may own it. Safe to ignore — the next sweep
309
- * will retry.
310
- */
311
- }
448
+ /*
449
+ * Another concurrent load may own it. Safe to ignore — the next sweep
450
+ * will retry.
451
+ */
452
+ removeLoadAppMirrorRootQuietly(entryPath);
312
453
  };
313
454
 
314
455
  /**
@@ -322,7 +463,7 @@ const cleanupStaleMirrorRoots = (mirrorParent: string): void => {
322
463
  }
323
464
  const now = Date.now();
324
465
  for (const entry of entries) {
325
- if (!entry.startsWith(MIRROR_ENTRY_PREFIX)) {
466
+ if (!entry.startsWith(LOAD_APP_MIRROR_ENTRY_PREFIX)) {
326
467
  continue;
327
468
  }
328
469
  const entryPath = join(mirrorParent, entry);
@@ -333,12 +474,9 @@ const cleanupStaleMirrorRoots = (mirrorParent: string): void => {
333
474
  };
334
475
 
335
476
  const freshMirrorRootPath = (cwd: string): string => {
336
- const mirrorParent = join(cwd, MIRROR_PARENT_DIRNAME);
477
+ const mirrorParent = join(cwd, LOAD_APP_MIRROR_PARENT_DIRNAME);
337
478
  cleanupStaleMirrorRoots(mirrorParent);
338
- return join(
339
- mirrorParent,
340
- `${MIRROR_ENTRY_PREFIX}${Date.now()}-${Math.random().toString(36).slice(2)}`
341
- );
479
+ return createLoadAppMirrorRootPath(cwd);
342
480
  };
343
481
 
344
482
  interface MirrorWalkContext {
@@ -383,11 +521,33 @@ const copyDirectoryTreeToMirror = async (
383
521
  }
384
522
  };
385
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
+
386
545
  const mirrorImportedModule = async (
387
546
  modulePath: string,
388
547
  context: MirrorWalkContext
389
548
  ): Promise<void> => {
390
549
  const moduleDirectory = dirname(modulePath);
550
+ await copyNearestPackageJsonToMirror(moduleDirectory, context);
391
551
  if (context.visitedDirectories.has(moduleDirectory)) {
392
552
  await copyFileToMirror(modulePath, context.mirrorRoot, context.copied);
393
553
  return;
@@ -429,7 +589,11 @@ const mirrorFreshImportGraph = async (
429
589
  };
430
590
 
431
591
  await visit(entryPath);
432
- return freshMirrorPath(entryPath, mirrorRoot);
592
+ const freshPath = resolveLoadAppMirrorFilePath(entryPath, mirrorRoot);
593
+ if (freshPath.isErr()) {
594
+ throw freshPath.error;
595
+ }
596
+ return freshPath.value;
433
597
  };
434
598
 
435
599
  /**
@@ -461,16 +625,16 @@ const prepareMirror = async (
461
625
  const freshPath = await mirrorFreshImportGraph(absolutePath, mirrorRoot);
462
626
  return { freshPath, mirrorRoot };
463
627
  } catch (error) {
464
- rmSync(mirrorRoot, { force: true, recursive: true });
628
+ removeLoadAppMirrorRootQuietly(mirrorRoot);
465
629
  throw error;
466
630
  }
467
631
  };
468
632
 
469
633
  const importFreshModule = async (
470
- modulePath: string,
634
+ resolvedModulePath: string,
471
635
  cwd: string
472
636
  ): Promise<Record<string, unknown>> => {
473
- const absolutePath = resolveAbsoluteModulePath(modulePath, cwd);
637
+ const absolutePath = resolvedModulePath;
474
638
  if (URL_SCHEME.test(absolutePath) && !absolutePath.startsWith('/')) {
475
639
  return await importWithCacheBust(absolutePath);
476
640
  }
@@ -491,7 +655,7 @@ const resolveLoadedTopo = (
491
655
  | Topo
492
656
  | undefined;
493
657
  if (!app?.trails) {
494
- throw new Error(
658
+ throw new ValidationError(
495
659
  `Could not find a Topo export in "${effectivePath}". ` +
496
660
  "Expected a default, 'graph', or 'app' named export created with topo()."
497
661
  );
@@ -505,6 +669,12 @@ export interface FreshAppLease {
505
669
  readonly release: () => void;
506
670
  }
507
671
 
672
+ export type LoadAppLeaseOptions = LoadAppTrustOptions;
673
+
674
+ export interface LoadAppOptions extends LoadAppTrustOptions {
675
+ readonly fresh?: boolean | undefined;
676
+ }
677
+
508
678
  const noopRelease = (): void => undefined;
509
679
 
510
680
  const createUrlSchemeLease = async (
@@ -546,26 +716,51 @@ const createFilesystemLease = async (
546
716
 
547
717
  export const loadFreshAppLease = async (
548
718
  modulePath: string | undefined,
549
- cwd: string
719
+ cwd: string,
720
+ options: LoadAppLeaseOptions = {}
550
721
  ): Promise<FreshAppLease> => {
551
722
  const effectivePath =
552
- modulePath === undefined ? findAppModule(cwd) : modulePath;
553
- const absolutePath = resolveAbsoluteModulePath(effectivePath, cwd);
723
+ modulePath === undefined ? findWorkspaceRelativeAppModule(cwd) : modulePath;
724
+ const absolutePath = resolveLoadAppModulePath(effectivePath, cwd, options);
554
725
 
555
726
  return URL_SCHEME.test(absolutePath) && !absolutePath.startsWith('/')
556
727
  ? await createUrlSchemeLease(absolutePath, effectivePath)
557
728
  : await createFilesystemLease(absolutePath, cwd, effectivePath);
558
729
  };
559
730
 
560
- /** Load a Topo export from a module path relative to cwd. */
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
+ */
561
752
  export const loadApp = async (
562
753
  modulePath: string | undefined,
563
754
  cwd: string,
564
- options: { fresh?: boolean | undefined } = {}
755
+ options: LoadAppOptions = {}
565
756
  ): Promise<Topo> => {
566
757
  const effectivePath =
567
- modulePath === undefined ? findAppModule(cwd) : modulePath;
568
- const resolvedModulePath = resolveAbsoluteModulePath(effectivePath, cwd);
758
+ modulePath === undefined ? findWorkspaceRelativeAppModule(cwd) : modulePath;
759
+ const resolvedModulePath = resolveLoadAppModulePath(
760
+ effectivePath,
761
+ cwd,
762
+ options
763
+ );
569
764
  const mod =
570
765
  options.fresh === true
571
766
  ? await importFreshModule(resolvedModulePath, cwd)
@@ -0,0 +1,21 @@
1
+ import { Result, ValidationError } from '@ontrails/core';
2
+
3
+ const ROOT_DIR_MESSAGE =
4
+ 'Trail execution requires rootDir input or ctx.cwd from the runtime context.';
5
+
6
+ export const resolveTrailRootDir = (
7
+ rootDir: string | undefined,
8
+ cwd: string | undefined
9
+ ): Result<string, ValidationError> => {
10
+ const resolved = rootDir ?? cwd;
11
+ return resolved === undefined
12
+ ? Result.err(new ValidationError(ROOT_DIR_MESSAGE))
13
+ : Result.ok(resolved);
14
+ };
15
+
16
+ export const requireTrailRootDir = (rootDir: string | undefined): string => {
17
+ if (rootDir === undefined) {
18
+ throw new ValidationError(ROOT_DIR_MESSAGE);
19
+ }
20
+ return rootDir;
21
+ };