@namewta/speculo 1.0.11 → 1.0.13

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 (38) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/template/workflows/learning/G-goal/G-goal.md +13 -8
  4. package/template/workflows/learning/G-goal/chain-template.md +14 -6
  5. package/template/workflows/learning/G-goal/goal-plan-template.md +72 -43
  6. package/template/workflows/learning/G-goal/orchestration-protocol.md +7 -6
  7. package/template/workflows/learning/G-goal/planning-modes.md +3 -3
  8. package/template/workflows/learning/G-goal/probe-template.md +28 -9
  9. package/template/workflows/learning/G-goal/progress-template.md +9 -5
  10. package/template/workflows/learning/G-goal/references/coverage-bar.md +7 -2
  11. package/template/workflows/learning/G-goal/references/external-goal-runner.md +13 -6
  12. package/template/workflows/learning/G-goal/references/lead-orchestration.md +52 -0
  13. package/template/workflows/learning/G-goal/references/mine-unit.md +49 -0
  14. package/template/workflows/learning/G-goal/references/split-rules.md +54 -0
  15. package/template/workflows/learning/G-goal/references/stop-rules.md +8 -3
  16. package/template/workflows/learning/INDEX.md +1 -1
  17. package/template/workflows/learning/README.md +5 -5
  18. package/template/workflows/learning/common/rules/artifact-contract.md +3 -3
  19. package/template/workflows/learning/common/rules/questioning-policy.md +5 -0
  20. package/template/workflows/learning/common/rules/teaching-policy.md +1 -1
  21. package/template/workflows/learning/common/skills/socratic-questioning/SKILL.md +4 -2
  22. package/template/workflows/learning/common/tools/validate-learning.mjs +213 -4
  23. package/template/workflows/ops/H-host-manage/H-host-manage.md +3 -0
  24. package/template/workflows/ops/INDEX.md +1 -1
  25. package/template/workflows/ops/README.md +1 -1
  26. package/template/workflows/ops/common/CAPABILITIES.md +2 -2
  27. package/template/workflows/ops/common/USAGE.md +23 -4
  28. package/template/workflows/ops/common/examples/register.example.json +1 -1
  29. package/template/workflows/ops/common/tests/test_ops_bootstrap.mjs +522 -0
  30. package/template/workflows/ops/common/toolchains/volta-linux.json +35 -0
  31. package/template/workflows/ops/common/tools/bootstrap-volta.sh +210 -0
  32. package/template/workflows/ops/common/tools/bootstrap.sh +1 -0
  33. package/template/workflows/ops/common/tools/opslib/bootstrap.mjs +221 -0
  34. package/template/workflows/ops/common/tools/opslib/cli.mjs +27 -5
  35. package/template/workflows/ops/common/tools/opslib/core.mjs +12 -0
  36. package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +20 -4
  37. package/template/workflows/ops/common/tools/opslib/transport.mjs +101 -22
  38. package/template/workflows/ops/common/tools/validate-ops.mjs +1 -0
@@ -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
+ }