pllla-connect 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js ADDED
@@ -0,0 +1,397 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pllla-connect — the one command a PLLLA connect card hands out, and the
4
+ * child process the desktop app runs to make "+ → Create → first greeting"
5
+ * take zero user actions (docs/agent/EXTERNAL_RUNTIME.md §6):
6
+ *
7
+ * npx pllla-connect pair_live_xxx --server https://pllla.com [--runtime openclaw]
8
+ * [--account agent-1a2b3c4d] [--json]
9
+ *
10
+ * Flow (§6.4): catalog → detect → [found: node → bridge]
11
+ * / [missing: node → install → onboard → bridge]
12
+ * → pair (the plugin does it at gateway start) → done.
13
+ *
14
+ * Three principles the flow never bends: the user's system Node / global npm
15
+ * is never touched (§6.1); detection never trusts PATH alone (§6.2); with no
16
+ * model login on the machine it stops honestly with `needs_model_auth`
17
+ * instead of lending a starter brain (§6.5).
18
+ */
19
+ import { homedir } from "node:os";
20
+ import { dirname } from "node:path";
21
+ import { deriveAccountId, isValidAccountId, PAIRING_TOKEN_PREFIX, } from "./account.js";
22
+ import { fetchRuntimeCatalog } from "./catalog.js";
23
+ import { detectRuntimes, privateRuntimePrefix, probeRuntimeVersion, } from "./detect.js";
24
+ import { shellQuote } from "./exec.js";
25
+ import { defaultNodeRoot, ensurePrivateNode, scanPrivateNodes, } from "./nodeProvision.js";
26
+ import { resolveNodeForRuntime } from "./nodeResolve.js";
27
+ import { bridgeExisting, buildManualOnboardCommand, buildRuntimeEnv, installFresh, onboardFresh, resolveAuthChoice, } from "./openclaw.js";
28
+ import { ConnectError, createProgressReporter, toConnectError, } from "./progress.js";
29
+ import { compareVersions } from "./version.js";
30
+ const SUPPORTED_CONTRACT_VERSION = 1;
31
+ /** Exit codes: 0 done · 1 failed · 2 stopped honestly (`needs_model_auth`). */
32
+ const EXIT_NEEDS_MODEL_AUTH = 2;
33
+ function parseArgs(argv) {
34
+ const positional = [];
35
+ let serverOrigin = "https://pllla.com";
36
+ let runtimeId = null;
37
+ let accountId = null;
38
+ let json = false;
39
+ for (let index = 0; index < argv.length; index += 1) {
40
+ const arg = argv[index];
41
+ if (arg === "--server") {
42
+ serverOrigin = argv[index + 1] ?? "";
43
+ index += 1;
44
+ }
45
+ else if (arg === "--runtime") {
46
+ runtimeId = argv[index + 1] ?? null;
47
+ index += 1;
48
+ }
49
+ else if (arg === "--account") {
50
+ accountId = argv[index + 1] ?? null;
51
+ index += 1;
52
+ }
53
+ else if (arg === "--json") {
54
+ json = true;
55
+ }
56
+ else if (arg === "--help" || arg === "-h") {
57
+ printHelp();
58
+ process.exit(0);
59
+ }
60
+ else {
61
+ positional.push(arg);
62
+ }
63
+ }
64
+ const pairingToken = positional[0] ?? "";
65
+ if (!pairingToken.startsWith(PAIRING_TOKEN_PREFIX)) {
66
+ // Usage on the error path goes to stderr — stdout is the NDJSON channel.
67
+ printHelp(process.stderr);
68
+ throw new Error("A pairing token (pair_live_…) is required — copy the command from your agent's connect card in PLLLA.");
69
+ }
70
+ if (!/^https?:\/\//.test(serverOrigin)) {
71
+ throw new Error(`--server must be an http(s) origin, got "${serverOrigin}".`);
72
+ }
73
+ if (accountId !== null && !isValidAccountId(accountId)) {
74
+ throw new Error(`--account must be 1–64 letters, digits, "-" or "_" (got "${accountId}").`);
75
+ }
76
+ return {
77
+ pairingToken,
78
+ serverOrigin: serverOrigin.replace(/\/+$/, ""),
79
+ runtimeId,
80
+ accountId,
81
+ json,
82
+ };
83
+ }
84
+ function printHelp(stream = process.stdout) {
85
+ stream.write(`
86
+ pllla-connect — connect a self-hosted agent runtime to a PLLLA agent
87
+
88
+ Usage:
89
+ npx pllla-connect <pair_live_token> [--server <origin>] [--runtime <id>]
90
+ [--account <id>] [--json]
91
+
92
+ --server PLLLA origin the agent lives on (default https://pllla.com)
93
+ --runtime Runtime id from the server catalog (default: whatever is detected)
94
+ --account Bridge account id (default: derived from the token)
95
+ --json Machine progress: one JSON object per line on stdout
96
+
97
+ The pairing token comes from the connect card in your PLLLA agent's chat.
98
+ Exit codes: 0 connected · 1 failed · 2 stopped — sign in to a model first.
99
+
100
+ `);
101
+ }
102
+ function buildConnectCommand(args) {
103
+ const parts = [
104
+ "npx pllla-connect",
105
+ args.pairingToken,
106
+ "--server",
107
+ args.serverOrigin,
108
+ ];
109
+ if (args.runtimeId)
110
+ parts.push("--runtime", args.runtimeId);
111
+ if (args.accountId)
112
+ parts.push("--account", args.accountId);
113
+ return parts.join(" ");
114
+ }
115
+ function pickTarget(detections, runtimeId) {
116
+ const detected = detections.find((detection) => detection.binaryPath) ??
117
+ detections.find((detection) => detection.stateDirFound || detection.gatewayLive);
118
+ if (detected)
119
+ return detected;
120
+ if (detections.length === 1)
121
+ return detections[0];
122
+ throw new ConnectError({
123
+ phase: "detect",
124
+ code: "unsupported_runtime",
125
+ message: runtimeId
126
+ ? `Runtime "${runtimeId}" was not detected.`
127
+ : `No runtime is installed and the catalog lists several (${detections
128
+ .map((detection) => detection.runtime.id)
129
+ .join(", ")}) — pass --runtime <id> to choose which one to install.`,
130
+ });
131
+ }
132
+ async function loadCatalog(args, reporter) {
133
+ reporter.progress("catalog", `Loading runtime catalog from ${args.serverOrigin}…`);
134
+ let catalog;
135
+ try {
136
+ catalog = await fetchRuntimeCatalog(args.serverOrigin);
137
+ }
138
+ catch (error) {
139
+ throw toConnectError(error, "catalog");
140
+ }
141
+ if (catalog.contractVersion !== SUPPORTED_CONTRACT_VERSION) {
142
+ throw new ConnectError({
143
+ phase: "catalog",
144
+ code: "unsupported_runtime",
145
+ message: `This pllla-connect build speaks contract v${SUPPORTED_CONTRACT_VERSION}, but the server speaks v${catalog.contractVersion}. Run the latest: npx pllla-connect@latest`,
146
+ });
147
+ }
148
+ const candidates = args.runtimeId
149
+ ? catalog.runtimes.filter((runtime) => runtime.id === args.runtimeId)
150
+ : catalog.runtimes;
151
+ if (candidates.length === 0) {
152
+ throw new ConnectError({
153
+ phase: "catalog",
154
+ code: "unsupported_runtime",
155
+ message: args.runtimeId
156
+ ? `Unknown runtime "${args.runtimeId}". Known: ${catalog.runtimes
157
+ .map((runtime) => runtime.id)
158
+ .join(", ")}`
159
+ : "The server's runtime catalog is empty.",
160
+ });
161
+ }
162
+ reporter.emit({
163
+ phase: "catalog",
164
+ status: "done",
165
+ message: `Catalog: ${candidates.map((runtime) => runtime.label).join(", ")}`,
166
+ });
167
+ return candidates;
168
+ }
169
+ async function connect(args, reporter) {
170
+ const home = homedir();
171
+ const log = reporter.log;
172
+ const candidates = await loadCatalog(args, reporter);
173
+ reporter.progress("detect", "Detecting installed runtimes…");
174
+ const detections = await detectRuntimes(candidates, {
175
+ home,
176
+ extraBinDirs: scanPrivateNodes(defaultNodeRoot(home)).map((node) => node.nodeBinDir),
177
+ });
178
+ const target = pickTarget(detections, args.runtimeId);
179
+ const runtime = target.runtime;
180
+ if (runtime.id !== "openclaw") {
181
+ throw new ConnectError({
182
+ phase: "detect",
183
+ code: "unsupported_runtime",
184
+ message: `This pllla-connect build has no bridge adapter for "${runtime.id}" yet. Run the latest: npx pllla-connect@latest`,
185
+ });
186
+ }
187
+ if (!target.binaryPath && (target.stateDirFound || target.gatewayLive)) {
188
+ // Installed somewhere we cannot see — a second install is exactly the
189
+ // trap §6.2 forbids, so stop and let a shell with the real PATH finish.
190
+ throw new ConnectError({
191
+ phase: "detect",
192
+ code: "bridge_failed",
193
+ message: `${runtime.label} looks installed (${[
194
+ target.stateDirFound ? "state dir found" : "",
195
+ target.gatewayLive ? "gateway answering" : "",
196
+ ]
197
+ .filter(Boolean)
198
+ .join(", ")}) but its CLI was not found in any known location. Open a terminal where \`${runtime.detect.commands[0]}\` works and run the connect command there.`,
199
+ manualCommand: buildConnectCommand(args),
200
+ });
201
+ }
202
+ const assertVersion = (binaryPath, version) => {
203
+ if (version && compareVersions(version, runtime.runtime.minVersion) < 0) {
204
+ throw new ConnectError({
205
+ phase: "detect",
206
+ code: "version_too_old",
207
+ message: `${runtime.label} ${version} is older than ${runtime.runtime.minVersion}, the first version with the PLLLA bridge extension point. Update it yourself (PLLLA never updates your install), then re-run.`,
208
+ manualCommand: `${shellQuote(binaryPath)} update`,
209
+ });
210
+ }
211
+ };
212
+ const accountId = args.accountId ?? deriveAccountId(args.pairingToken);
213
+ const nodeProgress = (message) => reporter.progress("node", message);
214
+ if (target.binaryPath) {
215
+ const binaryPath = target.binaryPath;
216
+ assertVersion(binaryPath, target.version);
217
+ reporter.emit({
218
+ phase: "detect",
219
+ status: "done",
220
+ message: `Found ${runtime.label} ${target.version ?? "(version unknown)"} at ${binaryPath}${target.gatewayLive ? " (gateway running)" : ""}.`,
221
+ });
222
+ reporter.progress("node", `Checking for a Node ${runtime.label} accepts…`);
223
+ const node = await resolveNodeForRuntime({
224
+ nodeRange: runtime.runtime.nodeRange,
225
+ provisionNodeMajor: runtime.runtime.provisionNodeMajor,
226
+ home,
227
+ preferredDirs: [dirname(binaryPath)],
228
+ log,
229
+ progress: nodeProgress,
230
+ });
231
+ reporter.emit({
232
+ phase: "node",
233
+ status: "done",
234
+ message: `Node ${node.version} (${node.source}).`,
235
+ });
236
+ if (!target.version) {
237
+ // Detection could not run the CLI (no node on the GUI PATH); now that
238
+ // a qualifying Node is known, read the version before touching config.
239
+ const version = probeRuntimeVersion(binaryPath, buildRuntimeEnv({ nodeBinDir: node.nodeBinDir, binaryPath, home }));
240
+ assertVersion(binaryPath, version);
241
+ if (!version) {
242
+ log(`Could not read ${runtime.label}'s version — continuing; the bridge step will surface a real incompatibility.`);
243
+ }
244
+ }
245
+ await runBridge({
246
+ reporter,
247
+ args,
248
+ runtime,
249
+ binaryPath,
250
+ nodeBinDir: node.nodeBinDir,
251
+ accountId,
252
+ home,
253
+ });
254
+ return accountId;
255
+ }
256
+ reporter.emit({
257
+ phase: "detect",
258
+ status: "done",
259
+ message: `${runtime.label} is not installed — PLLLA will install a private copy.`,
260
+ });
261
+ reporter.progress("node", `Preparing private Node ${runtime.runtime.provisionNodeMajor}…`);
262
+ const node = await ensurePrivateNode({
263
+ nodeRange: runtime.runtime.nodeRange,
264
+ provisionNodeMajor: runtime.runtime.provisionNodeMajor,
265
+ root: defaultNodeRoot(home),
266
+ log,
267
+ progress: nodeProgress,
268
+ });
269
+ reporter.emit({
270
+ phase: "node",
271
+ status: "done",
272
+ message: `Private Node ${node.version} ready.`,
273
+ });
274
+ reporter.progress("install", `Installing ${runtime.label}…`);
275
+ const installed = await installFresh({
276
+ nodeBin: node.nodeBin,
277
+ npmCli: node.npmCli,
278
+ prefix: privateRuntimePrefix(home, runtime.id),
279
+ home,
280
+ log,
281
+ });
282
+ reporter.emit({
283
+ phase: "install",
284
+ status: "done",
285
+ message: `${runtime.label} installed at ${installed.binaryPath}.`,
286
+ });
287
+ reporter.progress("onboard", "Looking for a model login on this machine…");
288
+ const auth = await resolveAuthChoice({
289
+ home,
290
+ nodeBinDir: node.nodeBinDir,
291
+ log,
292
+ });
293
+ if (!auth) {
294
+ throw new ConnectError({
295
+ phase: "onboard",
296
+ code: "needs_model_auth",
297
+ message: `${runtime.label} is installed but this machine has no model login: Claude Code is not signed in and Ollama has no models. Run the command below in a terminal, approve the device code it shows, then re-run this connect command.`,
298
+ manualCommand: buildManualOnboardCommand({
299
+ binaryPath: installed.binaryPath,
300
+ nodeBinDir: node.nodeBinDir,
301
+ }),
302
+ });
303
+ }
304
+ log(auth.detail);
305
+ if (auth.warning)
306
+ log(`Warning: ${auth.warning}`);
307
+ reporter.progress("onboard", `Setting up the brain (${auth.choice})…`);
308
+ await onboardFresh({
309
+ binaryPath: installed.binaryPath,
310
+ authChoice: auth.choice,
311
+ nodeBinDir: node.nodeBinDir,
312
+ home,
313
+ log,
314
+ });
315
+ reporter.emit({
316
+ phase: "onboard",
317
+ status: "done",
318
+ message: `Brain ready (${auth.choice}).`,
319
+ });
320
+ await runBridge({
321
+ reporter,
322
+ args,
323
+ runtime,
324
+ binaryPath: installed.binaryPath,
325
+ nodeBinDir: node.nodeBinDir,
326
+ accountId,
327
+ home,
328
+ });
329
+ return accountId;
330
+ }
331
+ async function runBridge(params) {
332
+ const { reporter, args, runtime, binaryPath, nodeBinDir, accountId, home } = params;
333
+ reporter.progress("bridge", `Connecting ${runtime.label} to PLLLA…`);
334
+ let bridged;
335
+ try {
336
+ bridged = await bridgeExisting({
337
+ binaryPath,
338
+ nodeBinDir,
339
+ accountId,
340
+ pairingToken: args.pairingToken,
341
+ serverOrigin: args.serverOrigin,
342
+ bridgePackage: runtime.bridge.package,
343
+ home,
344
+ log: reporter.log,
345
+ });
346
+ }
347
+ catch (error) {
348
+ throw toConnectError(error, "bridge", "bridge_failed");
349
+ }
350
+ reporter.emit({
351
+ phase: "bridge",
352
+ status: "done",
353
+ message: bridged.gatewayRestarted
354
+ ? "Bridge configured; gateway restarted."
355
+ : "Bridge configured; start the gateway to pair.",
356
+ });
357
+ reporter.progress("pair", "The bridge pairs with PLLLA when the gateway starts — the connect card flips to Connected and the agent sends its first greeting.");
358
+ }
359
+ async function main() {
360
+ const argv = process.argv.slice(2);
361
+ const reporter = createProgressReporter({ json: argv.includes("--json") });
362
+ let args;
363
+ try {
364
+ args = parseArgs(argv);
365
+ }
366
+ catch (error) {
367
+ reporter.log(`pllla-connect: ${error instanceof Error ? error.message : String(error)}`);
368
+ return 1;
369
+ }
370
+ try {
371
+ const account = await connect(args, reporter);
372
+ reporter.emit({
373
+ phase: "done",
374
+ status: "done",
375
+ account,
376
+ message: "Watch the agent's chat in PLLLA for the first greeting.",
377
+ });
378
+ return 0;
379
+ }
380
+ catch (error) {
381
+ const failure = toConnectError(error, "bridge");
382
+ reporter.emit({
383
+ phase: failure.phase,
384
+ status: "error",
385
+ code: failure.code,
386
+ message: failure.message,
387
+ manualCommand: failure.manualCommand,
388
+ });
389
+ return failure.code === "needs_model_auth" ? EXIT_NEEDS_MODEL_AUTH : 1;
390
+ }
391
+ }
392
+ main().then((code) => {
393
+ process.exitCode = code;
394
+ }, (error) => {
395
+ console.error(`\npllla-connect crashed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
396
+ process.exitCode = 1;
397
+ });
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Private Node provisioning (docs/agent/EXTERNAL_RUNTIME.md §6.1).
3
+ *
4
+ * PLLLA never upgrades or touches the user's system Node / global npm — that
5
+ * breaks their other projects. A runtime that needs a newer Node gets its own
6
+ * copy under `~/.pllla/runtimes/node/<version>/`, straight from the official
7
+ * nodejs.org tarball with SHASUMS256 verification. Electron-as-node is not an
8
+ * option here: the runtime's native modules (koffi, tree-sitter, …) need a
9
+ * real Node ABI.
10
+ *
11
+ * Which major to provision (`provisionNodeMajor`) and which versions qualify
12
+ * (`nodeRange`) are server-decided catalog fields. Network and filesystem
13
+ * work is isolated in `ensurePrivateNode`; everything it decides with is a
14
+ * pure function below so it can be unit-tested without a download.
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { execFileSync } from "node:child_process";
18
+ import { createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync, } from "node:fs";
19
+ import { once } from "node:events";
20
+ import { finished } from "node:stream/promises";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { runCommand } from "./exec.js";
24
+ import { ConnectError } from "./progress.js";
25
+ import { compareVersions, formatVersion, parseVersion, satisfiesNodeRange, } from "./version.js";
26
+ export const NODE_DIST_ORIGIN = "https://nodejs.org/dist";
27
+ export function defaultNodeRoot(home = homedir()) {
28
+ return join(home, ".pllla", "runtimes", "node");
29
+ }
30
+ export function toDistPlatform(platform) {
31
+ if (platform === "darwin" || platform === "linux")
32
+ return platform;
33
+ return null;
34
+ }
35
+ export function toDistArch(arch) {
36
+ if (arch === "arm64" || arch === "x64")
37
+ return arch;
38
+ return null;
39
+ }
40
+ /** `node-v26.0.0-darwin-arm64.tar.gz` */
41
+ export function tarballName(version, platform, arch) {
42
+ const parsed = parseVersion(version);
43
+ if (!parsed)
44
+ throw new Error(`Not a Node version: "${version}"`);
45
+ return `node-v${formatVersion(parsed)}-${platform}-${arch}.tar.gz`;
46
+ }
47
+ /** The `files` key nodejs.org lists for a platform tarball (`osx-arm64-tar`, `linux-x64`). */
48
+ export function distFileKey(platform, arch) {
49
+ return platform === "darwin" ? `osx-${arch}-tar` : `linux-${arch}`;
50
+ }
51
+ /**
52
+ * Highest release of `major` from the dist index (optionally only releases
53
+ * that ship `requiredFile`). The index lists releases only, so "highest" is
54
+ * "latest stable"; null when the major has no matching release.
55
+ */
56
+ export function pickLatestOfMajor(entries, major, requiredFile) {
57
+ let best = null;
58
+ for (const entry of entries) {
59
+ const parsed = parseVersion(entry.version);
60
+ if (!parsed || parsed.major !== major)
61
+ continue;
62
+ if (requiredFile && !(entry.files ?? []).includes(requiredFile))
63
+ continue;
64
+ if (!best || compareVersions(parsed, best.parsed) > 0) {
65
+ best = { entry, parsed };
66
+ }
67
+ }
68
+ return best?.entry ?? null;
69
+ }
70
+ /** `SHASUMS256.txt` → filename → sha256 (hex, lowercase). */
71
+ export function parseShasums(text) {
72
+ const map = new Map();
73
+ for (const line of text.split("\n")) {
74
+ const match = /^([0-9a-fA-F]{64})\s+\*?(\S+)\s*$/.exec(line.trim());
75
+ if (match)
76
+ map.set(match[2], match[1].toLowerCase());
77
+ }
78
+ return map;
79
+ }
80
+ /** Complete installs under `<root>` (a `bin/node` exists), newest first. */
81
+ export function scanPrivateNodes(root) {
82
+ let names;
83
+ try {
84
+ names = readdirSync(root);
85
+ }
86
+ catch {
87
+ return [];
88
+ }
89
+ const found = [];
90
+ for (const name of names) {
91
+ const parsed = parseVersion(name);
92
+ if (!parsed)
93
+ continue;
94
+ const dir = join(root, name);
95
+ const nodeBinDir = join(dir, "bin");
96
+ const nodeBin = join(nodeBinDir, "node");
97
+ if (!existsSync(nodeBin))
98
+ continue;
99
+ found.push({
100
+ version: formatVersion(parsed),
101
+ dir,
102
+ nodeBin,
103
+ nodeBinDir,
104
+ npmCli: join(dir, "lib", "node_modules", "npm", "bin", "npm-cli.js"),
105
+ });
106
+ }
107
+ return found.sort((a, b) => compareVersions(b.version, a.version));
108
+ }
109
+ /** The newest installed private Node that satisfies `nodeRange`. */
110
+ export function pickPrivateNode(installed, nodeRange) {
111
+ return (installed
112
+ .filter((node) => satisfiesNodeRange(node.version, nodeRange))
113
+ .sort((a, b) => compareVersions(b.version, a.version))[0] ?? null);
114
+ }
115
+ async function fetchText(url) {
116
+ const response = await fetch(url);
117
+ if (!response.ok)
118
+ throw new Error(`HTTP ${response.status} for ${url}`);
119
+ return response.text();
120
+ }
121
+ /** Streams `url` to `dest`, hashing on the fly. Returns the sha256 hex. */
122
+ async function downloadToFile(url, dest, onProgress) {
123
+ const response = await fetch(url);
124
+ if (!response.ok || !response.body) {
125
+ throw new Error(`HTTP ${response.status} for ${url}`);
126
+ }
127
+ const total = Number(response.headers.get("content-length") ?? 0);
128
+ const hash = createHash("sha256");
129
+ const reader = response.body.getReader();
130
+ const out = createWriteStream(dest);
131
+ let received = 0;
132
+ try {
133
+ for (;;) {
134
+ const { done, value } = await reader.read();
135
+ if (done)
136
+ break;
137
+ hash.update(value);
138
+ received += value.byteLength;
139
+ if (!out.write(value))
140
+ await once(out, "drain");
141
+ onProgress(received, total);
142
+ }
143
+ }
144
+ finally {
145
+ out.end();
146
+ }
147
+ await finished(out);
148
+ return hash.digest("hex");
149
+ }
150
+ function provisionError(message, cause) {
151
+ return new ConnectError({
152
+ phase: "node",
153
+ code: "node_provision_failed",
154
+ message,
155
+ cause,
156
+ });
157
+ }
158
+ function formatMegabytes(bytes) {
159
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
160
+ }
161
+ /**
162
+ * Reuses a private Node that satisfies `nodeRange`, otherwise downloads the
163
+ * newest release of `provisionNodeMajor` into `<root>/<version>/` (staged as
164
+ * `<version>.partial` and renamed only after the checksum, extraction, and a
165
+ * `--version` sanity run all pass, so a crashed download never masquerades
166
+ * as an install). Windows is refused honestly — no tar.gz layout there.
167
+ */
168
+ export async function ensurePrivateNode(params) {
169
+ const { nodeRange, provisionNodeMajor, log, progress } = params;
170
+ const root = params.root ?? defaultNodeRoot();
171
+ const reusable = pickPrivateNode(scanPrivateNodes(root), nodeRange);
172
+ if (reusable) {
173
+ log(`Reusing private Node ${reusable.version} at ${reusable.dir}.`);
174
+ return reusable;
175
+ }
176
+ const platform = toDistPlatform(params.platform ?? process.platform);
177
+ const arch = toDistArch(params.arch ?? process.arch);
178
+ if (!platform || !arch) {
179
+ throw provisionError(`Private Node provisioning supports macOS and Linux (arm64/x64) only — this machine is ${params.platform ?? process.platform}/${params.arch ?? process.arch}. Install Node ${provisionNodeMajor} yourself and re-run.`);
180
+ }
181
+ progress(`Preparing private Node ${provisionNodeMajor}…`);
182
+ let index;
183
+ try {
184
+ const parsed = JSON.parse(await fetchText(`${NODE_DIST_ORIGIN}/index.json`));
185
+ if (!Array.isArray(parsed))
186
+ throw new Error("index.json is not a list");
187
+ index = parsed;
188
+ }
189
+ catch (error) {
190
+ throw provisionError(`Could not read the Node release index from nodejs.org: ${error instanceof Error ? error.message : String(error)}`, error);
191
+ }
192
+ const release = pickLatestOfMajor(index, provisionNodeMajor, distFileKey(platform, arch));
193
+ if (!release) {
194
+ throw provisionError(`nodejs.org lists no Node ${provisionNodeMajor} release for ${platform}-${arch}.`);
195
+ }
196
+ const version = formatVersion(parseVersion(release.version));
197
+ if (!satisfiesNodeRange(version, nodeRange)) {
198
+ throw provisionError(`The server asks for Node ${provisionNodeMajor} (found ${version}) but its own range "${nodeRange}" rejects it — catalog misconfiguration.`);
199
+ }
200
+ const archive = tarballName(version, platform, arch);
201
+ const releaseUrl = `${NODE_DIST_ORIGIN}/v${version}`;
202
+ const finalDir = join(root, version);
203
+ const stagingDir = `${finalDir}.partial`;
204
+ const downloadsDir = join(root, ".downloads");
205
+ const archivePath = join(downloadsDir, archive);
206
+ mkdirSync(downloadsDir, { recursive: true });
207
+ rmSync(stagingDir, { recursive: true, force: true });
208
+ rmSync(archivePath, { force: true });
209
+ try {
210
+ progress(`Downloading Node ${version} (${archive})…`);
211
+ const shasums = parseShasums(await fetchText(`${releaseUrl}/SHASUMS256.txt`));
212
+ const expected = shasums.get(archive);
213
+ if (!expected) {
214
+ throw new Error(`SHASUMS256.txt has no entry for ${archive}`);
215
+ }
216
+ let lastPercent = -1;
217
+ let lastBytes = 0;
218
+ const actual = await downloadToFile(`${releaseUrl}/${archive}`, archivePath, (received, total) => {
219
+ if (total > 0) {
220
+ const percent = Math.floor((received / total) * 20) * 5;
221
+ if (percent !== lastPercent) {
222
+ lastPercent = percent;
223
+ progress(`Downloading Node ${version}… ${percent}%`);
224
+ }
225
+ }
226
+ else if (received - lastBytes >= 8 * 1024 * 1024) {
227
+ lastBytes = received;
228
+ progress(`Downloading Node ${version}… ${formatMegabytes(received)}`);
229
+ }
230
+ });
231
+ if (actual !== expected) {
232
+ throw new Error(`Checksum mismatch for ${archive} (expected ${expected}, got ${actual})`);
233
+ }
234
+ progress(`Extracting Node ${version}…`);
235
+ mkdirSync(stagingDir, { recursive: true });
236
+ await runCommand({
237
+ file: "tar",
238
+ args: ["-xzf", archivePath, "-C", stagingDir, "--strip-components=1"],
239
+ timeoutMs: 10 * 60_000,
240
+ onLine: log,
241
+ });
242
+ const nodeBin = join(stagingDir, "bin", "node");
243
+ const reported = execFileSync(nodeBin, ["--version"], {
244
+ encoding: "utf8",
245
+ timeout: 20_000,
246
+ stdio: ["ignore", "pipe", "pipe"],
247
+ }).trim();
248
+ if (reported !== `v${version}`) {
249
+ throw new Error(`Extracted node reports ${reported || "nothing"}, expected v${version}`);
250
+ }
251
+ rmSync(finalDir, { recursive: true, force: true });
252
+ renameSync(stagingDir, finalDir);
253
+ }
254
+ catch (error) {
255
+ rmSync(stagingDir, { recursive: true, force: true });
256
+ throw provisionError(`Private Node ${version} could not be provisioned: ${error instanceof Error ? error.message : String(error)}`, error);
257
+ }
258
+ finally {
259
+ rmSync(archivePath, { force: true });
260
+ }
261
+ log(`Private Node ${version} ready at ${finalDir}.`);
262
+ return {
263
+ version,
264
+ dir: finalDir,
265
+ nodeBin: join(finalDir, "bin", "node"),
266
+ nodeBinDir: join(finalDir, "bin"),
267
+ npmCli: join(finalDir, "lib", "node_modules", "npm", "bin", "npm-cli.js"),
268
+ };
269
+ }