@ontrails/trails 0.2.0

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 (121) hide show
  1. package/CHANGELOG.md +1906 -0
  2. package/README.md +48 -0
  3. package/bin/trails.ts +3 -0
  4. package/package.json +57 -0
  5. package/src/app.ts +167 -0
  6. package/src/clack.ts +111 -0
  7. package/src/cli.ts +308 -0
  8. package/src/completions.ts +431 -0
  9. package/src/lifecycle-source-io.ts +33 -0
  10. package/src/load-app-mirror.ts +202 -0
  11. package/src/local-state-io.ts +129 -0
  12. package/src/mcp-app.ts +42 -0
  13. package/src/mcp-options.ts +92 -0
  14. package/src/mcp.ts +8 -0
  15. package/src/project-writes.ts +377 -0
  16. package/src/regrade/audit.ts +571 -0
  17. package/src/regrade/config.ts +152 -0
  18. package/src/regrade/history.ts +636 -0
  19. package/src/regrade/lifecycle.ts +76 -0
  20. package/src/regrade/live-api-preserve.ts +123 -0
  21. package/src/regrade/plan-artifact.ts +515 -0
  22. package/src/regrade/plan-derivation.ts +301 -0
  23. package/src/regrade/prepared-run.ts +259 -0
  24. package/src/regrade/receipt-history.ts +446 -0
  25. package/src/regrade/source-transaction.ts +185 -0
  26. package/src/release/bindings.ts +58 -0
  27. package/src/release/changeset-packages.ts +99 -0
  28. package/src/release/check.ts +1191 -0
  29. package/src/release/cli-bundle.ts +575 -0
  30. package/src/release/config.ts +73 -0
  31. package/src/release/contract-facts.ts +425 -0
  32. package/src/release/homebrew.ts +221 -0
  33. package/src/release/index.ts +180 -0
  34. package/src/release/lock-roundtrip-smoke.ts +255 -0
  35. package/src/release/lock-roundtrip-workspace.ts +107 -0
  36. package/src/release/native-bun-publish.ts +964 -0
  37. package/src/release/native-bun-registry.ts +848 -0
  38. package/src/release/notes-cli.ts +171 -0
  39. package/src/release/notes.ts +390 -0
  40. package/src/release/pack-coherence.ts +455 -0
  41. package/src/release/package-route-facts.ts +146 -0
  42. package/src/release/packed-artifacts-smoke.ts +236 -0
  43. package/src/release/policy.ts +1780 -0
  44. package/src/release/semver.ts +104 -0
  45. package/src/release/smoke.ts +56 -0
  46. package/src/release/stable-version-release.ts +80 -0
  47. package/src/release/wayfinder-dogfood-smoke.ts +762 -0
  48. package/src/release/zero-line-transition.ts +68 -0
  49. package/src/retired-topo-command.ts +36 -0
  50. package/src/run-adapter-check.ts +76 -0
  51. package/src/run-argv.ts +133 -0
  52. package/src/run-collision.ts +126 -0
  53. package/src/run-completions-install.ts +179 -0
  54. package/src/run-example.ts +149 -0
  55. package/src/run-examples.ts +148 -0
  56. package/src/run-quiet.ts +75 -0
  57. package/src/run-regrade-progress.ts +47 -0
  58. package/src/run-release-check.ts +74 -0
  59. package/src/run-schema.ts +74 -0
  60. package/src/run-trace.ts +273 -0
  61. package/src/run-warden.ts +39 -0
  62. package/src/run-watch-project.ts +52 -0
  63. package/src/run-watch.ts +381 -0
  64. package/src/run-wayfind-outline.ts +170 -0
  65. package/src/scaffold-version-sync.ts +183 -0
  66. package/src/scaffold-versions.generated.ts +12 -0
  67. package/src/trails/adapter-check.ts +244 -0
  68. package/src/trails/add-surface.ts +816 -0
  69. package/src/trails/add-trail.ts +141 -0
  70. package/src/trails/add-verify.ts +252 -0
  71. package/src/trails/compile.ts +118 -0
  72. package/src/trails/completions-complete.ts +236 -0
  73. package/src/trails/completions.ts +47 -0
  74. package/src/trails/config-explain.ts +43 -0
  75. package/src/trails/create-adapter.ts +785 -0
  76. package/src/trails/create-scaffold.ts +1215 -0
  77. package/src/trails/create-versions.ts +62 -0
  78. package/src/trails/create.ts +652 -0
  79. package/src/trails/deprecate.ts +59 -0
  80. package/src/trails/dev-clean.ts +80 -0
  81. package/src/trails/dev-reset.ts +48 -0
  82. package/src/trails/dev-stats.ts +71 -0
  83. package/src/trails/dev-support.ts +360 -0
  84. package/src/trails/doctor.ts +77 -0
  85. package/src/trails/draft-promote.ts +949 -0
  86. package/src/trails/guide.ts +106 -0
  87. package/src/trails/load-app.ts +1145 -0
  88. package/src/trails/operator-context.ts +66 -0
  89. package/src/trails/project-context-output.ts +304 -0
  90. package/src/trails/project-context.ts +613 -0
  91. package/src/trails/project.ts +65 -0
  92. package/src/trails/regrade.ts +4951 -0
  93. package/src/trails/release-check.ts +113 -0
  94. package/src/trails/release-smoke.ts +49 -0
  95. package/src/trails/revise.ts +53 -0
  96. package/src/trails/root-dir.ts +21 -0
  97. package/src/trails/run-example.ts +592 -0
  98. package/src/trails/run-examples.ts +149 -0
  99. package/src/trails/run.ts +496 -0
  100. package/src/trails/scaffold-json.ts +60 -0
  101. package/src/trails/scaffold-topo-identity.ts +479 -0
  102. package/src/trails/survey.ts +990 -0
  103. package/src/trails/topo-activation.ts +14 -0
  104. package/src/trails/topo-constants.ts +2 -0
  105. package/src/trails/topo-history.ts +47 -0
  106. package/src/trails/topo-output-schemas.ts +259 -0
  107. package/src/trails/topo-pin.ts +38 -0
  108. package/src/trails/topo-read-support.ts +368 -0
  109. package/src/trails/topo-reports.ts +809 -0
  110. package/src/trails/topo-store-support.ts +323 -0
  111. package/src/trails/topo-support.ts +247 -0
  112. package/src/trails/topo-unpin.ts +61 -0
  113. package/src/trails/topo.ts +92 -0
  114. package/src/trails/validate.ts +348 -0
  115. package/src/trails/version-lifecycle-support.ts +936 -0
  116. package/src/trails/warden-guide.ts +134 -0
  117. package/src/trails/warden.ts +598 -0
  118. package/src/trails/wayfind-diff.ts +716 -0
  119. package/src/trails/wayfind-outline.ts +876 -0
  120. package/src/trails/wayfind.ts +1319 -0
  121. package/src/versions.ts +31 -0
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Shell completion infrastructure for the `trails` CLI.
3
+ *
4
+ * The completion model is a two-part system:
5
+ *
6
+ * 1. A small, static **shell script** registered with the user's shell. The
7
+ * script's only job is to invoke the binary's internal completion subcommand
8
+ * (`trails completions __complete`) with the partial argv at tab-press time
9
+ * and feed the resulting lines back to the shell.
10
+ * 2. A dynamic **`__complete` trail** that parses the partial argv and emits
11
+ * a sorted list of suggestions (e.g. trail IDs).
12
+ *
13
+ * This split keeps the shell-side script tiny and standardish (no rich shell
14
+ * DSL), while the heavy lifting stays in TypeScript where it can reuse
15
+ * Config-owned app identities and live topos for accurate suggestions.
16
+ */
17
+
18
+ import {
19
+ deriveStructuredTrailExamples,
20
+ RecoverableCompletionError,
21
+ Result,
22
+ ValidationError,
23
+ } from '@ontrails/core';
24
+ import { readTrailsProjectIdentity } from '@ontrails/config';
25
+
26
+ import { tryLoadFreshAppLease } from './trails/load-app.js';
27
+
28
+ interface CompletionApp {
29
+ readonly configured: boolean;
30
+ readonly id: string;
31
+ readonly modulePath: string | undefined;
32
+ readonly rootDir: string;
33
+ }
34
+
35
+ interface UnavailableCompletionApp {
36
+ readonly appId: string;
37
+ readonly error: Error;
38
+ }
39
+
40
+ interface CompletionOwnershipView {
41
+ readonly owners: ReadonlyMap<string, readonly string[]>;
42
+ readonly unavailableApps: readonly UnavailableCompletionApp[];
43
+ }
44
+
45
+ const completionApps = async (
46
+ workspaceRoot: string,
47
+ selectedAppId?: string | undefined,
48
+ selectedModulePath?: string | undefined
49
+ ): Promise<readonly CompletionApp[]> => {
50
+ const identity = await readTrailsProjectIdentity({
51
+ boundaryDir: workspaceRoot,
52
+ startDir: workspaceRoot,
53
+ });
54
+ return identity.workspace === undefined
55
+ ? [
56
+ {
57
+ configured: false,
58
+ id: 'standalone',
59
+ modulePath: selectedModulePath,
60
+ rootDir: identity.rootDir,
61
+ },
62
+ ]
63
+ : identity.apps
64
+ .filter(
65
+ (app) => selectedAppId === undefined || app.id === selectedAppId
66
+ )
67
+ .map((app) => ({
68
+ configured: true,
69
+ id: app.id,
70
+ modulePath:
71
+ app.id === selectedAppId && selectedModulePath !== undefined
72
+ ? selectedModulePath
73
+ : app.entry,
74
+ rootDir: app.rootDir,
75
+ }));
76
+ };
77
+
78
+ const completionOwners = async (
79
+ workspaceRoot: string,
80
+ trailId?: string | undefined,
81
+ selectedAppId?: string | undefined,
82
+ selectedModulePath?: string | undefined
83
+ ): Promise<CompletionOwnershipView> => {
84
+ const owners = new Map<string, string[]>();
85
+ const unavailableApps: UnavailableCompletionApp[] = [];
86
+ for (const app of await completionApps(
87
+ workspaceRoot,
88
+ selectedAppId,
89
+ selectedModulePath
90
+ )) {
91
+ const loaded = await tryLoadFreshAppLease(app.modulePath, app.rootDir);
92
+ if (loaded.isErr()) {
93
+ unavailableApps.push({ appId: app.id, error: loaded.error });
94
+ continue;
95
+ }
96
+ const lease = loaded.value;
97
+ try {
98
+ if (app.configured && lease.app.name !== app.id) {
99
+ unavailableApps.push({
100
+ appId: app.id,
101
+ error: new ValidationError(
102
+ `Configured app "${app.id}" loaded topo "${lease.app.name}" while completing shell input.`
103
+ ),
104
+ });
105
+ continue;
106
+ }
107
+ const ids = trailId === undefined ? lease.app.ids() : [trailId];
108
+ for (const id of ids) {
109
+ if (lease.app.get(id) === undefined) {
110
+ continue;
111
+ }
112
+ const current = owners.get(id) ?? [];
113
+ current.push(app.id);
114
+ owners.set(id, current);
115
+ }
116
+ } finally {
117
+ lease.release();
118
+ }
119
+ }
120
+ return { owners, unavailableApps };
121
+ };
122
+
123
+ /** Shells supported by the completion generator. */
124
+ export type CompletionShell = 'bash' | 'zsh' | 'fish';
125
+
126
+ type ScriptRenderer = (binName: string) => string;
127
+
128
+ const renderBashScript: ScriptRenderer = (binName) =>
129
+ `# ${binName} bash completion
130
+ _${binName}_complete() {
131
+ local cur word
132
+ local -a words completion_args
133
+ cur="\${COMP_WORDS[COMP_CWORD]}"
134
+ words=("\${COMP_WORDS[@]:1:COMP_CWORD}")
135
+ completion_args=()
136
+ for word in "\${words[@]}"; do
137
+ completion_args+=("--args=$word")
138
+ done
139
+ COMPREPLY=()
140
+ while IFS= read -r suggestion; do
141
+ COMPREPLY+=("$suggestion")
142
+ done < <(${binName} completions __complete "\${completion_args[@]}" 2>/dev/null)
143
+ return 0
144
+ }
145
+ complete -F _${binName}_complete ${binName}
146
+ `;
147
+
148
+ const renderZshScript: ScriptRenderer = (binName) =>
149
+ `#compdef ${binName}
150
+ # ${binName} zsh completion
151
+ _${binName}_complete() {
152
+ local -a suggestions trail_words completion_args
153
+ local output
154
+ local trail_word
155
+ trail_words=("\${(@)words[2,CURRENT]}")
156
+ completion_args=()
157
+ for trail_word in "\${trail_words[@]}"; do
158
+ completion_args+=("--args=$trail_word")
159
+ done
160
+ output="$(${binName} completions __complete "\${completion_args[@]}" 2>/dev/null)"
161
+ if [[ -n "$output" ]]; then
162
+ suggestions=("\${(@f)output}")
163
+ if (( \${#suggestions} )); then
164
+ compadd -- "\${suggestions[@]}"
165
+ fi
166
+ fi
167
+ }
168
+ compdef _${binName}_complete ${binName}
169
+ `;
170
+
171
+ const renderFishScript: ScriptRenderer = (binName) =>
172
+ `# ${binName} fish completion
173
+ function __${binName}_complete
174
+ set -l prior_tokens (commandline -opc)
175
+ set -e prior_tokens[1]
176
+ set -l completion_args
177
+ for token in $prior_tokens
178
+ set -a completion_args "--args=$token"
179
+ end
180
+ set -l current_token (commandline -ct)
181
+ if test (count $current_token) -eq 0
182
+ set -a completion_args --args=
183
+ else
184
+ set -a completion_args "--args=$current_token[1]"
185
+ end
186
+ ${binName} completions __complete $completion_args 2>/dev/null
187
+ end
188
+ complete -c ${binName} -f -a '(__${binName}_complete)'
189
+ `;
190
+
191
+ const SCRIPT_RENDERERS: Readonly<Record<CompletionShell, ScriptRenderer>> = {
192
+ bash: renderBashScript,
193
+ fish: renderFishScript,
194
+ zsh: renderZshScript,
195
+ };
196
+
197
+ /** Pattern that `binName` must match — alphanumerics, underscore, hyphen. */
198
+ const BIN_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
199
+
200
+ const recoverableCompletionError = (
201
+ message: string,
202
+ context: Record<string, unknown>,
203
+ cause?: unknown
204
+ ): RecoverableCompletionError =>
205
+ new RecoverableCompletionError(message, {
206
+ ...(cause instanceof Error ? { cause } : {}),
207
+ context,
208
+ });
209
+
210
+ /**
211
+ * Render a static shell completion script that delegates dynamic completion
212
+ * to `<binName> completions __complete --args=<token>...`.
213
+ *
214
+ * @param shell - target shell flavor.
215
+ * @param binName - binary name to register the completion against (typically
216
+ * `'trails'`). Used both as the registered command and as the prefix for the
217
+ * shell function name. Must match `/^[a-zA-Z0-9_-]+$/` — the value is
218
+ * interpolated verbatim into shell source, so any non-trivial input would
219
+ * be a shell-injection vector. We validate at the boundary per
220
+ * "validate at the boundary, trust internally" (docs/tenets.md).
221
+ */
222
+ export const renderCompletionScript = (
223
+ shell: CompletionShell,
224
+ binName: string
225
+ ): Result<string, ValidationError> => {
226
+ if (!BIN_NAME_PATTERN.test(binName)) {
227
+ return Result.err(
228
+ new ValidationError(
229
+ `renderCompletionScript: binName must match /^[a-zA-Z0-9_-]+$/ (got: ${JSON.stringify(binName)})`
230
+ )
231
+ );
232
+ }
233
+ return Result.ok(SCRIPT_RENDERERS[shell](binName));
234
+ };
235
+
236
+ /**
237
+ * Read trail IDs from the live workspace topo and return those matching
238
+ * `prefix`, sorted lexicographically. Includes IDs that collide across multiple
239
+ * apps — the shell only needs the unique set of identifiers, not their owners.
240
+ *
241
+ * @param workspaceRoot - workspace root directory used to resolve apps.
242
+ * @param prefix - prefix to filter by; an empty prefix returns every ID.
243
+ */
244
+ export const renderTrailIdCompletions = async (
245
+ workspaceRoot: string,
246
+ prefix: string,
247
+ selectedAppId?: string | undefined,
248
+ selectedModulePath?: string | undefined
249
+ ): Promise<readonly string[]> => {
250
+ let owners: ReadonlyMap<string, readonly string[]>;
251
+ try {
252
+ const ownership = await completionOwners(
253
+ workspaceRoot,
254
+ undefined,
255
+ selectedAppId,
256
+ selectedModulePath
257
+ );
258
+ ({ owners } = ownership);
259
+ } catch {
260
+ return [];
261
+ }
262
+ const matching: string[] = [];
263
+ for (const id of owners.keys()) {
264
+ if (id.startsWith(prefix)) {
265
+ matching.push(id);
266
+ }
267
+ }
268
+ matching.sort((a, b) => {
269
+ if (a < b) {
270
+ return -1;
271
+ }
272
+ if (a > b) {
273
+ return 1;
274
+ }
275
+ return 0;
276
+ });
277
+ return matching;
278
+ };
279
+
280
+ /** Return Config-owned app IDs matching a shell prefix. */
281
+ export const renderAppIdCompletions = async (
282
+ workspaceRoot: string,
283
+ prefix: string
284
+ ): Promise<readonly string[]> => {
285
+ try {
286
+ const apps = await completionApps(workspaceRoot);
287
+ return apps
288
+ .filter((app) => app.configured && app.id.startsWith(prefix))
289
+ .map((app) => app.id)
290
+ .toSorted();
291
+ } catch {
292
+ return [];
293
+ }
294
+ };
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Example name completion
298
+ // ---------------------------------------------------------------------------
299
+
300
+ /**
301
+ * Return example names for `trailId` matching `prefix`, sorted lexicographically.
302
+ *
303
+ * Resolves Config-owned app identity, requires one live owner, loads that app,
304
+ * and reads the `name` of every structured example.
305
+ *
306
+ * Completion is best-effort for shell callers, but this helper preserves
307
+ * load-time failures as `RecoverableCompletionError` so the internal bridge can
308
+ * decide whether to suppress them for prompt safety.
309
+ */
310
+ export const renderTrailExampleCompletions = async (
311
+ workspaceRoot: string,
312
+ trailId: string,
313
+ prefix: string,
314
+ selectedAppId?: string | undefined,
315
+ selectedModulePath?: string | undefined
316
+ ): Promise<Result<readonly string[], RecoverableCompletionError>> => {
317
+ try {
318
+ const ownership = await completionOwners(
319
+ workspaceRoot,
320
+ trailId,
321
+ selectedAppId,
322
+ selectedModulePath
323
+ );
324
+ const appIds = ownership.owners.get(trailId) ?? [];
325
+ if (appIds.length === 0 && ownership.unavailableApps.length > 0) {
326
+ const [firstUnavailable] = ownership.unavailableApps;
327
+ return Result.err(
328
+ recoverableCompletionError(
329
+ 'Cannot determine a live owner while completing example names',
330
+ {
331
+ trailId,
332
+ unavailableAppIds: ownership.unavailableApps.map(
333
+ (app) => app.appId
334
+ ),
335
+ workspaceRoot,
336
+ },
337
+ firstUnavailable?.error
338
+ )
339
+ );
340
+ }
341
+ if (appIds.length !== 1) {
342
+ return Result.ok([]);
343
+ }
344
+ const [ownerId] = appIds;
345
+ const apps = await completionApps(
346
+ workspaceRoot,
347
+ selectedAppId,
348
+ selectedModulePath
349
+ );
350
+ const owner = apps.find((app) => app.id === ownerId);
351
+ if (owner === undefined) {
352
+ return Result.ok([]);
353
+ }
354
+ const leaseResult = await tryLoadFreshAppLease(
355
+ owner.modulePath,
356
+ owner.rootDir
357
+ );
358
+ if (leaseResult.isErr()) {
359
+ return Result.err(
360
+ recoverableCompletionError(
361
+ 'Cannot load app while completing example names',
362
+ {
363
+ appId: owner.id,
364
+ modulePath: owner.modulePath,
365
+ trailId,
366
+ workspaceRoot,
367
+ },
368
+ leaseResult.error
369
+ )
370
+ );
371
+ }
372
+ const lease = leaseResult.value;
373
+ try {
374
+ if (owner.configured && lease.app.name !== owner.id) {
375
+ return Result.err(
376
+ recoverableCompletionError(
377
+ 'Configured app identity does not match its loaded topo while completing example names',
378
+ {
379
+ actualAppId: lease.app.name,
380
+ expectedAppId: owner.id,
381
+ modulePath: owner.modulePath,
382
+ trailId,
383
+ workspaceRoot,
384
+ }
385
+ )
386
+ );
387
+ }
388
+ const target = lease.app.get(trailId);
389
+ if (target === undefined) {
390
+ return Result.err(
391
+ recoverableCompletionError(
392
+ 'Indexed trail was not found in loaded app while completing example names',
393
+ {
394
+ appId: owner.id,
395
+ modulePath: owner.modulePath,
396
+ trailId,
397
+ workspaceRoot,
398
+ }
399
+ )
400
+ );
401
+ }
402
+ const structured = deriveStructuredTrailExamples(target.examples) ?? [];
403
+ const matching: string[] = [];
404
+ for (const example of structured) {
405
+ if (example.name.startsWith(prefix)) {
406
+ matching.push(example.name);
407
+ }
408
+ }
409
+ matching.sort((a, b) => {
410
+ if (a < b) {
411
+ return -1;
412
+ }
413
+ if (a > b) {
414
+ return 1;
415
+ }
416
+ return 0;
417
+ });
418
+ return Result.ok(matching);
419
+ } finally {
420
+ lease.release();
421
+ }
422
+ } catch (error) {
423
+ return Result.err(
424
+ recoverableCompletionError(
425
+ 'Cannot resolve workspace while completing example names',
426
+ { trailId, workspaceRoot },
427
+ error
428
+ )
429
+ );
430
+ }
431
+ };
@@ -0,0 +1,33 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+
3
+ import { InternalError, Result } from '@ontrails/core';
4
+
5
+ export const readLifecycleSourceFile = (
6
+ filePath: string
7
+ ): Result<string, Error> => {
8
+ try {
9
+ return Result.ok(readFileSync(filePath, 'utf8'));
10
+ } catch (error: unknown) {
11
+ return Result.err(
12
+ error instanceof Error
13
+ ? error
14
+ : new InternalError(`Unable to read lifecycle source file ${filePath}`)
15
+ );
16
+ }
17
+ };
18
+
19
+ export const writeLifecycleSourceFile = (
20
+ filePath: string,
21
+ source: string
22
+ ): Result<void, Error> => {
23
+ try {
24
+ writeFileSync(filePath, source);
25
+ return Result.ok();
26
+ } catch (error: unknown) {
27
+ return Result.err(
28
+ error instanceof Error
29
+ ? error
30
+ : new InternalError(`Unable to write lifecycle source file ${filePath}`)
31
+ );
32
+ }
33
+ };
@@ -0,0 +1,202 @@
1
+ import { mkdirSync, rmSync } from 'node:fs';
2
+ import {
3
+ basename,
4
+ dirname,
5
+ isAbsolute,
6
+ join,
7
+ parse as parsePath,
8
+ relative,
9
+ resolve,
10
+ } from 'node:path';
11
+
12
+ import {
13
+ deriveSafePath,
14
+ InternalError,
15
+ PermissionError,
16
+ Result,
17
+ ValidationError,
18
+ } from '@ontrails/core';
19
+ // Result is imported as a value for factories above; this alias keeps returned
20
+ // Result types readable without colliding with the value import.
21
+ import type { Result as TrailsResult } from '@ontrails/core';
22
+
23
+ export const LOAD_APP_MIRROR_PARENT_DIRNAME = '.trails-tmp';
24
+
25
+ export const LOAD_APP_MIRROR_ENTRY_PREFIX = 'load-app-fresh-';
26
+
27
+ const asError = (error: unknown): Error =>
28
+ error instanceof Error ? error : new Error(String(error));
29
+
30
+ const validateMirrorRoot = (
31
+ mirrorRoot: string
32
+ ): TrailsResult<string, PermissionError> => {
33
+ const resolved = resolve(mirrorRoot);
34
+ const mirrorParent = dirname(resolved);
35
+
36
+ return basename(mirrorParent) === LOAD_APP_MIRROR_PARENT_DIRNAME &&
37
+ basename(resolved).startsWith(LOAD_APP_MIRROR_ENTRY_PREFIX)
38
+ ? Result.ok(resolved)
39
+ : Result.err(
40
+ new PermissionError(
41
+ `Refusing to write or remove non-load-app mirror path "${mirrorRoot}"`,
42
+ { context: { mirrorRoot: resolved } }
43
+ )
44
+ );
45
+ };
46
+
47
+ const isPathInside = (root: string, target: string): boolean => {
48
+ const candidate = relative(root, target);
49
+ return (
50
+ candidate === '' || (!candidate.startsWith('..') && !isAbsolute(candidate))
51
+ );
52
+ };
53
+
54
+ const resolveAbsoluteSourcePath = (
55
+ sourcePath: string
56
+ ): TrailsResult<string, ValidationError> =>
57
+ isAbsolute(sourcePath)
58
+ ? Result.ok(sourcePath)
59
+ : Result.err(
60
+ new ValidationError(
61
+ `Load-app mirror source path must be absolute: "${sourcePath}"`,
62
+ { context: { sourcePath } }
63
+ )
64
+ );
65
+
66
+ export const ensureLoadAppMirrorDirectory = (
67
+ directoryPath: string,
68
+ mirrorRoot: string
69
+ ): TrailsResult<void, Error> => {
70
+ const root = validateMirrorRoot(mirrorRoot);
71
+ if (root.isErr()) {
72
+ return root;
73
+ }
74
+
75
+ const target = resolve(directoryPath);
76
+ if (!isPathInside(root.value, target)) {
77
+ return Result.err(
78
+ new PermissionError(
79
+ `Refusing to create load-app mirror directory outside "${root.value}"`,
80
+ { context: { directoryPath: target, mirrorRoot: root.value } }
81
+ )
82
+ );
83
+ }
84
+
85
+ try {
86
+ mkdirSync(target, { recursive: true });
87
+ return Result.ok();
88
+ } catch (error) {
89
+ return Result.err(
90
+ new InternalError(
91
+ `Failed to create load-app mirror directory "${target}"`,
92
+ {
93
+ cause: asError(error),
94
+ context: { directoryPath: target, mirrorRoot: root.value },
95
+ }
96
+ )
97
+ );
98
+ }
99
+ };
100
+
101
+ /**
102
+ * Convert an absolute source path to the deterministic location inside a
103
+ * load-app fresh mirror.
104
+ */
105
+ export const resolveLoadAppMirrorFilePath = (
106
+ sourcePath: string,
107
+ mirrorRoot: string
108
+ ): TrailsResult<string, Error> => {
109
+ const root = validateMirrorRoot(mirrorRoot);
110
+ if (root.isErr()) {
111
+ return root;
112
+ }
113
+
114
+ const source = resolveAbsoluteSourcePath(sourcePath);
115
+ if (source.isErr()) {
116
+ return source;
117
+ }
118
+
119
+ const mirrorRelativePath = relative(
120
+ parsePath(source.value).root,
121
+ source.value
122
+ );
123
+ return deriveSafePath(root.value, mirrorRelativePath);
124
+ };
125
+
126
+ /**
127
+ * Copy a source file into its load-app fresh mirror by raw bytes.
128
+ *
129
+ * @remarks
130
+ * Reading via `.bytes()` rather than `.text()` preserves binary payloads
131
+ * (`.wasm`, `.node`, compiled assets) that may sit alongside source files in
132
+ * the app's graph. Text decoding would corrupt them on the way through the
133
+ * mirror.
134
+ */
135
+ export const writeLoadAppMirrorFile = async (
136
+ sourcePath: string,
137
+ mirrorRoot: string
138
+ ): Promise<TrailsResult<string, Error>> => {
139
+ const mirrorPath = resolveLoadAppMirrorFilePath(sourcePath, mirrorRoot);
140
+ if (mirrorPath.isErr()) {
141
+ return mirrorPath;
142
+ }
143
+
144
+ try {
145
+ mkdirSync(dirname(mirrorPath.value), { recursive: true });
146
+ const bytes = await Bun.file(sourcePath).bytes();
147
+ await Bun.write(mirrorPath.value, bytes);
148
+ return Result.ok(mirrorPath.value);
149
+ } catch (error) {
150
+ return Result.err(
151
+ new InternalError(`Failed to mirror load-app file "${sourcePath}"`, {
152
+ cause: asError(error),
153
+ context: { mirrorPath: mirrorPath.value, mirrorRoot, sourcePath },
154
+ })
155
+ );
156
+ }
157
+ };
158
+
159
+ export const removeLoadAppMirrorRoot = (
160
+ mirrorRoot: string
161
+ ): TrailsResult<void, Error> => {
162
+ const root = validateMirrorRoot(mirrorRoot);
163
+ if (root.isErr()) {
164
+ return root;
165
+ }
166
+
167
+ try {
168
+ rmSync(root.value, { force: true, recursive: true });
169
+ return Result.ok();
170
+ } catch (error) {
171
+ return Result.err(
172
+ new InternalError(`Failed to remove load-app mirror "${mirrorRoot}"`, {
173
+ cause: asError(error),
174
+ context: { mirrorRoot: root.value },
175
+ })
176
+ );
177
+ }
178
+ };
179
+
180
+ /**
181
+ * Best-effort cleanup for process-exit and stale-sweep paths.
182
+ *
183
+ * This intentionally suppresses validation and filesystem failures because the
184
+ * caller is already abandoning a temporary mirror and cleanup must not turn
185
+ * into an application-load failure.
186
+ */
187
+ export const removeLoadAppMirrorRootQuietly = (mirrorRoot: string): void => {
188
+ try {
189
+ removeLoadAppMirrorRoot(mirrorRoot);
190
+ } catch {
191
+ // Best-effort cleanup must never become the failure path.
192
+ }
193
+ };
194
+
195
+ export const createLoadAppMirrorRootPath = (cwd: string): string =>
196
+ join(
197
+ resolve(cwd),
198
+ LOAD_APP_MIRROR_PARENT_DIRNAME,
199
+ `${LOAD_APP_MIRROR_ENTRY_PREFIX}${Date.now()}-${Math.random()
200
+ .toString(36)
201
+ .slice(2)}`
202
+ );