@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.
- package/CHANGELOG.md +197 -2
- package/README.md +27 -0
- package/package.json +19 -8
- package/src/app.ts +15 -5
- package/src/cli.ts +303 -10
- package/src/completions.ts +240 -0
- package/src/load-app-mirror.ts +160 -0
- package/src/local-state-io.ts +153 -0
- package/src/project-writes.ts +320 -0
- package/src/run-collision.ts +125 -0
- package/src/run-completions-install.ts +179 -0
- package/src/run-example.ts +149 -0
- package/src/run-examples.ts +148 -0
- package/src/run-quiet.ts +75 -0
- package/src/run-trace.ts +273 -0
- package/src/run-warden.ts +39 -0
- package/src/run-watch.ts +432 -0
- package/src/scaffold-versions.generated.ts +12 -0
- package/src/trails/add-surface.ts +45 -23
- package/src/trails/add-trail.ts +27 -17
- package/src/trails/add-verify.ts +57 -17
- package/src/trails/completions-complete.ts +165 -0
- package/src/trails/completions.ts +47 -0
- package/src/trails/create-scaffold.ts +86 -33
- package/src/trails/create.ts +11 -3
- package/src/trails/dev-clean.ts +6 -1
- package/src/trails/dev-reset.ts +6 -1
- package/src/trails/dev-stats.ts +6 -1
- package/src/trails/dev-support.ts +29 -17
- package/src/trails/draft-promote.ts +289 -80
- package/src/trails/guide.ts +54 -34
- package/src/trails/load-app.ts +251 -56
- package/src/trails/root-dir.ts +21 -0
- package/src/trails/run-example.ts +482 -0
- package/src/trails/run-examples.ts +141 -0
- package/src/trails/run.ts +403 -0
- package/src/trails/survey.ts +506 -200
- package/src/trails/topo-activation.ts +385 -0
- package/src/trails/topo-compile.ts +55 -0
- package/src/trails/topo-history.ts +6 -1
- package/src/trails/topo-output-schemas.ts +175 -0
- package/src/trails/topo-pin.ts +19 -6
- package/src/trails/topo-read-support.ts +171 -228
- package/src/trails/topo-reports.ts +400 -25
- package/src/trails/topo-store-support.ts +43 -19
- package/src/trails/topo-support.ts +18 -28
- package/src/trails/topo-unpin.ts +6 -1
- package/src/trails/topo-verify.ts +18 -5
- package/src/trails/topo.ts +60 -23
- package/src/trails/warden-guide.ts +121 -0
- package/src/trails/warden.ts +137 -56
- package/src/versions.ts +3 -18
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/__tests__/examples.test.ts +0 -45
- package/dist/bin/trails.d.ts +0 -3
- package/dist/bin/trails.d.ts.map +0 -1
- package/dist/bin/trails.js +0 -4
- package/dist/bin/trails.js.map +0 -1
- package/dist/src/app.d.ts +0 -2
- package/dist/src/app.d.ts.map +0 -1
- package/dist/src/app.js +0 -22
- package/dist/src/app.js.map +0 -1
- package/dist/src/clack.d.ts +0 -9
- package/dist/src/clack.d.ts.map +0 -1
- package/dist/src/clack.js +0 -84
- package/dist/src/clack.js.map +0 -1
- package/dist/src/cli.d.ts +0 -2
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js +0 -14
- package/dist/src/cli.js.map +0 -1
- package/dist/src/trails/add-surface.d.ts +0 -13
- package/dist/src/trails/add-surface.d.ts.map +0 -1
- package/dist/src/trails/add-surface.js +0 -110
- package/dist/src/trails/add-surface.js.map +0 -1
- package/dist/src/trails/add-trail.d.ts +0 -12
- package/dist/src/trails/add-trail.d.ts.map +0 -1
- package/dist/src/trails/add-trail.js +0 -104
- package/dist/src/trails/add-trail.js.map +0 -1
- package/dist/src/trails/add-trailhead.d.ts +0 -13
- package/dist/src/trails/add-trailhead.d.ts.map +0 -1
- package/dist/src/trails/add-trailhead.js +0 -88
- package/dist/src/trails/add-trailhead.js.map +0 -1
- package/dist/src/trails/add-verify.d.ts +0 -10
- package/dist/src/trails/add-verify.d.ts.map +0 -1
- package/dist/src/trails/add-verify.js +0 -68
- package/dist/src/trails/add-verify.js.map +0 -1
- package/dist/src/trails/create-scaffold.d.ts +0 -15
- package/dist/src/trails/create-scaffold.d.ts.map +0 -1
- package/dist/src/trails/create-scaffold.js +0 -295
- package/dist/src/trails/create-scaffold.js.map +0 -1
- package/dist/src/trails/create.d.ts +0 -18
- package/dist/src/trails/create.d.ts.map +0 -1
- package/dist/src/trails/create.js +0 -126
- package/dist/src/trails/create.js.map +0 -1
- package/dist/src/trails/dev-clean.d.ts +0 -9
- package/dist/src/trails/dev-clean.d.ts.map +0 -1
- package/dist/src/trails/dev-clean.js +0 -66
- package/dist/src/trails/dev-clean.js.map +0 -1
- package/dist/src/trails/dev-reset.d.ts +0 -6
- package/dist/src/trails/dev-reset.d.ts.map +0 -1
- package/dist/src/trails/dev-reset.js +0 -39
- package/dist/src/trails/dev-reset.js.map +0 -1
- package/dist/src/trails/dev-stats.d.ts +0 -7
- package/dist/src/trails/dev-stats.d.ts.map +0 -1
- package/dist/src/trails/dev-stats.js +0 -61
- package/dist/src/trails/dev-stats.js.map +0 -1
- package/dist/src/trails/dev-support.d.ts +0 -64
- package/dist/src/trails/dev-support.d.ts.map +0 -1
- package/dist/src/trails/dev-support.js +0 -181
- package/dist/src/trails/dev-support.js.map +0 -1
- package/dist/src/trails/draft-promote.d.ts +0 -18
- package/dist/src/trails/draft-promote.d.ts.map +0 -1
- package/dist/src/trails/draft-promote.js +0 -400
- package/dist/src/trails/draft-promote.js.map +0 -1
- package/dist/src/trails/guide.d.ts +0 -21
- package/dist/src/trails/guide.d.ts.map +0 -1
- package/dist/src/trails/guide.js +0 -61
- package/dist/src/trails/guide.js.map +0 -1
- package/dist/src/trails/load-app.d.ts +0 -12
- package/dist/src/trails/load-app.d.ts.map +0 -1
- package/dist/src/trails/load-app.js +0 -415
- package/dist/src/trails/load-app.js.map +0 -1
- package/dist/src/trails/project.d.ts +0 -8
- package/dist/src/trails/project.d.ts.map +0 -1
- package/dist/src/trails/project.js +0 -54
- package/dist/src/trails/project.js.map +0 -1
- package/dist/src/trails/survey.d.ts +0 -18
- package/dist/src/trails/survey.d.ts.map +0 -1
- package/dist/src/trails/survey.js +0 -234
- package/dist/src/trails/survey.js.map +0 -1
- package/dist/src/trails/topo-constants.d.ts +0 -3
- package/dist/src/trails/topo-constants.d.ts.map +0 -1
- package/dist/src/trails/topo-constants.js +0 -3
- package/dist/src/trails/topo-constants.js.map +0 -1
- package/dist/src/trails/topo-export.d.ts +0 -19
- package/dist/src/trails/topo-export.d.ts.map +0 -1
- package/dist/src/trails/topo-export.js +0 -31
- package/dist/src/trails/topo-export.js.map +0 -1
- package/dist/src/trails/topo-history.d.ts +0 -20
- package/dist/src/trails/topo-history.d.ts.map +0 -1
- package/dist/src/trails/topo-history.js +0 -32
- package/dist/src/trails/topo-history.js.map +0 -1
- package/dist/src/trails/topo-pin.d.ts +0 -17
- package/dist/src/trails/topo-pin.d.ts.map +0 -1
- package/dist/src/trails/topo-pin.js +0 -31
- package/dist/src/trails/topo-pin.js.map +0 -1
- package/dist/src/trails/topo-read-support.d.ts +0 -58
- package/dist/src/trails/topo-read-support.d.ts.map +0 -1
- package/dist/src/trails/topo-read-support.js +0 -167
- package/dist/src/trails/topo-read-support.js.map +0 -1
- package/dist/src/trails/topo-reports.d.ts +0 -54
- package/dist/src/trails/topo-reports.d.ts.map +0 -1
- package/dist/src/trails/topo-reports.js +0 -128
- package/dist/src/trails/topo-reports.js.map +0 -1
- package/dist/src/trails/topo-show.d.ts +0 -23
- package/dist/src/trails/topo-show.d.ts.map +0 -1
- package/dist/src/trails/topo-show.js +0 -49
- package/dist/src/trails/topo-show.js.map +0 -1
- package/dist/src/trails/topo-store-support.d.ts +0 -13
- package/dist/src/trails/topo-store-support.d.ts.map +0 -1
- package/dist/src/trails/topo-store-support.js +0 -55
- package/dist/src/trails/topo-store-support.js.map +0 -1
- package/dist/src/trails/topo-support.d.ts +0 -76
- package/dist/src/trails/topo-support.d.ts.map +0 -1
- package/dist/src/trails/topo-support.js +0 -132
- package/dist/src/trails/topo-support.js.map +0 -1
- package/dist/src/trails/topo-unpin.d.ts +0 -20
- package/dist/src/trails/topo-unpin.d.ts.map +0 -1
- package/dist/src/trails/topo-unpin.js +0 -44
- package/dist/src/trails/topo-unpin.js.map +0 -1
- package/dist/src/trails/topo-verify.d.ts +0 -5
- package/dist/src/trails/topo-verify.d.ts.map +0 -1
- package/dist/src/trails/topo-verify.js +0 -24
- package/dist/src/trails/topo-verify.js.map +0 -1
- package/dist/src/trails/topo.d.ts +0 -5
- package/dist/src/trails/topo.d.ts.map +0 -1
- package/dist/src/trails/topo.js +0 -63
- package/dist/src/trails/topo.js.map +0 -1
- package/dist/src/trails/warden.d.ts +0 -20
- package/dist/src/trails/warden.d.ts.map +0 -1
- package/dist/src/trails/warden.js +0 -98
- package/dist/src/trails/warden.js.map +0 -1
- package/dist/src/versions.d.ts +0 -12
- package/dist/src/versions.d.ts.map +0 -1
- package/dist/src/versions.js +0 -23
- package/dist/src/versions.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/__tests__/add-trail.test.ts +0 -97
- package/src/__tests__/create.test.ts +0 -415
- package/src/__tests__/draft-promote.test.ts +0 -144
- package/src/__tests__/guide.test.ts +0 -96
- package/src/__tests__/load-app.test.ts +0 -419
- package/src/__tests__/survey.test.ts +0 -377
- package/src/__tests__/topo-dev.test.ts +0 -426
- package/src/__tests__/warden.test.ts +0 -74
- package/src/trails/topo-export.ts +0 -35
- package/src/trails/topo-show.ts +0 -54
- package/tsconfig.json +0 -9
- package/tsconfig.tests.json +0 -10
|
@@ -0,0 +1,240 @@
|
|
|
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 the
|
|
15
|
+
* workspace trail index for accurate, live suggestions.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
deriveStructuredTrailExamples,
|
|
20
|
+
RecoverableCompletionError,
|
|
21
|
+
Result,
|
|
22
|
+
ValidationError,
|
|
23
|
+
} from '@ontrails/core';
|
|
24
|
+
import { buildWorkspaceTrailIndex } from '@ontrails/topographer';
|
|
25
|
+
|
|
26
|
+
import { tryLoadFreshAppLease } from './trails/load-app.js';
|
|
27
|
+
|
|
28
|
+
/** Shells supported by the completion generator. */
|
|
29
|
+
export type CompletionShell = 'bash' | 'zsh' | 'fish';
|
|
30
|
+
|
|
31
|
+
type ScriptRenderer = (binName: string) => string;
|
|
32
|
+
|
|
33
|
+
const renderBashScript: ScriptRenderer = (binName) =>
|
|
34
|
+
`# ${binName} bash completion
|
|
35
|
+
_${binName}_complete() {
|
|
36
|
+
local cur words
|
|
37
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
38
|
+
words=("\${COMP_WORDS[@]:1:COMP_CWORD}")
|
|
39
|
+
COMPREPLY=()
|
|
40
|
+
while IFS= read -r suggestion; do
|
|
41
|
+
COMPREPLY+=("$suggestion")
|
|
42
|
+
done < <(${binName} completions __complete "\${words[@]}" 2>/dev/null)
|
|
43
|
+
return 0
|
|
44
|
+
}
|
|
45
|
+
complete -F _${binName}_complete ${binName}
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
const renderZshScript: ScriptRenderer = (binName) =>
|
|
49
|
+
`#compdef ${binName}
|
|
50
|
+
# ${binName} zsh completion
|
|
51
|
+
_${binName}_complete() {
|
|
52
|
+
local -a suggestions trail_words
|
|
53
|
+
local output
|
|
54
|
+
trail_words=("\${(@)words[2,CURRENT]}")
|
|
55
|
+
output="$(${binName} completions __complete "\${trail_words[@]}" 2>/dev/null)"
|
|
56
|
+
if [[ -n "$output" ]]; then
|
|
57
|
+
suggestions=("\${(@f)output}")
|
|
58
|
+
if (( \${#suggestions} )); then
|
|
59
|
+
compadd -- "\${suggestions[@]}"
|
|
60
|
+
fi
|
|
61
|
+
fi
|
|
62
|
+
}
|
|
63
|
+
compdef _${binName}_complete ${binName}
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
const renderFishScript: ScriptRenderer = (binName) =>
|
|
67
|
+
`# ${binName} fish completion
|
|
68
|
+
function __${binName}_complete
|
|
69
|
+
set -l tokens (commandline -opc) (commandline -ct)
|
|
70
|
+
set -e tokens[1]
|
|
71
|
+
${binName} completions __complete $tokens 2>/dev/null
|
|
72
|
+
end
|
|
73
|
+
complete -c ${binName} -f -a '(__${binName}_complete)'
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
const SCRIPT_RENDERERS: Readonly<Record<CompletionShell, ScriptRenderer>> = {
|
|
77
|
+
bash: renderBashScript,
|
|
78
|
+
fish: renderFishScript,
|
|
79
|
+
zsh: renderZshScript,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Pattern that `binName` must match — alphanumerics, underscore, hyphen. */
|
|
83
|
+
const BIN_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
84
|
+
|
|
85
|
+
const recoverableCompletionError = (
|
|
86
|
+
message: string,
|
|
87
|
+
context: Record<string, unknown>,
|
|
88
|
+
cause?: unknown
|
|
89
|
+
): RecoverableCompletionError =>
|
|
90
|
+
new RecoverableCompletionError(message, {
|
|
91
|
+
...(cause instanceof Error ? { cause } : {}),
|
|
92
|
+
context,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Render a static shell completion script that delegates dynamic completion
|
|
97
|
+
* to `<binName> completions __complete <args...>`.
|
|
98
|
+
*
|
|
99
|
+
* @param shell - target shell flavor.
|
|
100
|
+
* @param binName - binary name to register the completion against (typically
|
|
101
|
+
* `'trails'`). Used both as the registered command and as the prefix for the
|
|
102
|
+
* shell function name. Must match `/^[a-zA-Z0-9_-]+$/` — the value is
|
|
103
|
+
* interpolated verbatim into shell source, so any non-trivial input would
|
|
104
|
+
* be a shell-injection vector. We validate at the boundary per
|
|
105
|
+
* "validate at the boundary, trust internally" (docs/tenets.md).
|
|
106
|
+
*/
|
|
107
|
+
export const renderCompletionScript = (
|
|
108
|
+
shell: CompletionShell,
|
|
109
|
+
binName: string
|
|
110
|
+
): Result<string, ValidationError> => {
|
|
111
|
+
if (!BIN_NAME_PATTERN.test(binName)) {
|
|
112
|
+
return Result.err(
|
|
113
|
+
new ValidationError(
|
|
114
|
+
`renderCompletionScript: binName must match /^[a-zA-Z0-9_-]+$/ (got: ${JSON.stringify(binName)})`
|
|
115
|
+
)
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return Result.ok(SCRIPT_RENDERERS[shell](binName));
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Read trail IDs from the live workspace topo and return those matching
|
|
123
|
+
* `prefix`, sorted lexicographically. Includes IDs that collide across multiple
|
|
124
|
+
* apps — the shell only needs the unique set of identifiers, not their owners.
|
|
125
|
+
*
|
|
126
|
+
* @param workspaceRoot - workspace root directory used to resolve apps.
|
|
127
|
+
* @param prefix - prefix to filter by; an empty prefix returns every ID.
|
|
128
|
+
*/
|
|
129
|
+
export const renderTrailIdCompletions = async (
|
|
130
|
+
workspaceRoot: string,
|
|
131
|
+
prefix: string
|
|
132
|
+
): Promise<readonly string[]> => {
|
|
133
|
+
let result: Awaited<ReturnType<typeof buildWorkspaceTrailIndex>>;
|
|
134
|
+
try {
|
|
135
|
+
result = await buildWorkspaceTrailIndex({ cwd: workspaceRoot });
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
const ids = new Set<string>(Object.keys(result.index));
|
|
140
|
+
for (const collision of result.collisions) {
|
|
141
|
+
ids.add(collision.trailId);
|
|
142
|
+
}
|
|
143
|
+
const matching: string[] = [];
|
|
144
|
+
for (const id of ids) {
|
|
145
|
+
if (id.startsWith(prefix)) {
|
|
146
|
+
matching.push(id);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
matching.sort((a, b) => {
|
|
150
|
+
if (a < b) {
|
|
151
|
+
return -1;
|
|
152
|
+
}
|
|
153
|
+
if (a > b) {
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
return 0;
|
|
157
|
+
});
|
|
158
|
+
return matching;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Example name completion
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Return example names for `trailId` matching `prefix`, sorted lexicographically.
|
|
167
|
+
*
|
|
168
|
+
* Looks the trail up via the workspace index (TRL-404), resolves its owning
|
|
169
|
+
* app module from the enriched index, loads the app's topo, and reads the
|
|
170
|
+
* `name` of every structured example.
|
|
171
|
+
*
|
|
172
|
+
* Completion is best-effort for shell callers, but this helper preserves
|
|
173
|
+
* load-time failures as `RecoverableCompletionError` so the internal bridge can
|
|
174
|
+
* decide whether to suppress them for prompt safety.
|
|
175
|
+
*/
|
|
176
|
+
export const renderTrailExampleCompletions = async (
|
|
177
|
+
workspaceRoot: string,
|
|
178
|
+
trailId: string,
|
|
179
|
+
prefix: string
|
|
180
|
+
): Promise<Result<readonly string[], RecoverableCompletionError>> => {
|
|
181
|
+
try {
|
|
182
|
+
const { index } = await buildWorkspaceTrailIndex({ cwd: workspaceRoot });
|
|
183
|
+
const owner = index[trailId];
|
|
184
|
+
if (owner === undefined) {
|
|
185
|
+
return Result.ok([]);
|
|
186
|
+
}
|
|
187
|
+
const leaseResult = await tryLoadFreshAppLease(
|
|
188
|
+
owner.modulePath,
|
|
189
|
+
workspaceRoot
|
|
190
|
+
);
|
|
191
|
+
if (leaseResult.isErr()) {
|
|
192
|
+
return Result.err(
|
|
193
|
+
recoverableCompletionError(
|
|
194
|
+
'Cannot load app while completing example names',
|
|
195
|
+
{ modulePath: owner.modulePath, trailId, workspaceRoot },
|
|
196
|
+
leaseResult.error
|
|
197
|
+
)
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const lease = leaseResult.value;
|
|
201
|
+
try {
|
|
202
|
+
const target = lease.app.get(trailId);
|
|
203
|
+
if (target === undefined) {
|
|
204
|
+
return Result.err(
|
|
205
|
+
recoverableCompletionError(
|
|
206
|
+
'Indexed trail was not found in loaded app while completing example names',
|
|
207
|
+
{ modulePath: owner.modulePath, trailId, workspaceRoot }
|
|
208
|
+
)
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
const structured = deriveStructuredTrailExamples(target.examples) ?? [];
|
|
212
|
+
const matching: string[] = [];
|
|
213
|
+
for (const example of structured) {
|
|
214
|
+
if (example.name.startsWith(prefix)) {
|
|
215
|
+
matching.push(example.name);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
matching.sort((a, b) => {
|
|
219
|
+
if (a < b) {
|
|
220
|
+
return -1;
|
|
221
|
+
}
|
|
222
|
+
if (a > b) {
|
|
223
|
+
return 1;
|
|
224
|
+
}
|
|
225
|
+
return 0;
|
|
226
|
+
});
|
|
227
|
+
return Result.ok(matching);
|
|
228
|
+
} finally {
|
|
229
|
+
lease.release();
|
|
230
|
+
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return Result.err(
|
|
233
|
+
recoverableCompletionError(
|
|
234
|
+
'Cannot resolve workspace while completing example names',
|
|
235
|
+
{ trailId, workspaceRoot },
|
|
236
|
+
error
|
|
237
|
+
)
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
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 resolveAbsoluteSourcePath = (
|
|
48
|
+
sourcePath: string
|
|
49
|
+
): TrailsResult<string, ValidationError> =>
|
|
50
|
+
isAbsolute(sourcePath)
|
|
51
|
+
? Result.ok(sourcePath)
|
|
52
|
+
: Result.err(
|
|
53
|
+
new ValidationError(
|
|
54
|
+
`Load-app mirror source path must be absolute: "${sourcePath}"`,
|
|
55
|
+
{ context: { sourcePath } }
|
|
56
|
+
)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Convert an absolute source path to the deterministic location inside a
|
|
61
|
+
* load-app fresh mirror.
|
|
62
|
+
*/
|
|
63
|
+
export const resolveLoadAppMirrorFilePath = (
|
|
64
|
+
sourcePath: string,
|
|
65
|
+
mirrorRoot: string
|
|
66
|
+
): TrailsResult<string, Error> => {
|
|
67
|
+
const root = validateMirrorRoot(mirrorRoot);
|
|
68
|
+
if (root.isErr()) {
|
|
69
|
+
return root;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const source = resolveAbsoluteSourcePath(sourcePath);
|
|
73
|
+
if (source.isErr()) {
|
|
74
|
+
return source;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const mirrorRelativePath = relative(
|
|
78
|
+
parsePath(source.value).root,
|
|
79
|
+
source.value
|
|
80
|
+
);
|
|
81
|
+
return deriveSafePath(root.value, mirrorRelativePath);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Copy a source file into its load-app fresh mirror by raw bytes.
|
|
86
|
+
*
|
|
87
|
+
* @remarks
|
|
88
|
+
* Reading via `.bytes()` rather than `.text()` preserves binary payloads
|
|
89
|
+
* (`.wasm`, `.node`, compiled assets) that may sit alongside source files in
|
|
90
|
+
* the app's graph. Text decoding would corrupt them on the way through the
|
|
91
|
+
* mirror.
|
|
92
|
+
*/
|
|
93
|
+
export const writeLoadAppMirrorFile = async (
|
|
94
|
+
sourcePath: string,
|
|
95
|
+
mirrorRoot: string
|
|
96
|
+
): Promise<TrailsResult<string, Error>> => {
|
|
97
|
+
const mirrorPath = resolveLoadAppMirrorFilePath(sourcePath, mirrorRoot);
|
|
98
|
+
if (mirrorPath.isErr()) {
|
|
99
|
+
return mirrorPath;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
mkdirSync(dirname(mirrorPath.value), { recursive: true });
|
|
104
|
+
const bytes = await Bun.file(sourcePath).bytes();
|
|
105
|
+
await Bun.write(mirrorPath.value, bytes);
|
|
106
|
+
return Result.ok(mirrorPath.value);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return Result.err(
|
|
109
|
+
new InternalError(`Failed to mirror load-app file "${sourcePath}"`, {
|
|
110
|
+
cause: asError(error),
|
|
111
|
+
context: { mirrorPath: mirrorPath.value, mirrorRoot, sourcePath },
|
|
112
|
+
})
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const removeLoadAppMirrorRoot = (
|
|
118
|
+
mirrorRoot: string
|
|
119
|
+
): TrailsResult<void, Error> => {
|
|
120
|
+
const root = validateMirrorRoot(mirrorRoot);
|
|
121
|
+
if (root.isErr()) {
|
|
122
|
+
return root;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
rmSync(root.value, { force: true, recursive: true });
|
|
127
|
+
return Result.ok();
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return Result.err(
|
|
130
|
+
new InternalError(`Failed to remove load-app mirror "${mirrorRoot}"`, {
|
|
131
|
+
cause: asError(error),
|
|
132
|
+
context: { mirrorRoot: root.value },
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Best-effort cleanup for process-exit and stale-sweep paths.
|
|
140
|
+
*
|
|
141
|
+
* This intentionally suppresses validation and filesystem failures because the
|
|
142
|
+
* caller is already abandoning a temporary mirror and cleanup must not turn
|
|
143
|
+
* into an application-load failure.
|
|
144
|
+
*/
|
|
145
|
+
export const removeLoadAppMirrorRootQuietly = (mirrorRoot: string): void => {
|
|
146
|
+
try {
|
|
147
|
+
removeLoadAppMirrorRoot(mirrorRoot);
|
|
148
|
+
} catch {
|
|
149
|
+
// Best-effort cleanup must never become the failure path.
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const createLoadAppMirrorRootPath = (cwd: string): string =>
|
|
154
|
+
join(
|
|
155
|
+
resolve(cwd),
|
|
156
|
+
LOAD_APP_MIRROR_PARENT_DIRNAME,
|
|
157
|
+
`${LOAD_APP_MIRROR_ENTRY_PREFIX}${Date.now()}-${Math.random()
|
|
158
|
+
.toString(36)
|
|
159
|
+
.slice(2)}`
|
|
160
|
+
);
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
deriveSafePath,
|
|
8
|
+
InternalError,
|
|
9
|
+
Result,
|
|
10
|
+
ValidationError,
|
|
11
|
+
} from '@ontrails/core';
|
|
12
|
+
import type { Result as TrailsResult } from '@ontrails/core';
|
|
13
|
+
|
|
14
|
+
const EXAMPLE_ROOT_PARENT = join(tmpdir(), 'ontrails-trails-examples');
|
|
15
|
+
const EXAMPLE_ROOT_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/u;
|
|
16
|
+
|
|
17
|
+
const asError = (error: unknown): Error =>
|
|
18
|
+
error instanceof Error ? error : new Error(String(error));
|
|
19
|
+
|
|
20
|
+
const resolveExampleRoot = (name: string): TrailsResult<string, Error> => {
|
|
21
|
+
if (!EXAMPLE_ROOT_NAME_PATTERN.test(name)) {
|
|
22
|
+
return Result.err(
|
|
23
|
+
new ValidationError(
|
|
24
|
+
'Example root name must be lowercase and contain only letters, digits, ".", "_", or "-".',
|
|
25
|
+
{ context: { name } }
|
|
26
|
+
)
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return deriveSafePath(EXAMPLE_ROOT_PARENT, name);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const createIsolatedExampleRoot = (name: string): string => {
|
|
34
|
+
const root = resolveExampleRoot(name);
|
|
35
|
+
if (root.isErr()) {
|
|
36
|
+
throw root.error;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
rmSync(root.value, { force: true, recursive: true });
|
|
41
|
+
mkdirSync(root.value, { recursive: true });
|
|
42
|
+
return root.value;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new InternalError(`Failed to recreate example root "${name}"`, {
|
|
45
|
+
cause: asError(error),
|
|
46
|
+
context: { name, rootDir: root.value },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const writeIsolatedExampleAppModule = (
|
|
52
|
+
rootDir: string,
|
|
53
|
+
sourceModulePath: string
|
|
54
|
+
): string => {
|
|
55
|
+
if (!isAbsolute(sourceModulePath)) {
|
|
56
|
+
throw new ValidationError(
|
|
57
|
+
'Example app source module path must be absolute.',
|
|
58
|
+
{
|
|
59
|
+
context: { rootDir, sourceModulePath },
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const modulePath = './src/app.ts';
|
|
65
|
+
const target = deriveSafePath(rootDir, modulePath);
|
|
66
|
+
if (target.isErr()) {
|
|
67
|
+
throw target.error;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
mkdirSync(dirname(target.value), { recursive: true });
|
|
72
|
+
writeFileSync(
|
|
73
|
+
target.value,
|
|
74
|
+
`export { app } from ${JSON.stringify(pathToFileURL(sourceModulePath).href)};\n`
|
|
75
|
+
);
|
|
76
|
+
return modulePath;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new InternalError('Failed to write isolated example app module', {
|
|
79
|
+
cause: asError(error),
|
|
80
|
+
context: { rootDir, sourceModulePath, targetPath: target.value },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const writeIsolatedExampleJsonFile = (
|
|
86
|
+
rootDir: string,
|
|
87
|
+
relativePath: string,
|
|
88
|
+
value: unknown
|
|
89
|
+
): string => {
|
|
90
|
+
const target = deriveSafePath(rootDir, relativePath);
|
|
91
|
+
if (target.isErr()) {
|
|
92
|
+
throw target.error;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
mkdirSync(dirname(target.value), { recursive: true });
|
|
97
|
+
writeFileSync(target.value, `${JSON.stringify(value, null, 2)}\n`);
|
|
98
|
+
return relativePath;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new InternalError('Failed to write isolated example JSON file', {
|
|
101
|
+
cause: asError(error),
|
|
102
|
+
context: { relativePath, rootDir, targetPath: target.value },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const writeIsolatedExampleTextFile = (
|
|
108
|
+
rootDir: string,
|
|
109
|
+
relativePath: string,
|
|
110
|
+
contents: string
|
|
111
|
+
): string => {
|
|
112
|
+
const target = deriveSafePath(rootDir, relativePath);
|
|
113
|
+
if (target.isErr()) {
|
|
114
|
+
throw target.error;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
mkdirSync(dirname(target.value), { recursive: true });
|
|
119
|
+
writeFileSync(target.value, contents);
|
|
120
|
+
return relativePath;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
throw new InternalError('Failed to write isolated example text file', {
|
|
123
|
+
cause: asError(error),
|
|
124
|
+
context: { relativePath, rootDir, targetPath: target.value },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const removeRootRelativeFileIfPresent = (
|
|
130
|
+
rootDir: string,
|
|
131
|
+
relativePath: string
|
|
132
|
+
): TrailsResult<boolean, Error> => {
|
|
133
|
+
const target = deriveSafePath(rootDir, relativePath);
|
|
134
|
+
if (target.isErr()) {
|
|
135
|
+
return target;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!existsSync(target.value)) {
|
|
139
|
+
return Result.ok(false);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
rmSync(target.value, { force: true });
|
|
144
|
+
return Result.ok(true);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return Result.err(
|
|
147
|
+
new InternalError(`Failed to remove local state file "${relativePath}"`, {
|
|
148
|
+
cause: asError(error),
|
|
149
|
+
context: { relativePath, rootDir, targetPath: target.value },
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
};
|