@jr2/cli 0.3.0 → 0.5.1
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/README.md +4 -3
- package/bin/jr2.js +3 -3
- package/manifests/operator.yaml +265 -23
- package/package.json +5 -5
- package/src/agents-md.ts +149 -0
- package/src/assembly.ts +432 -0
- package/src/build.ts +145 -290
- package/src/cli.ts +20 -41
- package/src/client.ts +144 -33
- package/src/command-table.ts +641 -0
- package/src/commands/check.ts +37 -0
- package/src/commands/down.ts +7 -16
- package/src/commands/gc.ts +2 -11
- package/src/commands/init.ts +82 -19
- package/src/commands/kit.ts +6 -11
- package/src/commands/logs.ts +13 -9
- package/src/commands/run.ts +70 -17
- package/src/commands/runs.ts +83 -6
- package/src/commands/send.ts +22 -23
- package/src/commands/status.ts +134 -22
- package/src/commands/up.ts +1704 -386
- package/src/commands/version.ts +26 -25
- package/src/deploy.ts +880 -101
- package/src/effector-image.ts +133 -0
- package/src/index.ts +26 -0
- package/src/instance-check.ts +309 -0
- package/src/instance.ts +3 -9
- package/src/kit-version.ts +27 -17
- package/src/kube.ts +439 -70
- package/src/nodes.ts +33 -2
- package/src/output.ts +1 -1
- package/src/repo-sweep.ts +1 -1
- package/src/sweep.ts +30 -15
package/src/assembly.ts
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
// The Assembly, staged on the host (ADR-0069). `jr2 up` materializes the Instance with the bundle
|
|
2
|
+
// port, strips the Kit's packages out of it, seals it, and hashes it. The result is the tree that
|
|
3
|
+
// `jr2 up` packs and publishes to the door. The door recomputes the same hash with the same
|
|
4
|
+
// function and salt, so the two ends agree on one name for one Assembly.
|
|
5
|
+
//
|
|
6
|
+
// Why strip: the Orchestrator Kit image ships `@jr2/orchestrator` and its whole dependency closure
|
|
7
|
+
// at `/instance/node_modules`. An Assembly lives at `/instance/.jr2/assemblies/<hash>/`, so Node's
|
|
8
|
+
// bare-specifier walk from any file in it reaches the image's copies, but only when the Assembly
|
|
9
|
+
// holds no copy of its own. A second copy of `@jr2/orchestrator`, `xstate` or `zod` is a Machine
|
|
10
|
+
// whose Menus are empty with no error anywhere (ADR-0054). So the closure goes, and a copy that the
|
|
11
|
+
// strip cannot remove is a refusal, never a silent shadow.
|
|
12
|
+
|
|
13
|
+
import { lstat, mkdtemp, readdir, readFile, readlink, realpath, rm, stat } from "node:fs/promises";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
16
|
+
import { assemblyHash, discoverModules, KIT_VERSION } from "@jr2/orchestrator/internal";
|
|
17
|
+
import { lockfileInstall, type BuildPort } from "./build.ts";
|
|
18
|
+
|
|
19
|
+
const ORCHESTRATOR = "@jr2/orchestrator";
|
|
20
|
+
|
|
21
|
+
/** A staged Assembly: the exact tree `jr2 up` packs and publishes, and its address. */
|
|
22
|
+
export type StagedAssembly = {
|
|
23
|
+
/** The stripped, sealed tree. */
|
|
24
|
+
dir: string;
|
|
25
|
+
/** `assemblyHash(dir)`: the name the door recomputes and stores it under. */
|
|
26
|
+
hash: string;
|
|
27
|
+
/** The `@jr2/orchestrator` version the Assembly was written against. Equal to this CLI's
|
|
28
|
+
* `KIT_VERSION`, or staging refuses (ADR-0056, ADR-0069). */
|
|
29
|
+
kitVersion: string;
|
|
30
|
+
/** The Workflow names the door will register from `workflows/`. */
|
|
31
|
+
workflows: string[];
|
|
32
|
+
/** Remove the scratch directory. Always call it. */
|
|
33
|
+
dispose(): Promise<void>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Stage the Instance at `instanceDir` as an Assembly (ADR-0069). The steps run in this order, and
|
|
38
|
+
* each refusal names the path and the fix:
|
|
39
|
+
*
|
|
40
|
+
* 1. `port.bundle` materializes the Instance and its production dependencies (ADR-0043).
|
|
41
|
+
* 2. pnpm's two staging records go. They record where and when the staging ran, not content.
|
|
42
|
+
* 3. The Kit version is read from the staged `@jr2/orchestrator` and must equal this CLI's.
|
|
43
|
+
* 4. The Kit closure is stripped ({@link stripKitClosure}).
|
|
44
|
+
* 5. Every `node_modules/.bin` goes: the Orchestrator runs no bin from an Assembly.
|
|
45
|
+
* 6. The seal is checked ({@link checkSealed}). Nothing is rewritten.
|
|
46
|
+
* 7. The tree is hashed with nothing excluded (ADR-0038), and its Workflows are listed.
|
|
47
|
+
*/
|
|
48
|
+
export async function stageAssembly(port: BuildPort, instanceDir: string): Promise<StagedAssembly> {
|
|
49
|
+
const scratch = await mkdtemp(join(tmpdir(), "jr2-assembly-"));
|
|
50
|
+
const dir = join(scratch, "assembly");
|
|
51
|
+
const dispose = () => rm(scratch, { recursive: true, force: true });
|
|
52
|
+
try {
|
|
53
|
+
await port.bundle(instanceDir, dir);
|
|
54
|
+
// `.modules.yaml` holds a `prunedAt` stamp and the scratch paths; `.pnpm-workspace-state.json`
|
|
55
|
+
// holds a `lastValidatedTimestamp`. Either one alone gives every staging a new hash.
|
|
56
|
+
for (const record of [".modules.yaml", ".pnpm-workspace-state.json"]) {
|
|
57
|
+
await rm(join(dir, "node_modules", record), { force: true });
|
|
58
|
+
}
|
|
59
|
+
const kitVersion = await readKitVersion(dir, instanceDir);
|
|
60
|
+
await stripKitClosure(dir);
|
|
61
|
+
await removeBinDirs(dir);
|
|
62
|
+
await checkSealed(dir);
|
|
63
|
+
const hash = await assemblyHash(dir);
|
|
64
|
+
const workflows = (await discoverModules(join(dir, "workflows"))).map((m) => m.name);
|
|
65
|
+
return { dir, hash, kitVersion, workflows, dispose };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
await dispose();
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Step 3: the version the Assembly was written against. ADR-0056's refusal, moved here; the door
|
|
73
|
+
* repeats it, so a publish with a CLI of another version never reaches an import. */
|
|
74
|
+
async function readKitVersion(dir: string, instanceDir: string): Promise<string> {
|
|
75
|
+
const manifest = join(dir, "node_modules", ORCHESTRATOR, "package.json");
|
|
76
|
+
let text: string | undefined;
|
|
77
|
+
try {
|
|
78
|
+
text = await readFile(manifest, "utf8");
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
81
|
+
}
|
|
82
|
+
let version: string | undefined;
|
|
83
|
+
if (text !== undefined) {
|
|
84
|
+
try {
|
|
85
|
+
version = (JSON.parse(text) as { version?: string }).version;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`the Assembly staged from ${instanceDir} has a ${ORCHESTRATOR} whose manifest does not parse ` +
|
|
89
|
+
`(node_modules/${ORCHESTRATOR}/package.json: ${(err as Error).message}) — reinstall ` +
|
|
90
|
+
`${ORCHESTRATOR}@${KIT_VERSION} in ${instanceDir}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (version === undefined) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`the Assembly staged from ${instanceDir} has no ${ORCHESTRATOR} (no node_modules/${ORCHESTRATOR}/package.json) — ` +
|
|
97
|
+
`an Assembly names the Kit version it was written against; run ` +
|
|
98
|
+
`\`${await addCommand(instanceDir)} ${ORCHESTRATOR}@${KIT_VERSION}\` in ${instanceDir} and commit the lockfile`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (version !== KIT_VERSION) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Kit version mismatch — an Instance has one (ADR-0056, ADR-0069): the Assembly staged from ${instanceDir} ` +
|
|
104
|
+
`resolves ${ORCHESTRATOR} ${version}, and this jr2 is ${KIT_VERSION}; pin "${ORCHESTRATOR}" and ` +
|
|
105
|
+
`"@jr2/cli" at ONE exact version in ${join(instanceDir, "package.json")} and reinstall`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return version;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The add command of the package manager that installs this Instance, so the fix in a refusal
|
|
112
|
+
* does not write a second lockfile. An Instance with no lockfile of its own stages through `pnpm
|
|
113
|
+
* deploy` in its workspace (ADR-0043). */
|
|
114
|
+
async function addCommand(instanceDir: string): Promise<string> {
|
|
115
|
+
let manager = "pnpm";
|
|
116
|
+
try {
|
|
117
|
+
manager = (await lockfileInstall(instanceDir)).command;
|
|
118
|
+
} catch {
|
|
119
|
+
// no lockfile, or more than one: the bundle step named that already; pnpm is the workspace path
|
|
120
|
+
}
|
|
121
|
+
return manager === "npm" ? "npm i" : `${manager} add`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- the Kit closure ----------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/** One package that Node resolution reaches inside the staged tree. */
|
|
127
|
+
type Resolved = { name: string; path: string; real: string };
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve `name` the way Node would from code in `fromDir`: the nearest `<ancestor>/node_modules/
|
|
131
|
+
* <name>` walking up, never above `root` and never outside it (the staged tree is all the Assembly
|
|
132
|
+
* carries; above it is the Kit image). Node skips an ancestor that is itself named `node_modules`.
|
|
133
|
+
*/
|
|
134
|
+
async function resolveFrom(root: string, fromDir: string, name: string): Promise<Resolved | undefined> {
|
|
135
|
+
for (let d = fromDir; isInside(root, d); d = dirname(d)) {
|
|
136
|
+
if (basename(d) !== "node_modules") {
|
|
137
|
+
const path = join(d, "node_modules", name);
|
|
138
|
+
try {
|
|
139
|
+
if ((await stat(path)).isDirectory()) return { name, path, real: await realpath(path) };
|
|
140
|
+
} catch {
|
|
141
|
+
// not here; keep walking
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (d === root) return undefined;
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The `dependencies` and `optionalDependencies` of the package at `dir`: what a production
|
|
150
|
+
* install puts on disk, so what the Kit image ships too (`npm install --omit=dev` installs both).
|
|
151
|
+
* Not `devDependencies` (absent from a production install) and not `peerDependencies` (the
|
|
152
|
+
* dependent supplies them, so they are its closure). A manifest that does not parse is refused:
|
|
153
|
+
* the closure would shrink without a word. */
|
|
154
|
+
async function dependenciesOf(dir: string, root: string): Promise<string[]> {
|
|
155
|
+
const manifest = join(dir, "package.json");
|
|
156
|
+
let text: string;
|
|
157
|
+
try {
|
|
158
|
+
text = await readFile(manifest, "utf8");
|
|
159
|
+
} catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const m = JSON.parse(text) as {
|
|
164
|
+
dependencies?: Record<string, string>;
|
|
165
|
+
optionalDependencies?: Record<string, string>;
|
|
166
|
+
};
|
|
167
|
+
return [...new Set([...Object.keys(m.dependencies ?? {}), ...Object.keys(m.optionalDependencies ?? {})])];
|
|
168
|
+
} catch (err) {
|
|
169
|
+
const shown = isInside(root, manifest) ? relative(root, manifest) : manifest;
|
|
170
|
+
throw new Error(
|
|
171
|
+
`cannot stage the Assembly: ${shown} does not parse (${(err as Error).message}), so the Kit closure ` +
|
|
172
|
+
`that the strip removes is unknown (ADR-0069) — reinstall the Instance's dependencies`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The Kit closure of the staged tree at `dir` (ADR-0069): `@jr2/orchestrator` and every package its
|
|
179
|
+
* `dependencies` reach, transitively, resolved by Node's rule inside the tree. Names, because the
|
|
180
|
+
* rule is about names: any copy of a closure package in an Assembly shadows the image's copy. A
|
|
181
|
+
* name that resolves to nothing in the tree is still in the closure (the image ships it), but its
|
|
182
|
+
* own dependencies are unknown here, so the walk stops at it.
|
|
183
|
+
*/
|
|
184
|
+
export async function closureOf(dir: string): Promise<Set<string>> {
|
|
185
|
+
return new Set((await resolveClosure(await realpath(dir))).names);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function resolveClosure(root: string): Promise<{ names: string[]; resolved: Resolved[] }> {
|
|
189
|
+
const names = new Set<string>([ORCHESTRATOR]);
|
|
190
|
+
const resolved: Resolved[] = [];
|
|
191
|
+
const seen = new Set<string>();
|
|
192
|
+
const top = await resolveFrom(root, root, ORCHESTRATOR);
|
|
193
|
+
const queue: Resolved[] = top ? [top] : [];
|
|
194
|
+
while (queue.length > 0) {
|
|
195
|
+
const pkg = queue.shift()!;
|
|
196
|
+
if (seen.has(pkg.real)) continue;
|
|
197
|
+
seen.add(pkg.real);
|
|
198
|
+
resolved.push(pkg);
|
|
199
|
+
// Node resolves from a module's REAL location. When a link takes a package out of the tree (a
|
|
200
|
+
// workspace link that `pnpm deploy` writes back into the checkout), that location is host
|
|
201
|
+
// geography: its `node_modules` are not in the Assembly and not in the image. So the walk
|
|
202
|
+
// resolves from the directory that holds the link, which is where the image puts the package.
|
|
203
|
+
// Not from the link itself: its own `node_modules` is the checkout's too.
|
|
204
|
+
const from = isInside(root, pkg.real) ? pkg.real : dirname(pkg.path);
|
|
205
|
+
for (const dep of await dependenciesOf(pkg.real, root)) {
|
|
206
|
+
names.add(dep);
|
|
207
|
+
const next = await resolveFrom(root, from, dep);
|
|
208
|
+
if (next) queue.push(next);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { names: [...names].sort(), resolved };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Step 4: strip the Kit closure from the staged tree at `dir`. Every `node_modules/<name>` entry of
|
|
216
|
+
* a closure package goes when it IS the copy the closure resolved (the directory itself, or a link
|
|
217
|
+
* to it). Both bundle paths install hoisted (`npm ci`, `bun`, and pnpm with `node-linker=hoisted`),
|
|
218
|
+
* so these are top-level entries, and the tree holds no pnpm store. An entry
|
|
219
|
+
* of a closure name that is ANY OTHER copy is refused, naming its path and the package that nests
|
|
220
|
+
* it: the strip cannot remove it without breaking its nester, and keeping it is a second copy of a
|
|
221
|
+
* Kit package (ADR-0054). Nothing is deleted until the whole tree is known to be clean.
|
|
222
|
+
*/
|
|
223
|
+
async function stripKitClosure(dir: string): Promise<void> {
|
|
224
|
+
const root = await realpath(dir);
|
|
225
|
+
const { names, resolved } = await resolveClosure(root);
|
|
226
|
+
const closure = new Set(names);
|
|
227
|
+
const kitDirs = new Set(resolved.map((r) => r.real));
|
|
228
|
+
const viaKit = (real: string) => [...kitDirs].some((k) => isInside(k, real));
|
|
229
|
+
|
|
230
|
+
const remove: string[] = [];
|
|
231
|
+
const refusals: string[] = [];
|
|
232
|
+
const walk = async (d: string): Promise<void> => {
|
|
233
|
+
for (const e of await readdir(d, { withFileTypes: true })) {
|
|
234
|
+
const p = join(d, e.name);
|
|
235
|
+
if (e.name === "node_modules" && e.isDirectory()) {
|
|
236
|
+
await walkNodeModules(p);
|
|
237
|
+
} else if (e.isDirectory()) {
|
|
238
|
+
await walk(p);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
// `nm` is one `node_modules` directory: its entries are package names (a scope is one level more).
|
|
243
|
+
const walkNodeModules = async (nm: string): Promise<void> => {
|
|
244
|
+
for (const e of await readdir(nm, { withFileTypes: true })) {
|
|
245
|
+
const p = join(nm, e.name);
|
|
246
|
+
if (e.name.startsWith("@") && e.isDirectory()) {
|
|
247
|
+
for (const s of await readdir(p, { withFileTypes: true })) await visitPackage(nm, `${e.name}/${s.name}`);
|
|
248
|
+
} else {
|
|
249
|
+
await visitPackage(nm, e.name);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
const visitPackage = async (nm: string, name: string): Promise<void> => {
|
|
254
|
+
const p = join(nm, name);
|
|
255
|
+
const st = await lstat(p);
|
|
256
|
+
if (closure.has(name)) {
|
|
257
|
+
let real: string | undefined;
|
|
258
|
+
try {
|
|
259
|
+
real = await realpath(p);
|
|
260
|
+
} catch {
|
|
261
|
+
real = undefined;
|
|
262
|
+
}
|
|
263
|
+
// A link to nothing, or to a place outside the tree, carries no copy: it goes with the rest.
|
|
264
|
+
if (real === undefined || kitDirs.has(real) || viaKit(real) || !isInside(root, real)) {
|
|
265
|
+
remove.push(p);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
refusals.push(
|
|
269
|
+
`${relative(root, p)} is a second copy of ${name}, nested by ${await nesterOf(root, nm)}: ` +
|
|
270
|
+
`the Orchestrator's image ships ${name} for ${ORCHESTRATOR} ${KIT_VERSION}, and an Assembly that ` +
|
|
271
|
+
`carries its own copy loads Machines whose Menus are empty (ADR-0054) — align that dependency ` +
|
|
272
|
+
`with the ${name} ${ORCHESTRATOR} uses, or remove it, and reinstall`,
|
|
273
|
+
);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// A package outside the closure is the Instance's own; its nested `node_modules` can still hide
|
|
277
|
+
// a copy of a Kit package. A link leads elsewhere in the tree, which the walk reaches there.
|
|
278
|
+
if (st.isDirectory()) await walk(p);
|
|
279
|
+
};
|
|
280
|
+
await walk(root);
|
|
281
|
+
|
|
282
|
+
if (refusals.length > 0) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`cannot stage the Assembly: it would carry a second copy of a Kit package (ADR-0069)\n ${refusals.join("\n ")}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
for (const p of new Set(remove)) {
|
|
288
|
+
await rm(p, { recursive: true, force: true });
|
|
289
|
+
// A scope directory the strip emptied goes too: it is a record of a package that is gone.
|
|
290
|
+
const scope = dirname(p);
|
|
291
|
+
if (basename(scope).startsWith("@") && (await readdir(scope).catch(() => ["?"])).length === 0) {
|
|
292
|
+
await rm(scope, { recursive: true, force: true });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The package whose `node_modules` is `nm`, by its manifest's name, for a refusal. */
|
|
298
|
+
async function nesterOf(root: string, nm: string): Promise<string> {
|
|
299
|
+
const owner = dirname(nm);
|
|
300
|
+
if (owner === root) return "the Instance itself";
|
|
301
|
+
try {
|
|
302
|
+
const m = JSON.parse(await readFile(join(owner, "package.json"), "utf8")) as { name?: string; version?: string };
|
|
303
|
+
if (m.name) return `${m.name}${m.version ? `@${m.version}` : ""} (${relative(root, owner)})`;
|
|
304
|
+
} catch {
|
|
305
|
+
// not a package directory — name the directory
|
|
306
|
+
}
|
|
307
|
+
return relative(root, owner);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// --- `.bin` and the seal ------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
/** Step 5: every `node_modules/.bin`, at any depth. The Orchestrator runs no bin from an Assembly
|
|
313
|
+
* (an Effect runs in an Effector, ADR-0068), and a shim names the staging path in its `NODE_PATH`,
|
|
314
|
+
* which no rewrite can turn into a path that contains its own hash (ADR-0069). */
|
|
315
|
+
async function removeBinDirs(dir: string): Promise<void> {
|
|
316
|
+
const walk = async (d: string): Promise<void> => {
|
|
317
|
+
for (const e of await readdir(d, { withFileTypes: true })) {
|
|
318
|
+
const p = join(d, e.name);
|
|
319
|
+
// Whatever its type: a `.bin` that is a link is a bin directory too.
|
|
320
|
+
if (e.name === ".bin" && basename(d) === "node_modules") await rm(p, { recursive: true, force: true });
|
|
321
|
+
else if (e.isDirectory()) await walk(p);
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
await walk(dir);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The seal's two checks (ADR-0038, ADR-0069): the staged tree must record nothing about where it
|
|
329
|
+
* was staged, so one hash names one Assembly. A symlink whose target names the staging path, or a
|
|
330
|
+
* file that holds it, refuses the stage, naming the path. Nothing is rewritten: the `.bin` shims
|
|
331
|
+
* the old seal rewrote are deleted now, so whatever still holds the path is unknown content.
|
|
332
|
+
*
|
|
333
|
+
* Both spellings of the staging path are checked: the one `mkdtemp` returned and the one `realpath`
|
|
334
|
+
* resolves it to. On macOS `/var` is a link to `/private/var`, so the two differ.
|
|
335
|
+
*/
|
|
336
|
+
export async function checkSealed(dir: string): Promise<void> {
|
|
337
|
+
const spellings = [...new Set([resolve(dir), await realpath(dir)])].sort((a, b) => b.length - a.length);
|
|
338
|
+
const needles = spellings.map((s) => Buffer.from(s));
|
|
339
|
+
const walk = async (d: string): Promise<void> => {
|
|
340
|
+
for (const e of await readdir(d, { withFileTypes: true })) {
|
|
341
|
+
const p = join(d, e.name);
|
|
342
|
+
if (e.isSymbolicLink()) {
|
|
343
|
+
// `assemblyHash` hashes a link's target, so a link that names the staging path gives every
|
|
344
|
+
// staging a new hash, and the door's tree would name a directory it does not have.
|
|
345
|
+
const target = await readlink(p);
|
|
346
|
+
if (spellings.some((s) => target.includes(s))) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`cannot seal the Assembly: the symlink ${relative(dir, p)} points at the staging path ("${target}") — ` +
|
|
349
|
+
`the Assembly would name a directory the Orchestrator does not have; make the link relative ` +
|
|
350
|
+
`in the package that writes it`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
// The door refuses a link that leaves the Assembly (ADR-0069); name it here, before the
|
|
354
|
+
// cluster work, with the fix.
|
|
355
|
+
if (isAbsolute(target) || !isInside(dir, resolve(dirname(p), target))) {
|
|
356
|
+
const rel = relative(dir, p);
|
|
357
|
+
// A link under `node_modules` is a package manager's; a link elsewhere is the Instance's own.
|
|
358
|
+
const fix = rel.split(sep).includes("node_modules")
|
|
359
|
+
? "install that dependency as a copy, not a link"
|
|
360
|
+
: "make the link relative, with a target inside the Instance";
|
|
361
|
+
throw new Error(
|
|
362
|
+
`cannot seal the Assembly: the symlink ${rel} points outside the Assembly ("${target}") — ` +
|
|
363
|
+
`an Assembly carries only what it holds; ${fix}`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
} else if (e.isDirectory()) {
|
|
367
|
+
await walk(p);
|
|
368
|
+
} else if (e.isFile()) {
|
|
369
|
+
const bytes = await readFile(p);
|
|
370
|
+
if (needles.some((n) => bytes.includes(n))) {
|
|
371
|
+
throw new Error(
|
|
372
|
+
`cannot seal the Assembly: ${relative(dir, p)} holds the staging path (${spellings[0]}) — ` +
|
|
373
|
+
`the Assembly would record where it was staged, and two stagings of one Instance would get two ` +
|
|
374
|
+
`hashes; find what writes the install path into that file and stop it (a postinstall step is the ` +
|
|
375
|
+
`usual cause)`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
await walk(dir);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// --- where a host directory sits in the Assembly -------------------------------------------------
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The staged copy of a host directory (ADR-0069): what the Orchestrator holds in place of `dir`
|
|
388
|
+
* once the Assembly lands. A directory under the Instance root keeps its relative path. A
|
|
389
|
+
* directory a dependency ships sits under that package's hoisted copy, `node_modules/<name>/…`:
|
|
390
|
+
* a module's `file:` URL names the module's real location, and pnpm links a workspace package to
|
|
391
|
+
* its checkout, outside the root. `undefined` when the Assembly does not carry it.
|
|
392
|
+
*
|
|
393
|
+
* This is what keys a `file:` Sandbox Image context (ADR-0037, ADR-0049): the host digests the
|
|
394
|
+
* staged copy, the door and every provision digest the landed copy, and the two are the same bytes
|
|
395
|
+
* by construction. The Instance folder's own copy can differ, since staging drops `node_modules`,
|
|
396
|
+
* `.git`, `.env` and `.npmrc` at every depth (ADR-0043), so a digest of it would name a context
|
|
397
|
+
* the Orchestrator never sees.
|
|
398
|
+
*/
|
|
399
|
+
export async function stagedCopyOf(stagedDir: string, root: string, dir: string): Promise<string | undefined> {
|
|
400
|
+
const real = await realpath(dir).catch(() => dir);
|
|
401
|
+
const rootReal = await realpath(root).catch(() => root);
|
|
402
|
+
let candidate: string | undefined;
|
|
403
|
+
if (isInside(rootReal, real)) candidate = join(stagedDir, relative(rootReal, real));
|
|
404
|
+
else {
|
|
405
|
+
const pkg = await packageAbove(real);
|
|
406
|
+
if (pkg) candidate = join(stagedDir, "node_modules", pkg.name, relative(pkg.dir, real));
|
|
407
|
+
}
|
|
408
|
+
if (candidate === undefined) return undefined;
|
|
409
|
+
try {
|
|
410
|
+
return (await stat(candidate)).isDirectory() ? candidate : undefined;
|
|
411
|
+
} catch {
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The nearest package above `dir`, by its manifest's name; undefined when no manifest names one. */
|
|
417
|
+
async function packageAbove(dir: string): Promise<{ name: string; dir: string } | undefined> {
|
|
418
|
+
for (let d = dir; ; d = dirname(d)) {
|
|
419
|
+
try {
|
|
420
|
+
const m = JSON.parse(await readFile(join(d, "package.json"), "utf8")) as { name?: string };
|
|
421
|
+
if (m.name) return { name: m.name, dir: d };
|
|
422
|
+
} catch {
|
|
423
|
+
// no manifest here; keep walking
|
|
424
|
+
}
|
|
425
|
+
if (dirname(d) === d) return undefined;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function isInside(parent: string, child: string): boolean {
|
|
430
|
+
const r = relative(parent, child);
|
|
431
|
+
return r === "" || (r !== ".." && !r.startsWith(`..${sep}`) && !isAbsolute(r));
|
|
432
|
+
}
|