@namewta/speculo 1.0.12 → 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.
@@ -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
+ }
@@ -0,0 +1,210 @@
1
+ #!/bin/sh
2
+ # First-stage Linux SSH Node bootstrap. POSIX only; never runs install.sh or edits profiles.
3
+ set -eu
4
+
5
+ ACK='I-APPROVE-THIS-BOOTSTRAP'
6
+
7
+ json_str() {
8
+ printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
9
+ }
10
+
11
+ digest_file() {
12
+ if command -v sha256sum >/dev/null 2>&1; then
13
+ sha256sum "$1" | cut -d' ' -f1
14
+ elif command -v shasum >/dev/null 2>&1; then
15
+ shasum -a 256 "$1" | cut -d' ' -f1
16
+ else
17
+ echo 'No SHA-256 implementation; bootstrap blocked' >&2
18
+ exit 2
19
+ fi
20
+ }
21
+
22
+ profile_digest() {
23
+ d=''
24
+ for f in "${HOME-}/.bashrc" "${HOME-}/.profile"; do
25
+ if [ -f "$f" ] && [ ! -L "$f" ]; then
26
+ d="${d}$(digest_file "$f")"
27
+ else
28
+ d="${d}absent"
29
+ fi
30
+ done
31
+ printf '%s' "$d"
32
+ }
33
+
34
+ normalize_arch() {
35
+ m=$(uname -m)
36
+ case "$m" in
37
+ x86_64) printf 'linux-x64' ;;
38
+ aarch64|arm64) printf 'linux-arm64' ;;
39
+ *) printf 'unsupported:%s' "$m" ;;
40
+ esac
41
+ }
42
+
43
+ tool_path() {
44
+ if command -v "$1" >/dev/null 2>&1; then command -v "$1"; else printf 'missing'; fi
45
+ }
46
+
47
+ abspath_cmd() {
48
+ p=$1
49
+ if [ "$p" = "missing" ] || [ -z "$p" ]; then
50
+ printf 'missing'
51
+ return
52
+ fi
53
+ if command -v readlink >/dev/null 2>&1; then
54
+ readlink -f "$p" 2>/dev/null || printf '%s' "$p"
55
+ else
56
+ printf '%s' "$p"
57
+ fi
58
+ }
59
+
60
+ emit_probe() {
61
+ specified=${1-}
62
+ arch=$(normalize_arch)
63
+ nodep=$(tool_path node)
64
+ voltap=$(tool_path volta)
65
+ usable=false
66
+ node_path=null
67
+ node_version=null
68
+ if [ -n "$specified" ]; then
69
+ if [ -x "$specified" ] && [ ! -L "$specified" -o -x "$specified" ]; then
70
+ usable=true
71
+ nodep=$specified
72
+ else
73
+ nodep=missing
74
+ usable=false
75
+ fi
76
+ elif [ "$nodep" != "missing" ] && [ -x "$nodep" ]; then
77
+ usable=true
78
+ fi
79
+ if [ "$usable" = true ]; then
80
+ nodep=$(abspath_cmd "$nodep")
81
+ node_path="\"$(json_str "$nodep")\""
82
+ ver=$("$nodep" --version 2>/dev/null || true)
83
+ if [ -n "$ver" ]; then node_version="\"$(json_str "$ver")\""; fi
84
+ else
85
+ nodep=missing
86
+ fi
87
+ profile_has=false
88
+ for f in "${HOME-}/.bashrc" "${HOME-}/.profile"; do
89
+ if [ -f "$f" ] && grep -q volta "$f" 2>/dev/null; then profile_has=true; fi
90
+ done
91
+ volta_out=$voltap
92
+ [ "$volta_out" = "missing" ] || volta_out=$(abspath_cmd "$volta_out")
93
+ printf '{"status":"probed","uname":"%s","arch":"%s","tools":{"node":"%s","volta":"%s","tar":"%s","sha256sum":"%s"},"node_path":%s,"node_version":%s,"node_usable":%s,"profile_has_volta":%s}\n' \
94
+ "$(json_str "$(uname -sm)")" "$(json_str "$arch")" \
95
+ "$(json_str "$nodep")" "$(json_str "$volta_out")" \
96
+ "$(json_str "$(tool_path tar)")" "$(json_str "$(tool_path sha256sum)")" \
97
+ "$node_path" "$node_version" "$usable" "$profile_has"
98
+ }
99
+
100
+ find_named() {
101
+ root=$1
102
+ name=$2
103
+ found=''
104
+ # Prefer a file literally named $name, skipping install.sh.
105
+ for cand in "$root/$name" "$root"/*/"$name"; do
106
+ if [ -f "$cand" ] && [ ! -L "$cand" ]; then
107
+ found=$cand
108
+ break
109
+ fi
110
+ done
111
+ printf '%s' "$found"
112
+ }
113
+
114
+ case "${1:---probe}" in
115
+ --probe)
116
+ emit_probe "${2-}"
117
+ ;;
118
+ --apply)
119
+ [ "$#" -eq 8 ] || { echo 'usage: bootstrap-volta.sh --apply VOLTA_TAR VOLTA_SHA NODE_TAR NODE_SHA VOLTA_HOME NODE_VERSION I-APPROVE-THIS-BOOTSTRAP' >&2; exit 2; }
120
+ volta_tar=$2
121
+ volta_sha=$3
122
+ node_tar=$4
123
+ node_sha=$5
124
+ volta_home=$6
125
+ node_version=$7
126
+ [ "$8" = "$ACK" ] || { echo 'explicit bootstrap acknowledgement required' >&2; exit 2; }
127
+ case "$node_version" in
128
+ ''|latest|Latest|LATEST|*latest*) echo 'refusing latest or empty node version' >&2; exit 2 ;;
129
+ esac
130
+ case "$volta_home" in
131
+ /*) ;;
132
+ *) echo 'VOLTA_HOME must be an absolute path' >&2; exit 2 ;;
133
+ esac
134
+ case "$volta_home" in
135
+ *..*|*[\ \ ]*) echo 'VOLTA_HOME must not contain spaces or ..' >&2; exit 2 ;;
136
+ esac
137
+ for f in "$volta_tar" "$node_tar"; do
138
+ [ -f "$f" ] && [ ! -L "$f" ] || { echo "Installer must be a reviewed regular file: $f" >&2; exit 2; }
139
+ done
140
+ actual=$(digest_file "$volta_tar")
141
+ [ "$actual" = "$volta_sha" ] || { echo 'Volta installer changed after review' >&2; exit 2; }
142
+ actual=$(digest_file "$node_tar")
143
+ [ "$actual" = "$node_sha" ] || { echo 'Node installer changed after review' >&2; exit 2; }
144
+
145
+ image="$volta_home/tools/image/node/$node_version"
146
+ node_bin="$image/bin/node"
147
+ wrapper="$volta_home/bin/ops-node"
148
+ if [ -x "$node_bin" ] && [ -x "$wrapper" ]; then
149
+ ver=$("$wrapper" --version 2>/dev/null || true)
150
+ if [ "$ver" = "v$node_version" ]; then
151
+ printf '{"status":"installed","volta_home":"%s","connection_node":"%s","node_path":"%s","volta_bin":"%s","node_version":"%s","volta_version":"2.0.2","profile_unchanged":true,"idempotent":true}\n' \
152
+ "$(json_str "$volta_home")" "$(json_str "$wrapper")" "$(json_str "$node_bin")" "$(json_str "$volta_home/bin/volta")" "$(json_str "$node_version")"
153
+ exit 0
154
+ fi
155
+ fi
156
+
157
+ before=$(profile_digest)
158
+ mkdir -p "$volta_home/bin" "$image" "$volta_home/tmp"
159
+ stage=$(mktemp -d "$volta_home/tmp/extract.XXXXXX")
160
+ trap 'rm -rf "$stage"' EXIT
161
+
162
+ tar -xzf "$volta_tar" -C "$stage"
163
+ volta_src=$(find_named "$stage" volta)
164
+ [ -n "$volta_src" ] || { echo 'volta binary missing from archive; refusing to run install.sh' >&2; exit 2; }
165
+ cp "$volta_src" "$volta_home/bin/volta"
166
+ chmod 755 "$volta_home/bin/volta"
167
+ shim_src=$(find_named "$stage" volta-shim)
168
+ if [ -n "$shim_src" ]; then
169
+ cp "$shim_src" "$volta_home/bin/volta-shim"
170
+ chmod 755 "$volta_home/bin/volta-shim"
171
+ fi
172
+ mig_src=$(find_named "$stage" volta-migrate)
173
+ if [ -n "$mig_src" ]; then
174
+ cp "$mig_src" "$volta_home/bin/volta-migrate"
175
+ chmod 755 "$volta_home/bin/volta-migrate"
176
+ fi
177
+ # Never execute install.sh even if present.
178
+ if [ -f "$stage/install.sh" ] || [ -f "$stage"/*/install.sh ] 2>/dev/null; then
179
+ :
180
+ fi
181
+
182
+ tar -xzf "$node_tar" -C "$image" --strip-components=1
183
+ [ -x "$node_bin" ] || { echo 'Node binary missing after extract' >&2; exit 2; }
184
+ chmod 755 "$node_bin"
185
+
186
+ cat > "$wrapper" <<EOF
187
+ #!/bin/sh
188
+ export VOLTA_HOME=$(printf '%s' "$volta_home" | sed "s/'/'\\\\''/g; s/^/'/; s/\$/'/")
189
+ export PATH="\$VOLTA_HOME/bin:\$PATH"
190
+ exec $(printf '%s' "$node_bin" | sed "s/'/'\\\\''/g; s/^/'/; s/\$/'/") "\$@"
191
+ EOF
192
+ chmod 755 "$wrapper"
193
+ cp "$wrapper" "$volta_home/bin/node"
194
+ chmod 755 "$volta_home/bin/node"
195
+
196
+ ver=$("$wrapper" --version 2>/dev/null || true)
197
+ [ "$ver" = "v$node_version" ] || { echo "ops-node version mismatch: got ${ver:-empty} want v$node_version" >&2; exit 2; }
198
+
199
+ after=$(profile_digest)
200
+ [ "$before" = "$after" ] || { echo 'bootstrap refused: shell profile changed' >&2; exit 2; }
201
+
202
+ volta_ver=$("$volta_home/bin/volta" --version 2>/dev/null || printf '2.0.2')
203
+ printf '{"status":"installed","volta_home":"%s","connection_node":"%s","node_path":"%s","volta_bin":"%s","node_version":"%s","volta_version":"%s","profile_unchanged":true}\n' \
204
+ "$(json_str "$volta_home")" "$(json_str "$wrapper")" "$(json_str "$node_bin")" "$(json_str "$volta_home/bin/volta")" "$(json_str "$node_version")" "$(json_str "$volta_ver")"
205
+ ;;
206
+ *)
207
+ echo 'usage: bootstrap-volta.sh --probe [NODE_PATH] | --apply VOLTA_TAR VOLTA_SHA NODE_TAR NODE_SHA VOLTA_HOME NODE_VERSION I-APPROVE-THIS-BOOTSTRAP' >&2
208
+ exit 2
209
+ ;;
210
+ esac
@@ -1,5 +1,6 @@
1
1
  #!/bin/sh
2
2
  # First-stage controller/target bootstrap: POSIX shell only, no Python prerequisite.
3
+ # Remote Linux SSH hosts that lack Node must use ops.mjs bootstrap-node, not this local script.
3
4
  set -eu
4
5
  case "${1:---probe}" in
5
6
  --probe)
@@ -0,0 +1,221 @@
1
+ /** Node-less Linux SSH bootstrap: pinned Volta/Node via POSIX + scp, then enroll. */
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { dirname, basename, isAbsolute, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { digest, exact, identifier, newId, noSymlinks, NodeMissing, now, OpsError, privateDir, rootPath, targetJoin, writeJson } from "./core.mjs";
7
+ import { register, validateHost } from "./model.mjs";
8
+ import { call, posixCall, posixSend, validateSshEndpoint } from "./transport.mjs";
9
+
10
+ const ACK = "I-APPROVE-THIS-BOOTSTRAP";
11
+ const TOOLS = join(dirname(fileURLToPath(import.meta.url)), "..");
12
+ const SCRIPT = readFileSync(join(TOOLS, "bootstrap-volta.sh"), "utf8");
13
+ const PIN_PATH = join(TOOLS, "..", "toolchains", "volta-linux.json");
14
+
15
+ export function loadVoltaPin() {
16
+ const pin = JSON.parse(readFileSync(PIN_PATH, "utf8"));
17
+ if (pin.schema_version !== 1 || pin.manager !== "volta") throw new OpsError("invalid volta-linux pin metadata");
18
+ return pin;
19
+ }
20
+
21
+ export const bootstrapHooks = { loadPin: loadVoltaPin };
22
+
23
+ function rejectUnpinned(label, value) {
24
+ const s = String(value ?? "");
25
+ if (!s || /latest/i.test(s)) throw new OpsError(`${label} must be a concrete pinned name, not latest`);
26
+ return s;
27
+ }
28
+
29
+ function safeFilename(name) {
30
+ rejectUnpinned("installer filename", name);
31
+ if (!/^[A-Za-z0-9._+-]+$/.test(name)) throw new OpsError("unsafe installer filename: " + name);
32
+ return name;
33
+ }
34
+
35
+ function sha256File(path) {
36
+ noSymlinks(path, { allowMissing: false });
37
+ return digest(readFileSync(path));
38
+ }
39
+
40
+ function parseJsonLine(stdout, label) {
41
+ const lines = String(stdout || "").trim().split(/\r?\n/).filter(Boolean);
42
+ const last = lines.at(-1);
43
+ try { return JSON.parse(last); }
44
+ catch { throw new OpsError(`${label} returned invalid JSON`); }
45
+ }
46
+
47
+ export function posixProbe(endpoint, nodePath) {
48
+ const args = ["--probe"];
49
+ if (nodePath) args.push(String(nodePath));
50
+ const r = posixCall(endpoint, SCRIPT, { args });
51
+ return parseJsonLine(r.stdout, "posix probe");
52
+ }
53
+
54
+ function linuxHost(root) {
55
+ return { platform: "linux", root: rootPath(root, "linux") };
56
+ }
57
+
58
+ function installerDir(hostRoot) {
59
+ return targetJoin(linuxHost(hostRoot), "_host/installers");
60
+ }
61
+
62
+ function managedVoltaHome(hostRoot, account) {
63
+ return targetJoin(linuxHost(hostRoot), "_host/toolchains/" + account + "/volta");
64
+ }
65
+
66
+ function downloadHttps(url, dest) {
67
+ let u;
68
+ try { u = new URL(url); } catch { throw new OpsError("pin URL is invalid"); }
69
+ if (u.protocol !== "https:" || u.username || u.password) throw new OpsError("pin URL must be credential-free https");
70
+ privateDir(dirname(dest));
71
+ const curl = spawnSync("curl", ["-fsSL", "--max-time", "120", "-o", dest, url], { encoding: "utf8" });
72
+ if (curl.status === 0 && existsSync(dest)) return dest;
73
+ const wget = spawnSync("wget", ["-q", "-O", dest, url], { encoding: "utf8" });
74
+ if (wget.status === 0 && existsSync(dest)) return dest;
75
+ throw new OpsError("controller download failed; pass reviewed local archives instead of --allow-network");
76
+ }
77
+
78
+ function resolveArchive({ archive, sha256, pinEntry, allowNetwork, cacheDir, label }) {
79
+ const expected = pinEntry.sha256;
80
+ if (sha256 && sha256 !== expected) throw new OpsError(`${label} sha256 does not match the reviewed pin`);
81
+ let file = archive;
82
+ if (!file) {
83
+ if (!allowNetwork) throw new OpsError(`${label} archive is required unless --allow-network fetches the pinned URL`);
84
+ file = join(cacheDir, safeFilename(pinEntry.filename));
85
+ downloadHttps(pinEntry.url, file);
86
+ }
87
+ noSymlinks(file, { allowMissing: false });
88
+ if (safeFilename(basename(file)) && /latest/i.test(basename(file))) throw new OpsError(`${label} archive name must not contain latest`);
89
+ const actual = sha256File(file);
90
+ if (actual !== expected) throw new OpsError(`${label} installer changed after review`);
91
+ return { file, sha256: expected, filename: safeFilename(pinEntry.filename) };
92
+ }
93
+
94
+ export function bootstrapNode(args) {
95
+ const apply = Boolean(args.apply);
96
+ if (apply && args.probe) throw new OpsError("bootstrap-node --probe and --apply are mutually exclusive");
97
+ if (!args.connection_file) throw new OpsError("--connection-file is required");
98
+ const host = JSON.parse(readFileSync(args.connection_file, "utf8"));
99
+ const endpoint = host.connection || host;
100
+ validateSshEndpoint(endpoint);
101
+ if ((endpoint.shell ?? "posix") === "powershell") {
102
+ throw new OpsError("POSIX bootstrap is Linux-only; PowerShell targets still require an existing Node");
103
+ }
104
+ const pin = bootstrapHooks.loadPin();
105
+ const posix = posixProbe(endpoint, endpoint.node);
106
+ if (String(posix.arch || "").startsWith("unsupported")) throw new OpsError("unsupported target architecture: " + posix.arch);
107
+ const arch = posix.arch;
108
+ const pinArch = pin.archives[arch];
109
+ if (!pinArch) throw new OpsError("no pinned Volta/Node archives for " + arch);
110
+
111
+ if (!apply) {
112
+ return { status: "probed", posix, pin: { volta_version: pin.volta_version, node_version: pin.node_version, arch } };
113
+ }
114
+
115
+ if (posix.node_usable && posix.tools?.node && posix.tools.node !== "missing") {
116
+ return {
117
+ status: "skipped-existing-node",
118
+ connection_node: posix.node_path || posix.tools.node,
119
+ posix,
120
+ note: "existing Node left untouched; register it as connection.node",
121
+ };
122
+ }
123
+
124
+ if (args.ack !== ACK) throw new OpsError("explicit acknowledgement I-APPROVE-THIS-BOOTSTRAP is required");
125
+ if (!args.account) throw new OpsError("--account is required");
126
+ if (!args.host_root) throw new OpsError("--host-root is required");
127
+ identifier(args.account, "toolchain account");
128
+ const hostRoot = rootPath(args.host_root, "linux");
129
+ const nodeVersion = rejectUnpinned("node version", args.node_version || pin.node_version);
130
+ if (nodeVersion !== pin.node_version) throw new OpsError("node version must match the reviewed pin " + pin.node_version);
131
+
132
+ const cacheDir = args.state ? join(args.state, "private", "installers") : join(hostRoot, "_host", "installers-cache");
133
+ const volta = resolveArchive({
134
+ archive: args.volta_archive, sha256: args.volta_sha256, pinEntry: pinArch.volta,
135
+ allowNetwork: Boolean(args.allow_network), cacheDir, label: "Volta",
136
+ });
137
+ const node = resolveArchive({
138
+ archive: args.node_archive, sha256: args.node_sha256, pinEntry: pinArch.node,
139
+ allowNetwork: Boolean(args.allow_network), cacheDir, label: "Node",
140
+ });
141
+
142
+ const installers = installerDir(hostRoot);
143
+ const voltaHome = managedVoltaHome(hostRoot, args.account);
144
+ posixCall(endpoint, "#!/bin/sh\nset -eu\nmkdir -p -- \"$1\" \"$2\"\n", { args: [installers, voltaHome] });
145
+ const remoteVolta = join(installers, volta.filename);
146
+ const remoteNode = join(installers, node.filename);
147
+ posixSend(endpoint, volta.file, remoteVolta);
148
+ posixSend(endpoint, node.file, remoteNode);
149
+ const applied = posixCall(endpoint, SCRIPT, {
150
+ args: ["--apply", remoteVolta, volta.sha256, remoteNode, node.sha256, voltaHome, nodeVersion, ACK],
151
+ timeout: 600,
152
+ });
153
+ const result = parseJsonLine(applied.stdout, "posix apply");
154
+ result.status = result.status || "installed";
155
+ result.posix_probe = posix;
156
+ result.arch = arch;
157
+
158
+ if (args.state && args.host_id) {
159
+ identifier(args.host_id);
160
+ const receipt = join(args.state, "hosts", args.host_id, "bootstrap", newId("receipt") + ".json");
161
+ writeJson(receipt, { ...result, host_id: args.host_id, at: now() });
162
+ result.receipt = receipt;
163
+ }
164
+ return result;
165
+ }
166
+
167
+ export function enroll(state, request) {
168
+ exact(request, new Set(["hosts", "projects"]), new Set(), "enroll");
169
+ const hosts = request.hosts ?? [];
170
+ if (!hosts.length) throw new OpsError("enroll requires hosts");
171
+ const enrolled = [];
172
+ for (const raw of hosts) {
173
+ const host = { ...raw, connection: { ...(raw.connection || {}) } };
174
+ if (host.transport !== "ssh") throw new OpsError("enroll currently supports ssh hosts; local hosts use register");
175
+ validateSshEndpoint(host.connection);
176
+ if ((host.connection.shell ?? "posix") === "powershell") {
177
+ throw new OpsError("POSIX bootstrap/enroll is Linux-only");
178
+ }
179
+ if (!host.connection.node) throw new NodeMissing({ tools: { node: "missing" }, node_usable: false });
180
+ const posix = posixProbe(host.connection, host.connection.node);
181
+ if (posix.tools?.node === "missing" || posix.node_usable === false) throw new NodeMissing(posix);
182
+ const discovery = host.identity === "discover" || host.identity == null;
183
+ if (discovery) host.identity = "0".repeat(64);
184
+ if (!isAbsolute(host.connection.node)) {
185
+ throw new OpsError("connection.node must be the absolute path returned by bootstrap-node");
186
+ }
187
+ validateHost(host);
188
+ const observed = call(host, { action: "probe", ...(discovery ? { identity: null } : {}) }, { timeout: 180 });
189
+ host.identity = observed.identity;
190
+ validateHost(host);
191
+ register(state, { hosts: [host], projects: request.projects });
192
+ const inventory = call(host, { action: "probe" }, { timeout: 180 });
193
+ const snap = join(state, "hosts", host.host_id, "inventory", newId("snapshot") + ".json");
194
+ writeJson(snap, inventory);
195
+ enrolled.push({
196
+ host_id: host.host_id,
197
+ identity: host.identity,
198
+ connection_node: host.connection.node,
199
+ inventory,
200
+ snapshot: snap,
201
+ });
202
+ }
203
+ return {
204
+ status: "enrolled",
205
+ hosts: enrolled,
206
+ host_id: enrolled[0].host_id,
207
+ identity: enrolled[0].identity,
208
+ inventory: enrolled[0].inventory,
209
+ connection_node: enrolled[0].connection_node,
210
+ };
211
+ }
212
+
213
+ export function discoverOrMissing(host) {
214
+ if (host.transport !== "ssh") return null;
215
+ validateSshEndpoint(host.connection);
216
+ const posix = posixProbe(host.connection, host.connection?.node);
217
+ const missing = posix.tools?.node === "missing" || posix.node_usable === false;
218
+ if (missing) throw new NodeMissing(posix);
219
+ if (!host.connection.node && posix.node_path) host.connection.node = posix.node_path;
220
+ return posix;
221
+ }
@@ -3,7 +3,7 @@ import { existsSync, readdirSync, readFileSync, statSync, lstatSync, unlinkSync,
3
3
  import { dirname, isAbsolute, join, relative as pathRelative, resolve, sep } from "node:path";
4
4
  import { hostname } from "node:os";
5
5
  import {
6
- atomicWrite, digest, emptyStatus, identifier, newId, now, noSymlinks, OpsError,
6
+ atomicWrite, digest, emptyStatus, identifier, newId, now, noSymlinks, OpsError, NodeMissing,
7
7
  privateDir, readJson, VERSION, withLock, writeJson,
8
8
  } from "./core.mjs";
9
9
  import { load, ledgerLoad, putCredential, register, save, validate, validateHost, validateStatus } from "./model.mjs";
@@ -13,6 +13,7 @@ import { approval, apply, verifyJournal, requestBase } from "./execution.mjs";
13
13
  import { STANDARD } from "./docs.mjs";
14
14
  import { environmentSpec } from "./host_recipes.mjs";
15
15
  import { fetchSource } from "./sources.mjs";
16
+ import { bootstrapNode, enroll, discoverOrMissing } from "./bootstrap.mjs";
16
17
 
17
18
  export const cliHooks = { probeLocal: transportProbeLocal };
18
19
  export function probeLocal() { return cliHooks.probeLocal(); }
@@ -218,6 +219,15 @@ function parseArgs(argv) {
218
219
  else if (command === "status") { /* none */ }
219
220
  else if (command === "recover-controller-lock") grab("--ack");
220
221
  else if (command === "import-legacy") { grab("--source"); grab("--controller-id"); }
222
+ else if (command === "bootstrap-node") {
223
+ grab("--connection-file"); grab("--host-id"); grab("--account"); grab("--host-root");
224
+ grab("--volta-archive"); grab("--volta-sha256"); grab("--node-archive"); grab("--node-sha256");
225
+ grab("--node-version"); grab("--ack");
226
+ out.probe = flag("--probe");
227
+ out.apply = flag("--apply");
228
+ out.allow_network = flag("--allow-network");
229
+ }
230
+ else if (command === "enroll") grab("--file");
221
231
  else throw new Error("unknown command: " + command);
222
232
  if (argv.length) throw new Error("unrecognized arguments: " + argv.join(" "));
223
233
  return out;
@@ -234,7 +244,7 @@ export function main(argv = process.argv.slice(2)) {
234
244
  process.stdout.write(VERSION + "\n");
235
245
  return 0;
236
246
  }
237
- if (!args.state && !["analyze", "probe", "validate"].includes(args.command)) {
247
+ if (!args.state && !["analyze", "probe", "validate", "bootstrap-node"].includes(args.command)) {
238
248
  process.stderr.write("--state is required; it is never guessed from cwd\n");
239
249
  return 2;
240
250
  }
@@ -254,10 +264,14 @@ export function main(argv = process.argv.slice(2)) {
254
264
  } else if (args.connection_file) {
255
265
  const h = readJson(args.connection_file);
256
266
  const discovery = h.identity === "discover";
267
+ if (h.transport === "ssh") discoverOrMissing(h);
257
268
  if (discovery) h.identity = "0".repeat(64);
258
269
  validateHost(h);
259
270
  result = call(h, { action: "probe", ...(discovery ? { identity: null } : {}) }, { timeout: 180 });
260
- if (discovery) result.registration_note = "Read-only discovery over your pinned known_hosts. Review identity and store it explicitly; discover is never valid for register/apply.";
271
+ if (discovery) {
272
+ result.next = "ops.mjs --state STATE enroll --file register.json";
273
+ result.registration_note = "Read-only discovery over your pinned known_hosts. Next must persist via enroll (or register + probe --host); discover is never valid for register/apply.";
274
+ }
261
275
  } else result = probeLocal();
262
276
  if (args.output) writeJson(resolve(args.output), result);
263
277
  } else if (cmd === "register") result = register(state, readJson(args.file));
@@ -302,10 +316,18 @@ export function main(argv = process.argv.slice(2)) {
302
316
  };
303
317
  } else if (cmd === "recover-controller-lock") result = breakControllerLock(state, args.ack);
304
318
  else if (cmd === "import-legacy") result = importLegacy(state, resolve(args.source), args.controller_id);
319
+ else if (cmd === "bootstrap-node") result = bootstrapNode({ ...args, state });
320
+ else if (cmd === "enroll") result = enroll(state, readJson(args.file));
305
321
  process.stdout.write(JSON.stringify(result, null, 2) + "\n");
306
- return ["failed", "partial", "unknown", "docs_pending"].includes(result.status) ? 2 : 0;
322
+ return ["failed", "partial", "unknown", "docs_pending", "blocked"].includes(result.status) ? 2 : 0;
307
323
  } catch (e) {
308
- process.stderr.write(JSON.stringify({ status: "blocked", error: e.message || String(e) }) + "\n");
324
+ const payload = { status: "blocked", error: e.message || String(e) };
325
+ if (e instanceof NodeMissing) {
326
+ payload.error = "node-missing";
327
+ payload.next = e.next;
328
+ payload.posix = e.posix;
329
+ }
330
+ process.stderr.write(JSON.stringify(payload) + "\n");
309
331
  return 2;
310
332
  }
311
333
  }
@@ -19,6 +19,16 @@ export class OpsError extends Error {
19
19
  }
20
20
  }
21
21
 
22
+ export class NodeMissing extends OpsError {
23
+ constructor(posix, next = "ops.mjs bootstrap-node --apply") {
24
+ super("node-missing");
25
+ this.name = "NodeMissing";
26
+ this.code = "node-missing";
27
+ this.next = next;
28
+ this.posix = posix;
29
+ }
30
+ }
31
+
22
32
  export class UnknownResult extends OpsError {
23
33
  constructor(message) {
24
34
  super(message);
@@ -26,6 +36,8 @@ export class UnknownResult extends OpsError {
26
36
  }
27
37
  }
28
38
 
39
+
40
+
29
41
  export function now() {
30
42
  return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
31
43
  }
@@ -1,7 +1,9 @@
1
1
  /** Pinned environment-management recipes. No latest-version guessing or implicit profile edits. */
2
2
  import { identifier, exact, newId, now, OpsError, targetJoin, within } from "./core.mjs";
3
3
  import { load } from "./model.mjs";
4
- import { call } from "./transport.mjs";
4
+ import { call as transportCall } from "./transport.mjs";
5
+
6
+ export const hostRecipesHooks = { call: transportCall };
5
7
 
6
8
  function shlexQuote(s) {
7
9
  if (s === "") return "''";
@@ -13,12 +15,18 @@ function reEscape(s) {
13
15
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
16
  }
15
17
 
18
+ function managedVoltaBin(host, account, inventory) {
19
+ const home = (inventory.defaults?.VOLTA_HOME || "").trim()
20
+ || targetJoin(host, "_host/toolchains/" + account + "/volta");
21
+ return host.platform === "windows" ? home + "\\bin\\volta" : home.replace(/\/+$/, "") + "/bin/volta";
22
+ }
23
+
16
24
  export function environmentSpec(state, hid, request) {
17
25
  exact(request, new Set(["account", "python_versions", "uv_path", "node_version", "npm_version", "volta_path", "java_version", "original_java_candidate", "sdkman_init"]), new Set(["account"]), "environment request");
18
26
  identifier(request.account, "toolchain account");
19
27
  const status = load(state);
20
28
  const host = status.hosts[hid];
21
- const inventory = call(host, { action: "probe", disk_roots: [host.root] }, { timeout: 180 });
29
+ const inventory = hostRecipesHooks.call(host, { action: "probe", disk_roots: [host.root] }, { timeout: 180 });
22
30
  const base = targetJoin(host, "_host/toolchains/" + request.account);
23
31
  const defaults = Object.fromEntries(Object.entries(inventory.tools).filter(([k, v]) => ["java", "python", "python3", "node", "npm"].includes(k) && v.status === "observed"));
24
32
  const actions = [];
@@ -55,8 +63,16 @@ export function environmentSpec(state, hid, request) {
55
63
  }
56
64
  if (request.node_version) {
57
65
  const v = pinned(request.node_version, "Node");
58
- const volta = request.volta_path || inventory.tools.volta?.path;
59
- if (!volta) throw new OpsError("Volta missing: install a reviewed pinned release before the managed environment recipe");
66
+ let volta = request.volta_path || inventory.tools.volta?.path;
67
+ if (!volta) {
68
+ const candidate = managedVoltaBin(host, request.account, inventory);
69
+ const snap = hostRecipesHooks.call(host, { action: "snapshot", paths: [candidate] }, { timeout: 60 });
70
+ const st = snap.paths?.[candidate];
71
+ if (!st || st.kind === "absent") {
72
+ throw new OpsError("Volta missing: install a reviewed pinned release before the managed environment recipe; for a Node-less Linux SSH target run ops.mjs bootstrap-node first");
73
+ }
74
+ volta = candidate;
75
+ }
60
76
  const home = base + (host.platform === "windows" ? "\\volta" : "/volta");
61
77
  mkdir("_host/toolchains/" + request.account + "/volta");
62
78
  let old = (inventory.tools.node?.version ?? "").trim();