@namewta/speculo 1.0.12 → 1.0.14

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 (50) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/template/canonical/canonical-specdev-goal-plan.md +8 -5
  4. package/template/canonical/canonical-specdev-grill-with-docs.md +5 -4
  5. package/template/canonical/canonical-specdev-spec.md +7 -4
  6. package/template/canonical/canonical-specdev-tickets.md +19 -4
  7. package/template/commands/handoff.md +1 -1
  8. package/template/commands/status.md +1 -1
  9. package/template/workflows/ops/H-host-manage/H-host-manage.md +3 -0
  10. package/template/workflows/ops/INDEX.md +1 -1
  11. package/template/workflows/ops/README.md +1 -1
  12. package/template/workflows/ops/common/CAPABILITIES.md +2 -2
  13. package/template/workflows/ops/common/USAGE.md +23 -4
  14. package/template/workflows/ops/common/examples/register.example.json +1 -1
  15. package/template/workflows/ops/common/tests/test_ops_bootstrap.mjs +522 -0
  16. package/template/workflows/ops/common/toolchains/volta-linux.json +35 -0
  17. package/template/workflows/ops/common/tools/bootstrap-volta.sh +210 -0
  18. package/template/workflows/ops/common/tools/bootstrap.sh +1 -0
  19. package/template/workflows/ops/common/tools/opslib/bootstrap.mjs +221 -0
  20. package/template/workflows/ops/common/tools/opslib/cli.mjs +27 -5
  21. package/template/workflows/ops/common/tools/opslib/core.mjs +12 -0
  22. package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +20 -4
  23. package/template/workflows/ops/common/tools/opslib/transport.mjs +101 -22
  24. package/template/workflows/ops/common/tools/validate-ops.mjs +1 -0
  25. package/template/workflows/specdev/A-archive-and-consolidate/A-archive-and-consolidate.md +3 -3
  26. package/template/workflows/specdev/I-implement/references/implementation-procedure.md +1 -1
  27. package/template/workflows/specdev/I-init-setup/tracking-template.md +1 -1
  28. package/template/workflows/specdev/INDEX.md +1 -1
  29. package/template/workflows/specdev/README.md +4 -4
  30. package/template/workflows/specdev/T-tickets/ticket-template.md +1 -0
  31. package/template/workflows/specdev/T-triage/T-triage.md +44 -13
  32. package/template/workflows/specdev/T-triage/close-comment-template.md +42 -0
  33. package/template/workflows/specdev/T-triage/intake-protocol.md +6 -2
  34. package/template/workflows/specdev/T-triage/issue-body-template.md +34 -0
  35. package/template/workflows/specdev/T-triage/publish-protocol.md +92 -0
  36. package/template/workflows/specdev/T-triage/publish-template.md +49 -0
  37. package/template/workflows/specdev/T-triage/reconcile-protocol.md +3 -3
  38. package/template/workflows/specdev/T-triage/references/classification-map.md +53 -0
  39. package/template/workflows/specdev/T-triage/references/public-projection.md +66 -0
  40. package/template/workflows/specdev/T-triage/tools/publish-status.mjs +148 -0
  41. package/template/workflows/specdev/T-triage/triage-template.md +12 -1
  42. package/template/workflows/specdev/common/README.md +1 -0
  43. package/template/workflows/specdev/common/rules/artifact-contract.md +5 -4
  44. package/template/workflows/specdev/common/rules/change-completion.md +1 -1
  45. package/template/workflows/specdev/common/rules/evidence-and-verification.md +2 -0
  46. package/template/workflows/specdev/common/rules/workflow-routing.md +2 -1
  47. package/template/workflows/specdev/common/schemas/publish.schema.json +21 -0
  48. package/template/workflows/specdev/common/schemas/ticket.schema.json +11 -0
  49. package/template/workflows/specdev/common/schemas/triage.schema.json +3 -1
  50. package/template/workflows/specdev/common/tools/validate-specdev.mjs +173 -3
@@ -0,0 +1,522 @@
1
+ /** Node-less Linux SSH bootstrap/enroll contracts. SSH/scp are simulated; no public network. */
2
+ import assert from "node:assert/strict";
3
+ import { spawnSync } from "node:child_process";
4
+ import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import test from "node:test";
9
+ import { createHash } from "node:crypto";
10
+ import * as core from "../tools/opslib/core.mjs";
11
+ import * as transport from "../tools/opslib/transport.mjs";
12
+ import * as recipes from "../tools/opslib/host_recipes.mjs";
13
+ import * as bootstrap from "../tools/opslib/bootstrap.mjs";
14
+ import { initialize, main, cliHooks } from "../tools/opslib/cli.mjs";
15
+ import * as agent from "../tools/opslib/agent.mjs";
16
+ import * as model from "../tools/opslib/model.mjs";
17
+
18
+ const toolsDir = join(dirname(fileURLToPath(import.meta.url)), "../tools");
19
+ const scriptPath = join(toolsDir, "bootstrap-volta.sh");
20
+ const ACK = "I-APPROVE-THIS-BOOTSTRAP";
21
+
22
+ function sha256(p) {
23
+ return createHash("sha256").update(readFileSync(p)).digest("hex");
24
+ }
25
+
26
+ function splitRemote(cmd) {
27
+ return String(cmd).match(/(?:'[^']*'|\S)+/g).map((s) => (s.startsWith("'") ? s.slice(1, -1).replace(/'"'"'/g, "'") : s));
28
+ }
29
+
30
+ function makeArchives(dir, nodeVersion = "24.21.0") {
31
+ const voltaSrc = join(dir, "volta-src");
32
+ mkdirSync(voltaSrc, { recursive: true });
33
+ writeFileSync(join(voltaSrc, "volta"), "#!/bin/sh\necho 2.0.2\n", { mode: 0o755 });
34
+ writeFileSync(join(voltaSrc, "volta-shim"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
35
+ writeFileSync(join(voltaSrc, "install.sh"), "#!/bin/sh\necho SHOULD-NOT-RUN >&2; echo PROFILE_EDIT >> \"$HOME/.bashrc\"; exit 1\n", { mode: 0o755 });
36
+ const voltaTar = join(dir, "volta-2.0.2-linux.tar.gz");
37
+ const t1 = spawnSync("tar", ["-czf", voltaTar, "-C", voltaSrc, "volta", "volta-shim", "install.sh"], { encoding: "utf8" });
38
+ assert.equal(t1.status, 0, t1.stderr);
39
+
40
+ const top = `node-v${nodeVersion}-linux-x64`;
41
+ const nodeRoot = join(dir, top);
42
+ mkdirSync(join(nodeRoot, "bin"), { recursive: true });
43
+ writeFileSync(join(nodeRoot, "bin", "node"), `#!/bin/sh
44
+ if [ "$1" = "--version" ]; then echo v${nodeVersion}; exit 0; fi
45
+ exec ${JSON.stringify(process.execPath)} "$@"
46
+ `, { mode: 0o755 });
47
+ const nodeTar = join(dir, `${top}.tar.gz`);
48
+ const t2 = spawnSync("tar", ["-czf", nodeTar, "-C", dir, top], { encoding: "utf8" });
49
+ assert.equal(t2.status, 0, t2.stderr);
50
+ return { voltaTar, nodeTar, voltaSha: sha256(voltaTar), nodeSha: sha256(nodeTar), nodeVersion };
51
+ }
52
+
53
+ function pinFor(archives, arch = "linux-x64") {
54
+ const base = bootstrap.loadVoltaPin();
55
+ const slot = { ...base.archives[arch] };
56
+ slot.volta = { ...slot.volta, sha256: archives.voltaSha };
57
+ slot.node = { ...slot.node, sha256: archives.nodeSha };
58
+ return { ...base, archives: { ...base.archives, [arch]: slot } };
59
+ }
60
+
61
+ function mockTransport({ probeJson = null } = {}) {
62
+ const prev = transport.transportHooks.spawnSync;
63
+ const log = [];
64
+ transport.transportHooks.spawnSync = (cmd, args, kw) => {
65
+ log.push({ cmd, args: [...args], encoding: kw.encoding, inputIsBuffer: Buffer.isBuffer(kw.input) });
66
+ const last = args[args.length - 1];
67
+ if (cmd === "scp") {
68
+ const local = args[args.length - 2];
69
+ const spec = args[args.length - 1];
70
+ const remotePath = spec.slice(spec.indexOf(":") + 1);
71
+ mkdirSync(dirname(remotePath), { recursive: true });
72
+ copyFileSync(local, remotePath);
73
+ return { status: 0, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) };
74
+ }
75
+ if (cmd === "ssh") {
76
+ assert.ok(args.includes("BatchMode=yes"));
77
+ assert.ok(args.includes("StrictHostKeyChecking=yes"));
78
+ assert.ok(args.some((a) => String(a).startsWith("UserKnownHostsFile=")));
79
+ if (probeJson && String(last).includes("--probe")) {
80
+ return { status: 0, stdout: Buffer.from(JSON.stringify(probeJson) + "\n"), stderr: Buffer.alloc(0) };
81
+ }
82
+ if (String(last).includes("--input-type=module")) {
83
+ const parts = splitRemote(last);
84
+ return spawnSync(parts[0], parts.slice(1), {
85
+ input: kw.input, encoding: "buffer", timeout: kw.timeout, maxBuffer: kw.maxBuffer,
86
+ });
87
+ }
88
+ const parts = splitRemote(last);
89
+ return spawnSync(parts[0], parts.slice(1), {
90
+ input: kw.input, encoding: "buffer", timeout: kw.timeout, maxBuffer: kw.maxBuffer,
91
+ env: { ...process.env, HOME: kw.HOME || process.env.HOME },
92
+ });
93
+ }
94
+ return prev(cmd, args, kw);
95
+ };
96
+ return { log, restore() { transport.transportHooks.spawnSync = prev; } };
97
+ }
98
+
99
+ function captureMain(argv) {
100
+ const out = [];
101
+ const err = [];
102
+ const wo = process.stdout.write;
103
+ const we = process.stderr.write;
104
+ process.stdout.write = (c) => { out.push(String(c)); return true; };
105
+ process.stderr.write = (c) => { err.push(String(c)); return true; };
106
+ try {
107
+ const code = main(argv);
108
+ return { code, stdout: out.join(""), stderr: err.join("") };
109
+ } finally {
110
+ process.stdout.write = wo;
111
+ process.stderr.write = we;
112
+ }
113
+ }
114
+
115
+ function sshHost({ root, kh, node = "/usr/bin/node", identity = "discover" }) {
116
+ return {
117
+ host_id: "wsl-a",
118
+ display_name: "WSL fixture",
119
+ platform: "linux",
120
+ transport: "ssh",
121
+ root,
122
+ identity,
123
+ connection: {
124
+ hostname: "host.invalid",
125
+ username: "ops",
126
+ known_hosts: kh,
127
+ node,
128
+ },
129
+ };
130
+ }
131
+
132
+ function writeConn(dir, host) {
133
+ const p = join(dir, "conn.json");
134
+ writeFileSync(p, JSON.stringify(host, null, 2));
135
+ return p;
136
+ }
137
+
138
+ test("posix ssh uses the same host-key contract", () => {
139
+ const d = mkdtempSync(join(tmpdir(), "ops-posix-"));
140
+ const kh = join(d, "known_hosts");
141
+ writeFileSync(kh, "fixture pinned key\n");
142
+ const captured = {};
143
+ const prev = transport.transportHooks.spawnSync;
144
+ transport.transportHooks.spawnSync = (cmd, args, kw) => {
145
+ captured.argv = [cmd, ...args];
146
+ Object.assign(captured, kw);
147
+ return { status: 0, stdout: '{"status":"probed"}', stderr: "" };
148
+ };
149
+ try {
150
+ transport.posixCall({ hostname: "host.invalid", username: "ops", known_hosts: kh }, "#!/bin/sh\necho ok\n", { args: ["--probe"] });
151
+ assert.equal(captured.argv[0], "ssh");
152
+ assert.ok(captured.argv.includes("BatchMode=yes"));
153
+ assert.ok(captured.argv.includes("StrictHostKeyChecking=yes"));
154
+ assert.ok(captured.argv.includes("UserKnownHostsFile=" + kh));
155
+ assert.ok(captured.argv.includes("/bin/sh -s -- --probe") || captured.argv.at(-1).includes("/bin/sh"));
156
+ assert.ok(!String(captured.argv.at(-1)).includes("--input-type=module"));
157
+ assert.ok(Buffer.isBuffer(captured.input));
158
+ } finally { transport.transportHooks.spawnSync = prev; rmSync(d, { recursive: true, force: true }); }
159
+ });
160
+
161
+ test("scp uses the same host-key contract", () => {
162
+ const d = mkdtempSync(join(tmpdir(), "ops-scp-"));
163
+ const kh = join(d, "known_hosts");
164
+ writeFileSync(kh, "fixture pinned key\n");
165
+ const local = join(d, "a.tar.gz");
166
+ writeFileSync(local, "x");
167
+ const captured = {};
168
+ const prev = transport.transportHooks.spawnSync;
169
+ transport.transportHooks.spawnSync = (cmd, args, kw) => {
170
+ captured.argv = [cmd, ...args];
171
+ Object.assign(captured, kw);
172
+ return { status: 0, stdout: "", stderr: "" };
173
+ };
174
+ try {
175
+ transport.posixSend({ hostname: "host.invalid", username: "ops", known_hosts: kh }, local, "/srv/ops/_host/installers/a.tar.gz");
176
+ assert.equal(captured.argv[0], "scp");
177
+ assert.ok(captured.argv.includes("BatchMode=yes"));
178
+ assert.ok(captured.argv.includes("StrictHostKeyChecking=yes"));
179
+ assert.ok(captured.argv.includes("UserKnownHostsFile=" + kh));
180
+ assert.ok(captured.argv.includes("-P"));
181
+ assert.ok(!captured.argv.includes("-T"));
182
+ } finally { transport.transportHooks.spawnSync = prev; rmSync(d, { recursive: true, force: true }); }
183
+ });
184
+
185
+ test("bootstrap-volta apply installs wrapper and does not touch profile", () => {
186
+ const d = mkdtempSync(join(tmpdir(), "ops-vol-"));
187
+ const home = join(d, "home");
188
+ mkdirSync(home);
189
+ const bashrc = join(home, ".bashrc");
190
+ writeFileSync(bashrc, "keep-me\n");
191
+ const archives = makeArchives(d);
192
+ const voltaHome = join(d, "srv", "ops", "_host", "toolchains", "ops", "volta");
193
+ const p = spawnSync("/bin/sh", [scriptPath, "--apply", archives.voltaTar, archives.voltaSha, archives.nodeTar, archives.nodeSha, voltaHome, archives.nodeVersion, ACK], {
194
+ encoding: "utf8", env: { ...process.env, HOME: home },
195
+ });
196
+ try {
197
+ assert.equal(p.status, 0, p.stderr + p.stdout);
198
+ const result = JSON.parse(p.stdout.trim().split("\n").at(-1));
199
+ assert.equal(result.profile_unchanged, true);
200
+ assert.ok(result.connection_node.endsWith("/volta/bin/ops-node"));
201
+ assert.equal(readFileSync(bashrc, "utf8"), "keep-me\n");
202
+ const ver = spawnSync(result.connection_node, ["--version"], { encoding: "utf8", env: { ...process.env, HOME: home } });
203
+ assert.equal(ver.stdout.trim(), "v24.21.0");
204
+ assert.ok(!existsSync(join(voltaHome, "install.sh")) || readFileSync(join(home, ".bashrc"), "utf8") === "keep-me\n");
205
+ } finally { rmSync(d, { recursive: true, force: true }); }
206
+ });
207
+
208
+ test("bootstrap-volta apply without ack blocked", () => {
209
+ const d = mkdtempSync(join(tmpdir(), "ops-ack-"));
210
+ const archives = makeArchives(d);
211
+ const p = spawnSync("/bin/sh", [scriptPath, "--apply", archives.voltaTar, archives.voltaSha, archives.nodeTar, archives.nodeSha, join(d, "volta"), "24.21.0", "nope"], { encoding: "utf8" });
212
+ assert.notEqual(p.status, 0);
213
+ rmSync(d, { recursive: true, force: true });
214
+ });
215
+
216
+ test("bootstrap-volta sha mismatch blocked", () => {
217
+ const d = mkdtempSync(join(tmpdir(), "ops-sha-"));
218
+ const archives = makeArchives(d);
219
+ const p = spawnSync("/bin/sh", [scriptPath, "--apply", archives.voltaTar, "0".repeat(64), archives.nodeTar, archives.nodeSha, join(d, "volta"), "24.21.0", ACK], { encoding: "utf8" });
220
+ assert.notEqual(p.status, 0);
221
+ assert.ok(!existsSync(join(d, "volta", "bin", "ops-node")));
222
+ rmSync(d, { recursive: true, force: true });
223
+ });
224
+
225
+ function cliFixture() {
226
+ const tmp = mkdtempSync(join(tmpdir(), "ops-bootcli-"));
227
+ const state = join(tmp, "controller");
228
+ const target = join(tmp, "server");
229
+ mkdirSync(target, { recursive: true });
230
+ const kh = join(tmp, "known_hosts");
231
+ writeFileSync(kh, "fixture pinned key\n");
232
+ const prevProbe = cliHooks.probeLocal;
233
+ cliHooks.probeLocal = () => ({ identity: agent.fingerprint(), scope: "mock" });
234
+ initialize(state, "control-a");
235
+ cliHooks.probeLocal = prevProbe;
236
+ const archives = makeArchives(tmp);
237
+ const prevPin = bootstrap.bootstrapHooks.loadPin;
238
+ bootstrap.bootstrapHooks.loadPin = () => pinFor(archives);
239
+ return {
240
+ tmp, state, target, kh, archives,
241
+ cleanup() {
242
+ bootstrap.bootstrapHooks.loadPin = prevPin;
243
+ rmSync(tmp, { recursive: true, force: true });
244
+ },
245
+ };
246
+ }
247
+
248
+ const missingProbe = {
249
+ status: "probed", uname: "Linux x86_64", arch: "linux-x64",
250
+ tools: { node: "missing", volta: "missing", tar: "/bin/tar", sha256sum: "/usr/bin/sha256sum" },
251
+ node_path: null, node_version: null, node_usable: false, profile_has_volta: false,
252
+ };
253
+
254
+ test("discover probe without node returns node-missing", () => {
255
+ const fx = cliFixture();
256
+ const mock = mockTransport({ probeJson: missingProbe });
257
+ try {
258
+ const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
259
+ const conn = writeConn(fx.tmp, host);
260
+ const r = captureMain(["probe", "--connection-file", conn]);
261
+ assert.equal(r.code, 2);
262
+ const payload = JSON.parse(r.stderr);
263
+ assert.equal(payload.status, "blocked");
264
+ assert.equal(payload.error, "node-missing");
265
+ assert.ok(String(payload.next).includes("bootstrap-node"));
266
+ assert.ok(!existsSync(join(fx.state, "hosts", "wsl-a")));
267
+ } finally { mock.restore(); fx.cleanup(); }
268
+ });
269
+
270
+ test("bootstrap-node probe is read-only", () => {
271
+ const fx = cliFixture();
272
+ const mock = mockTransport({ probeJson: missingProbe });
273
+ try {
274
+ const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
275
+ const conn = writeConn(fx.tmp, host);
276
+ const r = captureMain(["bootstrap-node", "--connection-file", conn, "--probe"]);
277
+ assert.equal(r.code, 0, r.stderr);
278
+ const payload = JSON.parse(r.stdout);
279
+ assert.equal(payload.status, "probed");
280
+ assert.equal(payload.posix.tools.node, "missing");
281
+ assert.ok(!mock.log.some((e) => e.cmd === "scp"));
282
+ assert.ok(!mock.log.some((e) => String(e.args.at(-1) || "").includes("--apply")));
283
+ } finally { mock.restore(); fx.cleanup(); }
284
+ });
285
+
286
+ test("bootstrap-node apply without ack blocked", () => {
287
+ const fx = cliFixture();
288
+ const mock = mockTransport({ probeJson: missingProbe });
289
+ try {
290
+ const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
291
+ const conn = writeConn(fx.tmp, host);
292
+ const r = captureMain([
293
+ "bootstrap-node", "--connection-file", conn, "--apply",
294
+ "--account", "ops", "--host-root", fx.target,
295
+ "--volta-archive", fx.archives.voltaTar, "--volta-sha256", fx.archives.voltaSha,
296
+ "--node-archive", fx.archives.nodeTar, "--node-sha256", fx.archives.nodeSha,
297
+ ]);
298
+ assert.equal(r.code, 2);
299
+ assert.ok(r.stderr.includes("I-APPROVE-THIS-BOOTSTRAP"));
300
+ } finally { mock.restore(); fx.cleanup(); }
301
+ });
302
+
303
+ test("bootstrap-node sha mismatch blocked", () => {
304
+ const fx = cliFixture();
305
+ const mock = mockTransport({ probeJson: missingProbe });
306
+ try {
307
+ const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
308
+ const conn = writeConn(fx.tmp, host);
309
+ const r = captureMain([
310
+ "bootstrap-node", "--connection-file", conn, "--apply",
311
+ "--account", "ops", "--host-root", fx.target, "--ack", ACK,
312
+ "--volta-archive", fx.archives.voltaTar, "--volta-sha256", "0".repeat(64),
313
+ "--node-archive", fx.archives.nodeTar, "--node-sha256", fx.archives.nodeSha,
314
+ ]);
315
+ assert.equal(r.code, 2);
316
+ assert.ok(!mock.log.some((e) => String(e.args.at(-1) || "").includes("--apply")));
317
+ } finally { mock.restore(); fx.cleanup(); }
318
+ });
319
+
320
+ test("latest and curl-pipe names rejected", () => {
321
+ const fx = cliFixture();
322
+ const mock = mockTransport({ probeJson: missingProbe });
323
+ try {
324
+ const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
325
+ const conn = writeConn(fx.tmp, host);
326
+ const r = captureMain([
327
+ "bootstrap-node", "--connection-file", conn, "--apply",
328
+ "--account", "ops", "--host-root", fx.target, "--ack", ACK,
329
+ "--node-version", "latest",
330
+ "--volta-archive", fx.archives.voltaTar, "--volta-sha256", fx.archives.voltaSha,
331
+ "--node-archive", fx.archives.nodeTar, "--node-sha256", fx.archives.nodeSha,
332
+ ]);
333
+ assert.equal(r.code, 2);
334
+ assert.match(r.stderr, /latest/i);
335
+ } finally { mock.restore(); fx.cleanup(); }
336
+ });
337
+
338
+ test("bootstrap-node skips when node exists", () => {
339
+ const fx = cliFixture();
340
+ const mock = mockTransport({
341
+ probeJson: {
342
+ ...missingProbe,
343
+ tools: { ...missingProbe.tools, node: "/usr/bin/node" },
344
+ node_path: "/usr/bin/node",
345
+ node_usable: true,
346
+ node_version: "v22.22.3",
347
+ },
348
+ });
349
+ try {
350
+ const host = sshHost({ root: fx.target, kh: fx.kh, node: "/usr/bin/node" });
351
+ const conn = writeConn(fx.tmp, host);
352
+ const r = captureMain([
353
+ "bootstrap-node", "--connection-file", conn, "--apply",
354
+ "--account", "ops", "--host-root", fx.target, "--ack", ACK,
355
+ "--volta-archive", fx.archives.voltaTar, "--volta-sha256", fx.archives.voltaSha,
356
+ "--node-archive", fx.archives.nodeTar, "--node-sha256", fx.archives.nodeSha,
357
+ ]);
358
+ assert.equal(r.code, 0, r.stderr);
359
+ const payload = JSON.parse(r.stdout);
360
+ assert.equal(payload.status, "skipped-existing-node");
361
+ assert.ok(!mock.log.some((e) => e.cmd === "scp"));
362
+ } finally { mock.restore(); fx.cleanup(); }
363
+ });
364
+
365
+ test("bootstrap-node apply then enroll writes inventory and status", () => {
366
+ const fx = cliFixture();
367
+ const home = join(fx.tmp, "home");
368
+ mkdirSync(home);
369
+ writeFileSync(join(home, ".bashrc"), "keep-me\n");
370
+ const prevHome = process.env.HOME;
371
+ process.env.HOME = home;
372
+ const mock = mockTransport({ probeJson: missingProbe });
373
+ try {
374
+ const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
375
+ const conn = writeConn(fx.tmp, host);
376
+ const applied = captureMain([
377
+ "bootstrap-node", "--state", fx.state, "--host-id", "wsl-a",
378
+ "--connection-file", conn, "--apply",
379
+ "--account", "ops", "--host-root", fx.target, "--ack", ACK,
380
+ "--volta-archive", fx.archives.voltaTar, "--volta-sha256", fx.archives.voltaSha,
381
+ "--node-archive", fx.archives.nodeTar, "--node-sha256", fx.archives.nodeSha,
382
+ ]);
383
+ assert.equal(applied.code, 0, applied.stderr);
384
+ const boot = JSON.parse(applied.stdout);
385
+ assert.equal(boot.status, "installed");
386
+ assert.ok(boot.connection_node.endsWith("/ops-node"));
387
+ assert.equal(readFileSync(join(home, ".bashrc"), "utf8"), "keep-me\n");
388
+ assert.ok(existsSync(join(fx.state, "hosts", "wsl-a", "bootstrap")));
389
+ const statusBefore = model.load(fx.state);
390
+ assert.ok(!statusBefore.hosts["wsl-a"]);
391
+
392
+ host.connection.node = boot.connection_node;
393
+ host.identity = "discover";
394
+ const enrollFile = join(fx.tmp, "register.json");
395
+ writeFileSync(enrollFile, JSON.stringify({ hosts: [host], projects: [] }));
396
+ mock.restore();
397
+ const enrollMock = mockTransport({
398
+ probeJson: { ...missingProbe, tools: { ...missingProbe.tools, node: boot.connection_node }, node_path: boot.connection_node, node_usable: true },
399
+ });
400
+ try {
401
+ const enrolled = captureMain(["--state", fx.state, "enroll", "--file", enrollFile]);
402
+ assert.equal(enrolled.code, 0, enrolled.stderr + enrolled.stdout);
403
+ const result = JSON.parse(enrolled.stdout);
404
+ assert.equal(result.status, "enrolled");
405
+ assert.match(result.identity, /^[a-f0-9]{64}$/);
406
+ const snaps = readdirSync(join(fx.state, "hosts", "wsl-a", "inventory"));
407
+ assert.ok(snaps.some((n) => n.startsWith("snapshot-")));
408
+ const status = model.load(fx.state);
409
+ assert.ok(status.hosts["wsl-a"]);
410
+ assert.equal(status.hosts["wsl-a"].connection.node, boot.connection_node);
411
+ } finally { enrollMock.restore(); }
412
+ } finally {
413
+ process.env.HOME = prevHome;
414
+ mock.restore();
415
+ fx.cleanup();
416
+ }
417
+ });
418
+
419
+ test("enroll without node blocked", () => {
420
+ const fx = cliFixture();
421
+ const mock = mockTransport({ probeJson: missingProbe });
422
+ try {
423
+ const host = sshHost({ root: fx.target, kh: fx.kh, node: "/no/such/node" });
424
+ const enrollFile = join(fx.tmp, "register.json");
425
+ writeFileSync(enrollFile, JSON.stringify({ hosts: [host], projects: [] }));
426
+ const r = captureMain(["--state", fx.state, "enroll", "--file", enrollFile]);
427
+ assert.equal(r.code, 2);
428
+ const payload = JSON.parse(r.stderr);
429
+ assert.equal(payload.error, "node-missing");
430
+ const status = model.load(fx.state);
431
+ assert.deepEqual(Object.keys(status.hosts), []);
432
+ } finally { mock.restore(); fx.cleanup(); }
433
+ });
434
+
435
+ test("probe --connection-file still does not imply register", () => {
436
+ const fx = cliFixture();
437
+ const mock = mockTransport({
438
+ probeJson: { ...missingProbe, tools: { ...missingProbe.tools, node: process.execPath }, node_path: process.execPath, node_usable: true },
439
+ });
440
+ try {
441
+ const host = sshHost({ root: fx.target, kh: fx.kh, node: process.execPath, identity: "discover" });
442
+ const conn = writeConn(fx.tmp, host);
443
+ const r = captureMain(["probe", "--connection-file", conn]);
444
+ assert.equal(r.code, 0, r.stderr);
445
+ const payload = JSON.parse(r.stdout);
446
+ assert.ok(payload.next.includes("enroll"));
447
+ const status = model.load(fx.state);
448
+ assert.deepEqual(Object.keys(status.hosts), []);
449
+ } finally { mock.restore(); fx.cleanup(); }
450
+ });
451
+
452
+ test("environmentSpec uses managed volta path", () => {
453
+ const tmp = mkdtempSync(join(tmpdir(), "ops-env-"));
454
+ const state = join(tmp, "controller");
455
+ const target = join(tmp, "server");
456
+ const prevProbe = cliHooks.probeLocal;
457
+ cliHooks.probeLocal = () => ({ identity: agent.fingerprint(), scope: "mock" });
458
+ initialize(state, "control-a");
459
+ cliHooks.probeLocal = prevProbe;
460
+ const host = {
461
+ host_id: "node-a", display_name: "Node A", platform: process.platform === "win32" ? "windows" : "linux",
462
+ transport: "local", root: target, identity: agent.fingerprint(), connection: {},
463
+ };
464
+ model.register(state, { hosts: [host], projects: [] });
465
+ const prev = recipes.hostRecipesHooks.call;
466
+ const voltaBin = core.targetJoin(host, "_host/toolchains/ops/volta/bin/volta");
467
+ recipes.hostRecipesHooks.call = (_h, req) => {
468
+ if (req.action === "snapshot") return { paths: { [req.paths[0]]: { kind: "file" } } };
469
+ return {
470
+ identity: host.identity,
471
+ tools: {
472
+ node: { status: "observed", version: "v24.21.0", path: process.execPath },
473
+ volta: { status: "missing", path: null },
474
+ },
475
+ defaults: { VOLTA_HOME: core.targetJoin(host, "_host/toolchains/ops/volta") },
476
+ };
477
+ };
478
+ try {
479
+ const spec = recipes.environmentSpec(state, "node-a", { account: "ops", node_version: "24.21.0" });
480
+ const install = spec.host_actions.find((a) => a.kind === "install-toolchain");
481
+ assert.equal(install.argv[0], voltaBin);
482
+ } finally {
483
+ recipes.hostRecipesHooks.call = prev;
484
+ rmSync(tmp, { recursive: true, force: true });
485
+ }
486
+ });
487
+
488
+ test("environmentSpec still throws when volta truly absent", () => {
489
+ const tmp = mkdtempSync(join(tmpdir(), "ops-envmiss-"));
490
+ const state = join(tmp, "controller");
491
+ const target = join(tmp, "server");
492
+ const prevProbe = cliHooks.probeLocal;
493
+ cliHooks.probeLocal = () => ({ identity: agent.fingerprint(), scope: "mock" });
494
+ initialize(state, "control-a");
495
+ cliHooks.probeLocal = prevProbe;
496
+ const host = {
497
+ host_id: "node-a", display_name: "Node A", platform: process.platform === "win32" ? "windows" : "linux",
498
+ transport: "local", root: target, identity: agent.fingerprint(), connection: {},
499
+ };
500
+ model.register(state, { hosts: [host], projects: [] });
501
+ const prev = recipes.hostRecipesHooks.call;
502
+ recipes.hostRecipesHooks.call = (_h, req) => {
503
+ if (req.action === "snapshot") return { paths: { [req.paths[0]]: { kind: "absent" } } };
504
+ return {
505
+ identity: host.identity,
506
+ tools: {
507
+ node: { status: "observed", version: "v24.21.0", path: process.execPath },
508
+ volta: { status: "missing", path: null },
509
+ },
510
+ defaults: {},
511
+ };
512
+ };
513
+ try {
514
+ assert.throws(
515
+ () => recipes.environmentSpec(state, "node-a", { account: "ops", node_version: "24.21.0" }),
516
+ /bootstrap-node/,
517
+ );
518
+ } finally {
519
+ recipes.hostRecipesHooks.call = prev;
520
+ rmSync(tmp, { recursive: true, force: true });
521
+ }
522
+ });
@@ -0,0 +1,35 @@
1
+ {
2
+ "schema_version": 1,
3
+ "manager": "volta",
4
+ "volta_version": "2.0.2",
5
+ "node_version": "24.21.0",
6
+ "profile_edit": false,
7
+ "use_official_install_sh": false,
8
+ "volta_home_rel": "_host/toolchains/{account}/volta",
9
+ "archives": {
10
+ "linux-x64": {
11
+ "volta": {
12
+ "filename": "volta-2.0.2-linux.tar.gz",
13
+ "url": "https://github.com/volta-cli/volta/releases/download/v2.0.2/volta-2.0.2-linux.tar.gz",
14
+ "sha256": "6cec054c911fb925b629a09455775af6e95dc0f5694a4c28b63979ab9ef18037"
15
+ },
16
+ "node": {
17
+ "filename": "node-v24.21.0-linux-x64.tar.gz",
18
+ "url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz",
19
+ "sha256": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff"
20
+ }
21
+ },
22
+ "linux-arm64": {
23
+ "volta": {
24
+ "filename": "volta-2.0.2-linux-arm.tar.gz",
25
+ "url": "https://github.com/volta-cli/volta/releases/download/v2.0.2/volta-2.0.2-linux-arm.tar.gz",
26
+ "sha256": "1eb92f8b711753aa576352b2e580b2f3fdadeab8664929ca1da3d0de65448517"
27
+ },
28
+ "node": {
29
+ "filename": "node-v24.21.0-linux-arm64.tar.gz",
30
+ "url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.gz",
31
+ "sha256": "724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5"
32
+ }
33
+ }
34
+ }
35
+ }