@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.
- package/CHANGELOG.md +1906 -0
- package/README.md +48 -0
- package/bin/trails.ts +3 -0
- package/package.json +57 -0
- package/src/app.ts +167 -0
- package/src/clack.ts +111 -0
- package/src/cli.ts +308 -0
- package/src/completions.ts +431 -0
- package/src/lifecycle-source-io.ts +33 -0
- package/src/load-app-mirror.ts +202 -0
- package/src/local-state-io.ts +129 -0
- package/src/mcp-app.ts +42 -0
- package/src/mcp-options.ts +92 -0
- package/src/mcp.ts +8 -0
- package/src/project-writes.ts +377 -0
- package/src/regrade/audit.ts +571 -0
- package/src/regrade/config.ts +152 -0
- package/src/regrade/history.ts +636 -0
- package/src/regrade/lifecycle.ts +76 -0
- package/src/regrade/live-api-preserve.ts +123 -0
- package/src/regrade/plan-artifact.ts +515 -0
- package/src/regrade/plan-derivation.ts +301 -0
- package/src/regrade/prepared-run.ts +259 -0
- package/src/regrade/receipt-history.ts +446 -0
- package/src/regrade/source-transaction.ts +185 -0
- package/src/release/bindings.ts +58 -0
- package/src/release/changeset-packages.ts +99 -0
- package/src/release/check.ts +1191 -0
- package/src/release/cli-bundle.ts +575 -0
- package/src/release/config.ts +73 -0
- package/src/release/contract-facts.ts +425 -0
- package/src/release/homebrew.ts +221 -0
- package/src/release/index.ts +180 -0
- package/src/release/lock-roundtrip-smoke.ts +255 -0
- package/src/release/lock-roundtrip-workspace.ts +107 -0
- package/src/release/native-bun-publish.ts +964 -0
- package/src/release/native-bun-registry.ts +848 -0
- package/src/release/notes-cli.ts +171 -0
- package/src/release/notes.ts +390 -0
- package/src/release/pack-coherence.ts +455 -0
- package/src/release/package-route-facts.ts +146 -0
- package/src/release/packed-artifacts-smoke.ts +236 -0
- package/src/release/policy.ts +1780 -0
- package/src/release/semver.ts +104 -0
- package/src/release/smoke.ts +56 -0
- package/src/release/stable-version-release.ts +80 -0
- package/src/release/wayfinder-dogfood-smoke.ts +762 -0
- package/src/release/zero-line-transition.ts +68 -0
- package/src/retired-topo-command.ts +36 -0
- package/src/run-adapter-check.ts +76 -0
- package/src/run-argv.ts +133 -0
- package/src/run-collision.ts +126 -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-regrade-progress.ts +47 -0
- package/src/run-release-check.ts +74 -0
- package/src/run-schema.ts +74 -0
- package/src/run-trace.ts +273 -0
- package/src/run-warden.ts +39 -0
- package/src/run-watch-project.ts +52 -0
- package/src/run-watch.ts +381 -0
- package/src/run-wayfind-outline.ts +170 -0
- package/src/scaffold-version-sync.ts +183 -0
- package/src/scaffold-versions.generated.ts +12 -0
- package/src/trails/adapter-check.ts +244 -0
- package/src/trails/add-surface.ts +816 -0
- package/src/trails/add-trail.ts +141 -0
- package/src/trails/add-verify.ts +252 -0
- package/src/trails/compile.ts +118 -0
- package/src/trails/completions-complete.ts +236 -0
- package/src/trails/completions.ts +47 -0
- package/src/trails/config-explain.ts +43 -0
- package/src/trails/create-adapter.ts +785 -0
- package/src/trails/create-scaffold.ts +1215 -0
- package/src/trails/create-versions.ts +62 -0
- package/src/trails/create.ts +652 -0
- package/src/trails/deprecate.ts +59 -0
- package/src/trails/dev-clean.ts +80 -0
- package/src/trails/dev-reset.ts +48 -0
- package/src/trails/dev-stats.ts +71 -0
- package/src/trails/dev-support.ts +360 -0
- package/src/trails/doctor.ts +77 -0
- package/src/trails/draft-promote.ts +949 -0
- package/src/trails/guide.ts +106 -0
- package/src/trails/load-app.ts +1145 -0
- package/src/trails/operator-context.ts +66 -0
- package/src/trails/project-context-output.ts +304 -0
- package/src/trails/project-context.ts +613 -0
- package/src/trails/project.ts +65 -0
- package/src/trails/regrade.ts +4951 -0
- package/src/trails/release-check.ts +113 -0
- package/src/trails/release-smoke.ts +49 -0
- package/src/trails/revise.ts +53 -0
- package/src/trails/root-dir.ts +21 -0
- package/src/trails/run-example.ts +592 -0
- package/src/trails/run-examples.ts +149 -0
- package/src/trails/run.ts +496 -0
- package/src/trails/scaffold-json.ts +60 -0
- package/src/trails/scaffold-topo-identity.ts +479 -0
- package/src/trails/survey.ts +990 -0
- package/src/trails/topo-activation.ts +14 -0
- package/src/trails/topo-constants.ts +2 -0
- package/src/trails/topo-history.ts +47 -0
- package/src/trails/topo-output-schemas.ts +259 -0
- package/src/trails/topo-pin.ts +38 -0
- package/src/trails/topo-read-support.ts +368 -0
- package/src/trails/topo-reports.ts +809 -0
- package/src/trails/topo-store-support.ts +323 -0
- package/src/trails/topo-support.ts +247 -0
- package/src/trails/topo-unpin.ts +61 -0
- package/src/trails/topo.ts +92 -0
- package/src/trails/validate.ts +348 -0
- package/src/trails/version-lifecycle-support.ts +936 -0
- package/src/trails/warden-guide.ts +134 -0
- package/src/trails/warden.ts +598 -0
- package/src/trails/wayfind-diff.ts +716 -0
- package/src/trails/wayfind-outline.ts +876 -0
- package/src/trails/wayfind.ts +1319 -0
- 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
|
+
);
|