agentroam 0.2.0-preview.16 → 0.2.0-preview.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -17
- package/bin/desktop-download.mjs +62 -0
- package/bin/node-preflight.mjs +15 -22
- package/bin/node-runtime-policy.json +4 -0
- package/bin/runtime-policy.d.mts +5 -0
- package/bin/runtime-policy.mjs +37 -0
- package/dist/args.js +31 -2
- package/dist/args.test.js +16 -0
- package/dist/cli.js +64 -15
- package/dist/desktop-download.test.js +83 -0
- package/dist/device-pairing.js +146 -0
- package/dist/device-pairing.test.js +33 -0
- package/dist/install-script-contract.test.js +35 -11
- package/dist/native-runtime.js +46 -0
- package/dist/native-runtime.test.js +23 -2
- package/dist/node-preflight.test.js +21 -15
- package/dist/opencode-runtime-manager.js +15 -3
- package/dist/opencode-runtime-manager.test.js +33 -1
- package/dist/platform-packages.js +7 -4
- package/dist/platform-packages.test.js +15 -4
- package/dist/platform.js +2 -4
- package/dist/platform.test.js +3 -3
- package/dist/runtime-manager.js +3 -5
- package/dist/service/service-command.js +10 -6
- package/dist/service/service-command.test.js +21 -0
- package/dist/tunnel/public-readiness.js +1 -1
- package/install/README.md +13 -5
- package/install/install-agentroam.ps1 +45 -13
- package/install/install-agentroam.sh +68 -16
- package/package.json +8 -8
- package/dist/pairing.js +0 -2
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { renderQr } from "./qr.js";
|
|
7
|
+
/** Resolve a private local descriptor; never send the administrative token to a tunnel URL. */
|
|
8
|
+
export async function pairingAdmin(dataDir, action, body, options = {}) {
|
|
9
|
+
const request = options.request ?? fetch;
|
|
10
|
+
const directory = options.registry ?? process.env.AGENTROAM_DISCOVERY_DIR ?? join(homedir(), ".agentroam", "services");
|
|
11
|
+
const names = await readdir(directory).catch((error) => { if (error.code === "ENOENT")
|
|
12
|
+
return []; throw error; });
|
|
13
|
+
const candidates = [];
|
|
14
|
+
for (const name of names.filter((name) => name.endsWith(".json"))) {
|
|
15
|
+
try {
|
|
16
|
+
const d = JSON.parse(await readFile(join(directory, name), "utf8"));
|
|
17
|
+
const url = new URL(d.url);
|
|
18
|
+
if (d.protocol !== 1 || typeof d.instanceId !== "string" || !Number.isSafeInteger(d.pid) || d.pid <= 0 || ![resolve(dataDir), resolve(dataDir, "data")].includes(d.dataDir) || !/^[a-f0-9]{64}$/.test(d.token))
|
|
19
|
+
continue;
|
|
20
|
+
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/" || url.username || url.password || url.search || url.hash)
|
|
21
|
+
continue;
|
|
22
|
+
const response = await request(new URL("/api/desktop/identity", url), { headers: { "x-agentroam-desktop-token": d.token }, redirect: "error", signal: AbortSignal.timeout(1500) });
|
|
23
|
+
if (!response.ok)
|
|
24
|
+
continue;
|
|
25
|
+
const identity = await response.json();
|
|
26
|
+
if (identity.protocol === 1 && identity.instanceId === d.instanceId && identity.dataDir === d.dataDir)
|
|
27
|
+
candidates.push(d);
|
|
28
|
+
}
|
|
29
|
+
catch { /* Stale descriptors are expected after a process crash. */ }
|
|
30
|
+
}
|
|
31
|
+
if (candidates.length !== 1)
|
|
32
|
+
throw new Error(candidates.length ? "多个服务使用此数据目录,请先停止多余实例" : "未找到运行中的 AgentRoam 服务,请先启动服务或指定 --data-dir");
|
|
33
|
+
const d = candidates[0];
|
|
34
|
+
const readOnly = ["devices", "requests", "audit"].includes(action);
|
|
35
|
+
const response = await request(new URL(`/api/pairing/admin/${action}`, d.url), {
|
|
36
|
+
method: readOnly ? "GET" : "POST",
|
|
37
|
+
headers: { "x-agentroam-desktop-token": d.token, "content-type": "application/json" },
|
|
38
|
+
...(readOnly ? {} : { body: JSON.stringify(body ?? {}) }),
|
|
39
|
+
redirect: "error", signal: AbortSignal.timeout(5000),
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
const message = response.status === 423 ? "远程访问已锁定,请先执行 agentroam unlock" : response.status === 410 ? "请求已过期或已处理" : response.status === 409 ? "核对短语不匹配" : `设备授权操作失败 (HTTP ${response.status})`;
|
|
43
|
+
throw new Error(message);
|
|
44
|
+
}
|
|
45
|
+
return response.json();
|
|
46
|
+
}
|
|
47
|
+
export function terminalText(value) { return value.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ""); }
|
|
48
|
+
export function pairingQrPayload(accessUrl, grant) {
|
|
49
|
+
const url = new URL(accessUrl);
|
|
50
|
+
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || !/^[A-Za-z0-9_-]{43}$/.test(grant))
|
|
51
|
+
throw new Error("无效的配对地址或授权凭据");
|
|
52
|
+
return `${url.origin}/pair#pair=${grant}`;
|
|
53
|
+
}
|
|
54
|
+
export async function printPairingCode(dataDir, log = console.log, options = {}) {
|
|
55
|
+
const result = await pairingAdmin(dataDir, "code");
|
|
56
|
+
log(`配对码:${result.code.slice(0, 4)} ${result.code.slice(4)}`);
|
|
57
|
+
log("5 分钟内有效,仅可使用一次。手机输入后仍需在电脑上确认核对短语。");
|
|
58
|
+
if ((options.qr ?? true) && (options.interactive ?? process.stdout.isTTY)) {
|
|
59
|
+
const accessUrl = options.accessUrl ?? await readFile(join(dataDir, "tunnel.url"), "utf8").then((value) => value.trim(), () => null);
|
|
60
|
+
if (accessUrl) {
|
|
61
|
+
// Validate the public address before minting an invitation. Never persist the grant.
|
|
62
|
+
pairingQrPayload(accessUrl, "a".repeat(43));
|
|
63
|
+
const { grant } = await pairingAdmin(dataDir, "qr");
|
|
64
|
+
log("手机扫码直接连接(5 分钟、一次有效,请勿分享截图):");
|
|
65
|
+
log(await renderQr(pairingQrPayload(accessUrl, grant)));
|
|
66
|
+
}
|
|
67
|
+
else
|
|
68
|
+
log("生成 App 授权二维码:agentroam pair --url <手机可访问的服务器地址>");
|
|
69
|
+
}
|
|
70
|
+
if (options.interactive ?? (process.stdin.isTTY && process.stdout.isTTY)) {
|
|
71
|
+
await watchPairingApproval(dataDir, result.expiresAt, { log, signal: options.signal });
|
|
72
|
+
}
|
|
73
|
+
else
|
|
74
|
+
log("查看请求:agentroam approvals;批准:agentroam approve <请求ID> --phrase <手机核对短语>");
|
|
75
|
+
}
|
|
76
|
+
/** Watch only while the printed code is usable; approval gets the request's own deadline. */
|
|
77
|
+
export async function watchPairingApproval(dataDir, codeExpiresAt, options = {}) {
|
|
78
|
+
const admin = options.admin ?? pairingAdmin;
|
|
79
|
+
const log = options.log ?? console.log;
|
|
80
|
+
log("等待手机发起连接…");
|
|
81
|
+
while (Date.now() < codeExpiresAt && !options.signal?.aborted) {
|
|
82
|
+
const result = await admin(dataDir, "requests");
|
|
83
|
+
if (result.locked) {
|
|
84
|
+
log("远程访问已锁定");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (result.qrClaimed) {
|
|
88
|
+
log("App 已扫码连接。");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const request = result.requests.find((r) => r.created >= codeExpiresAt - 300_000);
|
|
92
|
+
if (request) {
|
|
93
|
+
log(`新设备请求连接:${terminalText(request.name)}`);
|
|
94
|
+
log(`请求 ID:${request.id}`);
|
|
95
|
+
log(`核对短语:${request.phrase}`);
|
|
96
|
+
const approved = await (options.confirm ?? confirmOnTerminal)(request, options.signal);
|
|
97
|
+
if (options.signal?.aborted)
|
|
98
|
+
return;
|
|
99
|
+
if (Date.now() >= request.expires) {
|
|
100
|
+
log("授权请求已过期,请重新配对。");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
await admin(dataDir, approved ? "approve" : "deny", { id: request.id, ...(approved ? { phrase: request.phrase } : {}) });
|
|
104
|
+
log(approved ? "已批准,手机将自动进入。" : "已拒绝此设备。");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await delay(800, undefined, { signal: options.signal });
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!options.signal?.aborted)
|
|
115
|
+
log("配对码已过期,执行 agentroam pair 生成新码。");
|
|
116
|
+
}
|
|
117
|
+
async function confirmOnTerminal(request, signal) {
|
|
118
|
+
const controller = new AbortController();
|
|
119
|
+
const timer = setTimeout(() => controller.abort(), Math.max(0, request.expires - Date.now()));
|
|
120
|
+
const input = createInterface({ input: process.stdin, output: process.stdout });
|
|
121
|
+
input.once("SIGINT", () => controller.abort());
|
|
122
|
+
try {
|
|
123
|
+
const answer = await input.question("确认手机显示相同短语?输入 yes 批准,其他输入拒绝:", { signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal });
|
|
124
|
+
return answer.trim().toLowerCase() === "yes";
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
input.close();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export async function installedDataDir(fallback) {
|
|
135
|
+
if (resolve(fallback) !== resolve(homedir(), ".agentroam"))
|
|
136
|
+
return fallback;
|
|
137
|
+
try {
|
|
138
|
+
const config = JSON.parse(await readFile(join(homedir(), ".agentroam", "service", "config.json"), "utf8"));
|
|
139
|
+
return typeof config.dataDir === "string" ? resolve(config.dataDir) : fallback;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error.code === "ENOENT")
|
|
143
|
+
return fallback;
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { parseArgs } from "./args.js";
|
|
3
|
+
import { pairingQrPayload, watchPairingApproval } from "./device-pairing.js";
|
|
4
|
+
describe("local approval commands", () => {
|
|
5
|
+
it("encodes a separate QR invitation and validates the server URL", () => {
|
|
6
|
+
expect(parseArgs(["pair", "--url", "https://phone.example/web"]).pairingUrl).toBe("https://phone.example/web");
|
|
7
|
+
const link = new URL(pairingQrPayload("https://phone.example/web", "a".repeat(43)));
|
|
8
|
+
expect(link.origin + link.pathname).toBe("https://phone.example/pair");
|
|
9
|
+
expect(link.search).toBe("");
|
|
10
|
+
expect(link.hash).toBe(`#pair=${"a".repeat(43)}`);
|
|
11
|
+
expect(() => pairingQrPayload("https://user:password@phone.example", "a".repeat(43))).toThrow();
|
|
12
|
+
});
|
|
13
|
+
const id = "12345678-1234-1234-1234-123456789abc";
|
|
14
|
+
it("requires a request ID and matching phrase for explicit approval", () => {
|
|
15
|
+
expect(() => parseArgs(["approve", id])).toThrow(/phrase/);
|
|
16
|
+
expect(parseArgs(["approve", id, "--phrase", "松林·月光·1234"])).toMatchObject({ approvalRequestId: id, approvalPhrase: "松林·月光·1234" });
|
|
17
|
+
for (const command of ["lock", "unlock", "audit", "approvals"])
|
|
18
|
+
expect(parseArgs([command]).command).toBe(command);
|
|
19
|
+
expect(parseArgs(["deny", id]).approvalRequestId).toBe(id);
|
|
20
|
+
});
|
|
21
|
+
it.each([true, false])("displays the phrase and sends only the local user's %s decision", async (approve) => {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
const request = { id, name: "Phone\u001b[31m", phrase: "松林·月光·1234", created: now, expires: now + 300_000, status: "waiting" };
|
|
24
|
+
const admin = vi.fn().mockResolvedValueOnce({ requests: [request], locked: false }).mockResolvedValueOnce({ status: approve ? "approved" : "denied" });
|
|
25
|
+
const log = vi.fn();
|
|
26
|
+
const confirm = vi.fn(async () => approve);
|
|
27
|
+
await watchPairingApproval("/tmp/unused", now + 300_000, { admin: admin, log, confirm });
|
|
28
|
+
expect(log).toHaveBeenCalledWith(`核对短语:${request.phrase}`);
|
|
29
|
+
expect(confirm).toHaveBeenCalledWith(request, undefined);
|
|
30
|
+
expect(admin).toHaveBeenLastCalledWith("/tmp/unused", approve ? "approve" : "deny", { id, ...(approve ? { phrase: request.phrase } : {}) });
|
|
31
|
+
expect(log.mock.calls.flat().join("\n")).not.toContain("\u001b");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -3,14 +3,36 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
4
|
import { describe, expect, it } from "vitest";
|
|
5
5
|
import { MANAGED_NODE_VERSION, NODE_RUNTIME_ASSETS } from "./node-runtime-manager.js";
|
|
6
|
-
import {
|
|
6
|
+
import { MINIMUM_NODE_VERSION, isSupportedNodeVersion } from "../bin/runtime-policy.mjs";
|
|
7
7
|
const installRoot = resolve(import.meta.dirname, "../install");
|
|
8
8
|
describe("standalone installer contracts", () => {
|
|
9
|
+
it.skipIf(process.platform !== "darwin" || process.arch !== "arm64")("allows installation from home and keeps a compatible active Node", async () => {
|
|
10
|
+
const root = await mkdtemp(resolve(process.env.TMPDIR || "/tmp", "agentroam-active-node-"));
|
|
11
|
+
try {
|
|
12
|
+
const homeDir = resolve(root, "home");
|
|
13
|
+
const workspace = resolve(root, "workspace");
|
|
14
|
+
const activeBin = resolve(root, "active bin");
|
|
15
|
+
const nvmRoot = resolve(root, "nvm");
|
|
16
|
+
await Promise.all([mkdir(homeDir), mkdir(workspace), mkdir(activeBin), mkdir(nvmRoot)]);
|
|
17
|
+
const expected = await fakeNode(resolve(activeBin, "node"), "24.13.0");
|
|
18
|
+
await fakeNode(resolve(nvmRoot, "versions/node/v26.0.0/bin/node"), "26.0.0");
|
|
19
|
+
const output = execFileSync("/bin/sh", [resolve(installRoot, "install-agentroam.sh")], {
|
|
20
|
+
cwd: homeDir, encoding: "utf8",
|
|
21
|
+
env: { ...process.env, HOME: homeDir, NVM_DIR: nvmRoot, PATH: `${activeBin}:/usr/bin:/bin`,
|
|
22
|
+
AGENTROAM_BOOTSTRAP_TEST: "1", AGENTROAM_BOOTSTRAP_NODE_DISCOVERY_ONLY: "1" },
|
|
23
|
+
});
|
|
24
|
+
expect(output.trim()).toBe(expected);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
await rm(root, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
9
30
|
it("keeps the macOS installer aligned with the runtime manifest", async () => {
|
|
10
31
|
const scriptPath = resolve(installRoot, "install-agentroam.sh");
|
|
11
32
|
const script = await readFile(scriptPath, "utf8");
|
|
12
33
|
expect(script).toContain(`NODE_VERSION="${MANAGED_NODE_VERSION}"`);
|
|
13
|
-
expect(script).toContain(`
|
|
34
|
+
expect(script).toContain(`MINIMUM_NODE_VERSION="${MINIMUM_NODE_VERSION}"`);
|
|
35
|
+
expect(script).toContain('AGENTROAM_VERSION_REQUEST="${AGENTROAM_VERSION:-preview}"');
|
|
14
36
|
expect(script).toContain(NODE_RUNTIME_ASSETS["darwin-arm64"].archive);
|
|
15
37
|
expect(script).toContain(NODE_RUNTIME_ASSETS["darwin-arm64"].sha256);
|
|
16
38
|
expect(script).toContain("https://registry.npmjs.org");
|
|
@@ -19,8 +41,8 @@ describe("standalone installer contracts", () => {
|
|
|
19
41
|
expect(script).toContain('AGENTROAM_ROOT');
|
|
20
42
|
expect(script).toContain('AGENTROAM_INSTALL_SKIP_SERVICE');
|
|
21
43
|
expect(script).toContain('service install --root "$SERVICE_ROOT" --data-dir "$DATA_DIR"');
|
|
22
|
-
expect(script).toContain("
|
|
23
|
-
expect(script).toContain("
|
|
44
|
+
expect(script).toContain('SERVICE_ROOT="$PWD"');
|
|
45
|
+
expect(script).toContain("find_nvm_node");
|
|
24
46
|
expect(script).toContain('${NVM_DIR:-}');
|
|
25
47
|
expect(script).not.toMatch(/\b(?:brew|sudo|fnm|volta)\b/);
|
|
26
48
|
execFileSync("sh", ["-n", scriptPath]);
|
|
@@ -28,7 +50,8 @@ describe("standalone installer contracts", () => {
|
|
|
28
50
|
it("keeps the Windows installer aligned with the runtime manifest", async () => {
|
|
29
51
|
const script = await readFile(resolve(installRoot, "install-agentroam.ps1"), "utf8");
|
|
30
52
|
expect(script).toContain(`$NodeVersion = "${MANAGED_NODE_VERSION}"`);
|
|
31
|
-
expect(script).toContain(`$
|
|
53
|
+
expect(script).toContain(`$MinimumNodeVersion = "${MINIMUM_NODE_VERSION}"`);
|
|
54
|
+
expect(script).toContain('$AgentRoamVersionRequest = if ($env:AGENTROAM_VERSION) { $env:AGENTROAM_VERSION } else { "preview" }');
|
|
32
55
|
expect(script).toContain(NODE_RUNTIME_ASSETS["windows-amd64"].archive);
|
|
33
56
|
expect(script).toContain(NODE_RUNTIME_ASSETS["windows-amd64"].sha256);
|
|
34
57
|
expect(script).toContain("https://registry.npmjs.org");
|
|
@@ -37,12 +60,12 @@ describe("standalone installer contracts", () => {
|
|
|
37
60
|
expect(script).toContain("$env:AGENTROAM_ROOT");
|
|
38
61
|
expect(script).toContain("$env:AGENTROAM_INSTALL_SKIP_SERVICE");
|
|
39
62
|
expect(script).toContain("service install --root $ServiceRoot --data-dir $DataDir");
|
|
40
|
-
expect(script).toContain("
|
|
41
|
-
expect(script).toContain("Find-
|
|
63
|
+
expect(script).toContain("(Get-Location).Path");
|
|
64
|
+
expect(script).toContain("Find-NvmNode");
|
|
42
65
|
expect(script).toContain("$env:NVM_HOME");
|
|
43
66
|
expect(script).not.toMatch(/\b(?:winget|choco|scoop|Start-Process\s+.*RunAs)\b/i);
|
|
44
67
|
});
|
|
45
|
-
it.skipIf(process.platform !== "darwin" || process.arch !== "arm64")("selects the highest
|
|
68
|
+
it.skipIf(process.platform !== "darwin" || process.arch !== "arm64")("selects the highest compatible NVM Node without changing the active version", async () => {
|
|
46
69
|
const root = await mkdtemp(resolve(process.env.TMPDIR || "/tmp", "agentroam-nvm-discovery-"));
|
|
47
70
|
try {
|
|
48
71
|
const home = resolve(root, "home");
|
|
@@ -52,7 +75,8 @@ describe("standalone installer contracts", () => {
|
|
|
52
75
|
await Promise.all([mkdir(home), mkdir(workspace), mkdir(activeBin), mkdir(nvmRoot)]);
|
|
53
76
|
await fakeNode(resolve(activeBin, "node"), "20.19.0");
|
|
54
77
|
await fakeNode(resolve(nvmRoot, "versions/node/v22.3.0/bin/node"), "22.3.0");
|
|
55
|
-
|
|
78
|
+
await fakeNode(resolve(nvmRoot, "versions/node/v22.22.2/bin/node"), "22.22.2");
|
|
79
|
+
const expected = await fakeNode(resolve(nvmRoot, "versions/node/v25.8.0/bin/node"), "25.8.0");
|
|
56
80
|
await fakeNode(resolve(nvmRoot, "versions/node/v22.99.0/bin/node"), "21.99.0");
|
|
57
81
|
const output = execFileSync("/bin/sh", [resolve(installRoot, "install-agentroam.sh")], {
|
|
58
82
|
cwd: workspace,
|
|
@@ -67,13 +91,13 @@ describe("standalone installer contracts", () => {
|
|
|
67
91
|
},
|
|
68
92
|
});
|
|
69
93
|
expect(output.trim().split("\n").at(-1)).toBe(expected);
|
|
70
|
-
expect(output).toContain("Using Node.js
|
|
94
|
+
expect(output).toContain("Using Node.js v25.8.0 from NVM");
|
|
71
95
|
}
|
|
72
96
|
finally {
|
|
73
97
|
await rm(root, { recursive: true, force: true });
|
|
74
98
|
}
|
|
75
99
|
});
|
|
76
|
-
it.skipIf(process.platform !== "win32" ||
|
|
100
|
+
it.skipIf(process.platform !== "win32" || !isSupportedNodeVersion(process.versions.node))("selects a compatible nvm-windows Node when the active PATH has no Node", async () => {
|
|
77
101
|
const root = await mkdtemp(resolve(process.env.TEMP || process.env.TMP || "C:\\Windows\\Temp", "agentroam-nvm-windows-"));
|
|
78
102
|
try {
|
|
79
103
|
const home = resolve(root, "home");
|
package/dist/native-runtime.js
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
import { chmod } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
export function probeSQLiteRuntime(runtimeRequire) {
|
|
4
|
+
const Database = runtimeRequire("better-sqlite3");
|
|
5
|
+
const database = new Database(":memory:");
|
|
6
|
+
try {
|
|
7
|
+
database.exec("CREATE TABLE runtime_probe (value INTEGER NOT NULL)");
|
|
8
|
+
database.prepare("INSERT INTO runtime_probe VALUES (?)").run(7);
|
|
9
|
+
if (database.prepare("SELECT value FROM runtime_probe").get()?.value !== 7) {
|
|
10
|
+
throw new Error("SQLite runtime query failed");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
database.close();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function probeTerminalRuntime(runtimeRequire, platform = process.platform) {
|
|
18
|
+
const pty = runtimeRequire("node-pty");
|
|
19
|
+
const windows = platform === "win32";
|
|
20
|
+
const marker = "agentroam-runtime-probe";
|
|
21
|
+
const terminal = pty.spawn(windows ? (process.env.ComSpec || "cmd.exe") : "/bin/sh", windows
|
|
22
|
+
? ["/d", "/s", "/c", `echo ${marker}`]
|
|
23
|
+
: ["-c", `printf ${marker}`], {
|
|
24
|
+
name: "xterm-256color", cols: 80, rows: 24, cwd: process.cwd(), env: process.env,
|
|
25
|
+
});
|
|
26
|
+
await new Promise((resolveProbe, rejectProbe) => {
|
|
27
|
+
let output = "";
|
|
28
|
+
const timer = setTimeout(() => {
|
|
29
|
+
dataListener.dispose();
|
|
30
|
+
exitListener.dispose();
|
|
31
|
+
try {
|
|
32
|
+
terminal.kill();
|
|
33
|
+
}
|
|
34
|
+
catch { }
|
|
35
|
+
rejectProbe(new Error("PTY runtime probe timed out"));
|
|
36
|
+
}, 5_000);
|
|
37
|
+
const dataListener = terminal.onData((chunk) => { output = (output + chunk).slice(-4096); });
|
|
38
|
+
const exitListener = terminal.onExit(({ exitCode }) => {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
dataListener.dispose();
|
|
41
|
+
exitListener.dispose();
|
|
42
|
+
if (exitCode === 0 && output.includes(marker))
|
|
43
|
+
resolveProbe();
|
|
44
|
+
else
|
|
45
|
+
rejectProbe(new Error(`PTY runtime probe failed (exit ${exitCode})`));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
3
49
|
export async function repairNativeRuntimePermissions(runtimeRoot, platform = process.platform, arch = process.arch) {
|
|
4
50
|
if (platform === "win32")
|
|
5
51
|
return;
|
|
@@ -1,8 +1,29 @@
|
|
|
1
1
|
import { mkdtemp, mkdir, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
5
|
-
import { repairNativeRuntimePermissions } from "./native-runtime.js";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { probeSQLiteRuntime, probeTerminalRuntime, repairNativeRuntimePermissions } from "./native-runtime.js";
|
|
6
|
+
describe("native runtime health", () => {
|
|
7
|
+
it("detects lazy SQLite binding failures after require succeeds", () => {
|
|
8
|
+
const requireRuntime = (() => class Database {
|
|
9
|
+
constructor() { throw new Error("native binding unavailable"); }
|
|
10
|
+
});
|
|
11
|
+
expect(() => probeSQLiteRuntime(requireRuntime)).toThrow("native binding unavailable");
|
|
12
|
+
});
|
|
13
|
+
it("closes SQLite when its query fails", () => {
|
|
14
|
+
const close = vi.fn();
|
|
15
|
+
const requireRuntime = (() => class Database {
|
|
16
|
+
exec() { throw new Error("database failed"); }
|
|
17
|
+
close = close;
|
|
18
|
+
});
|
|
19
|
+
expect(() => probeSQLiteRuntime(requireRuntime)).toThrow("database failed");
|
|
20
|
+
expect(close).toHaveBeenCalledOnce();
|
|
21
|
+
});
|
|
22
|
+
it("detects PTY spawn failures after require succeeds", async () => {
|
|
23
|
+
const requireRuntime = (() => ({ spawn() { throw new Error("spawn-helper unavailable"); } }));
|
|
24
|
+
await expect(probeTerminalRuntime(requireRuntime)).rejects.toThrow("spawn-helper unavailable");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
6
27
|
describe("repairNativeRuntimePermissions", () => {
|
|
7
28
|
it("restores the node-pty helper execute bits stripped by npm pack", async () => {
|
|
8
29
|
const root = await mkdtemp(resolve(tmpdir(), "agentroam-native-"));
|
|
@@ -4,18 +4,22 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
// @ts-expect-error The preflight intentionally remains plain ESM for Node 18 bootstrap.
|
|
7
|
-
import {
|
|
7
|
+
import { findNvmNode, parsePreflightDataDir, runNodePreflight } from "../bin/node-preflight.mjs";
|
|
8
8
|
const temporaryDirectories = [];
|
|
9
9
|
afterEach(async () => {
|
|
10
10
|
await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
|
11
11
|
});
|
|
12
12
|
describe("Node launcher preflight", () => {
|
|
13
|
-
it("passes through
|
|
13
|
+
it.each(["22.22.0", "22.23.0", "24.0.0", "25.8.0", "26.0.0", "100.0.0"])("passes through Node %s without installing or switching", async (nodeVersion) => {
|
|
14
14
|
const ensureNode = vi.fn();
|
|
15
|
-
|
|
15
|
+
const findSystemNode = vi.fn();
|
|
16
|
+
const spawnChild = vi.fn();
|
|
17
|
+
await expect(runNodePreflight({ nodeVersion, ensureNode, findSystemNode, spawnChild, environment: { AGENTROAM_MANAGED_NODE: "/older/node" } })).resolves.toEqual({ handled: false });
|
|
16
18
|
expect(ensureNode).not.toHaveBeenCalled();
|
|
19
|
+
expect(findSystemNode).not.toHaveBeenCalled();
|
|
20
|
+
expect(spawnChild).not.toHaveBeenCalled();
|
|
17
21
|
});
|
|
18
|
-
it.each(["18.20.0", "20.18.0", "
|
|
22
|
+
it.each(["18.20.0", "20.18.0", "22.1.0", "22.21.9"])("re-executes Node %s through the managed runtime", async (nodeVersion) => {
|
|
19
23
|
const child = fakeChild(0, null);
|
|
20
24
|
const ensureNode = vi.fn(async () => ({ executable: "/managed/node", version: "22.22.0" }));
|
|
21
25
|
const spawnChild = vi.fn((_executable, _args, _options) => child);
|
|
@@ -27,7 +31,7 @@ describe("Node launcher preflight", () => {
|
|
|
27
31
|
launcherPath: "/launcher/agentroam.mjs",
|
|
28
32
|
environment: { PATH: "" },
|
|
29
33
|
ensureNode,
|
|
30
|
-
|
|
34
|
+
findSystemNode: async () => null,
|
|
31
35
|
spawnChild,
|
|
32
36
|
processHost: fakeProcessHost(),
|
|
33
37
|
});
|
|
@@ -38,38 +42,40 @@ describe("Node launcher preflight", () => {
|
|
|
38
42
|
}));
|
|
39
43
|
expect(spawnChild).toHaveBeenCalledWith("/managed/node", ["/launcher/agentroam.mjs", "doctor", "--data-dir", "./custom"], expect.objectContaining({
|
|
40
44
|
stdio: "inherit",
|
|
41
|
-
env: expect.objectContaining({ AGENTROAM_MANAGED_NODE: "
|
|
45
|
+
env: expect.objectContaining({ AGENTROAM_MANAGED_NODE: "/managed/node" }),
|
|
42
46
|
}));
|
|
43
47
|
});
|
|
44
|
-
it("reuses another visible system Node
|
|
48
|
+
it("reuses another visible compatible system Node", async () => {
|
|
45
49
|
const ensureNode = vi.fn();
|
|
46
50
|
const spawnChild = vi.fn((_executable, _args, _options) => fakeChild(7, null));
|
|
47
51
|
await expect(runNodePreflight({
|
|
48
52
|
argv: ["version"],
|
|
49
|
-
nodeVersion: "
|
|
53
|
+
nodeVersion: "20.0.0",
|
|
50
54
|
platform: "win32",
|
|
51
55
|
arch: "x64",
|
|
52
56
|
environment: { PATH: "" },
|
|
53
|
-
|
|
57
|
+
findSystemNode: async () => "C:\\Node25\\node.exe",
|
|
54
58
|
ensureNode,
|
|
55
59
|
spawnChild,
|
|
56
60
|
processHost: fakeProcessHost(),
|
|
57
61
|
})).resolves.toMatchObject({ handled: true, exitCode: 7 });
|
|
58
62
|
expect(ensureNode).not.toHaveBeenCalled();
|
|
59
|
-
expect(spawnChild.mock.calls[0][0]).toBe("C:\\
|
|
63
|
+
expect(spawnChild.mock.calls[0][0]).toBe("C:\\Node25\\node.exe");
|
|
60
64
|
});
|
|
61
|
-
it.skipIf(process.platform === "win32")("selects the highest inactive NVM Node
|
|
65
|
+
it.skipIf(process.platform === "win32")("selects the highest compatible inactive NVM Node", async () => {
|
|
62
66
|
const root = await mkdtemp(resolve(tmpdir(), "agentroam-preflight-nvm-"));
|
|
63
67
|
temporaryDirectories.push(root);
|
|
64
68
|
await fakeNodeExecutable(resolve(root, "versions/node/v22.3.0/bin/node"), "22.3.0");
|
|
65
|
-
|
|
69
|
+
await fakeNodeExecutable(resolve(root, "versions/node/v22.22.2/bin/node"), "22.22.2");
|
|
66
70
|
await fakeNodeExecutable(resolve(root, "versions/node/v23.1.0/bin/node"), "23.1.0");
|
|
67
|
-
|
|
71
|
+
const expected = await fakeNodeExecutable(resolve(root, "versions/node/v25.8.0/bin/node"), "25.8.0");
|
|
72
|
+
await fakeNodeExecutable(resolve(root, "versions/node/v26.0.0-rc.1/bin/node"), "26.0.0-rc.1");
|
|
73
|
+
await expect(findNvmNode({ NVM_DIR: root, HOME: resolve(root, "home") }, "darwin"))
|
|
68
74
|
.resolves.toBe(expected);
|
|
69
75
|
});
|
|
70
76
|
it("rejects a failed managed re-entry instead of looping", async () => {
|
|
71
77
|
await expect(runNodePreflight({
|
|
72
|
-
nodeVersion: "
|
|
78
|
+
nodeVersion: "22.21.9",
|
|
73
79
|
environment: { AGENTROAM_MANAGED_NODE: "22.22.0" },
|
|
74
80
|
})).rejects.toMatchObject({ message: expect.stringContaining("re-entry failed"), exitCode: 2 });
|
|
75
81
|
});
|
|
@@ -94,7 +100,7 @@ describe("Node launcher preflight", () => {
|
|
|
94
100
|
platform: "darwin",
|
|
95
101
|
arch: "arm64",
|
|
96
102
|
environment: { PATH: "" },
|
|
97
|
-
|
|
103
|
+
findSystemNode: async () => "/compatible-node",
|
|
98
104
|
spawnChild: () => child,
|
|
99
105
|
processHost,
|
|
100
106
|
});
|
|
@@ -5,7 +5,9 @@ import { delimiter, isAbsolute, relative, resolve, win32 } from "node:path";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { resolveNpmExecutor } from "./codex-runtime-manager.js";
|
|
7
7
|
const execFileAsync = promisify(execFile);
|
|
8
|
+
// Default managed installation; compatibility has an independent minimum.
|
|
8
9
|
export const OPENCODE_RUNTIME_VERSION = "1.18.27";
|
|
10
|
+
export const OPENCODE_MINIMUM_VERSION = "1.18.27";
|
|
9
11
|
const OPENCODE_PACKAGE = `opencode-ai@${OPENCODE_RUNTIME_VERSION}`;
|
|
10
12
|
const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
11
13
|
const defaults = {
|
|
@@ -49,7 +51,7 @@ export async function resolveOpenCodeRuntime(options) {
|
|
|
49
51
|
return install({ ...options, environment, platform, dependencies, managedRoot });
|
|
50
52
|
}
|
|
51
53
|
export function parseOpenCodeVersion(output) {
|
|
52
|
-
return output.match(/(?:^|\s)(\d
|
|
54
|
+
return output.match(/(?:^|\s)((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))(?:\s|$)/)?.[1] ?? null;
|
|
53
55
|
}
|
|
54
56
|
export function managedOpenCodeBinaryCandidates(root, platform) {
|
|
55
57
|
return platform === "win32"
|
|
@@ -183,12 +185,22 @@ async function validate(executable, source, _platform, dependencies) {
|
|
|
183
185
|
const version = parseOpenCodeVersion(`${result.stdout}\n${result.stderr}`);
|
|
184
186
|
if (!version)
|
|
185
187
|
throw new Error(`Unable to parse OpenCode version from ${executable}`);
|
|
186
|
-
if (version
|
|
187
|
-
throw new Error(`OpenCode ${version} at ${executable} is incompatible; AgentRoam requires
|
|
188
|
+
if (!meetsOpenCodeMinimum(version)) {
|
|
189
|
+
throw new Error(`OpenCode ${version} at ${executable} is incompatible; AgentRoam requires >=${OPENCODE_MINIMUM_VERSION}`);
|
|
188
190
|
}
|
|
189
191
|
await dependencies.run(executable, ["serve", "--help"], 5_000);
|
|
190
192
|
return { executable, version, source };
|
|
191
193
|
}
|
|
194
|
+
// Compare numeric components, so 1.18.100 and 1.19.0 both exceed 1.18.27.
|
|
195
|
+
function meetsOpenCodeMinimum(version) {
|
|
196
|
+
const actual = version.split(".").map(BigInt);
|
|
197
|
+
const minimum = OPENCODE_MINIMUM_VERSION.split(".").map(BigInt);
|
|
198
|
+
for (let index = 0; index < minimum.length; index += 1) {
|
|
199
|
+
if (actual[index] !== minimum[index])
|
|
200
|
+
return actual[index] > minimum[index];
|
|
201
|
+
}
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
192
204
|
async function accessible(path, executable = false) {
|
|
193
205
|
try {
|
|
194
206
|
await access(path, executable ? constants.X_OK : constants.F_OK);
|
|
@@ -2,7 +2,7 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
import { OPENCODE_RUNTIME_VERSION, managedOpenCodeBinaryCandidates, parseOpenCodeVersion, resolveOpenCodeRuntime, } from "./opencode-runtime-manager.js";
|
|
5
|
+
import { OPENCODE_RUNTIME_VERSION, OPENCODE_MINIMUM_VERSION, managedOpenCodeBinaryCandidates, parseOpenCodeVersion, resolveOpenCodeRuntime, } from "./opencode-runtime-manager.js";
|
|
6
6
|
const roots = [];
|
|
7
7
|
afterEach(async () => {
|
|
8
8
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
@@ -31,6 +31,38 @@ describe("resolveOpenCodeRuntime", () => {
|
|
|
31
31
|
})).resolves.toEqual({ executable: binary.path, version: OPENCODE_RUNTIME_VERSION, source: "explicit" });
|
|
32
32
|
expect(run).toHaveBeenCalledWith(binary.path, ["serve", "--help"], 5_000);
|
|
33
33
|
});
|
|
34
|
+
it.each([OPENCODE_MINIMUM_VERSION, "1.18.30", "1.18.100", "1.19.0", "2.0.0"])("reuses PATH version %s without installation", async (version) => {
|
|
35
|
+
const binary = await executable();
|
|
36
|
+
const run = vi.fn(async (_command, args) => ({
|
|
37
|
+
stdout: args[0] === "--version" ? version : "serve help", stderr: "",
|
|
38
|
+
}));
|
|
39
|
+
await expect(resolveOpenCodeRuntime({
|
|
40
|
+
dataDir: binary.root, target: "darwin-arm64", platform: "darwin",
|
|
41
|
+
environment: { PATH: binary.root }, dependencies: { run },
|
|
42
|
+
})).resolves.toEqual({ executable: binary.path, version, source: "global" });
|
|
43
|
+
expect(run.mock.calls.map(([, args]) => args)).toEqual([["--version"], ["serve", "--help"]]);
|
|
44
|
+
});
|
|
45
|
+
it.each(["1.18.26", "1.18.9", "1.17.99", "0.99.99", "dev", "1.19.0-beta.1", "01.19.0"])("rejects unsupported explicit version %s", async (version) => {
|
|
46
|
+
const binary = await executable();
|
|
47
|
+
const run = vi.fn(async () => ({ stdout: version, stderr: "" }));
|
|
48
|
+
await expect(resolveOpenCodeRuntime({
|
|
49
|
+
dataDir: binary.root, target: "darwin-arm64", platform: "darwin",
|
|
50
|
+
environment: { AGENT_OPENCODE_BIN: binary.path, PATH: "" }, dependencies: { run },
|
|
51
|
+
})).rejects.toThrow(/requires >=|Unable to parse OpenCode version/);
|
|
52
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
53
|
+
});
|
|
54
|
+
it("rejects a newer executable without serve support", async () => {
|
|
55
|
+
const binary = await executable();
|
|
56
|
+
await expect(resolveOpenCodeRuntime({
|
|
57
|
+
dataDir: binary.root, target: "darwin-arm64", platform: "darwin",
|
|
58
|
+
environment: { AGENT_OPENCODE_BIN: binary.path, PATH: "" },
|
|
59
|
+
dependencies: { run: async (_command, args) => {
|
|
60
|
+
if (args[0] === "serve")
|
|
61
|
+
throw new Error("serve unavailable");
|
|
62
|
+
return { stdout: "1.18.30", stderr: "" };
|
|
63
|
+
} },
|
|
64
|
+
})).rejects.toThrow("serve unavailable");
|
|
65
|
+
});
|
|
34
66
|
it("rejects relative explicit overrides", async () => {
|
|
35
67
|
await expect(resolveOpenCodeRuntime({
|
|
36
68
|
dataDir: "/data",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
|
-
|
|
4
|
+
import { MINIMUM_NODE_VERSION, assertSupportedNodeVersion } from "../bin/runtime-policy.mjs";
|
|
5
|
+
export const AGENTROAM_VERSION = "0.2.0-preview.18";
|
|
5
6
|
export const PLATFORM_PACKAGES = {
|
|
6
7
|
"darwin-arm64": {
|
|
7
8
|
runtime: "agentroam-runtime-darwin-arm64",
|
|
@@ -37,12 +38,14 @@ export function resolvePlatformRuntime(target, requireFromCli = createRequire(im
|
|
|
37
38
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
38
39
|
if (manifest.packageVersion !== AGENTROAM_VERSION
|
|
39
40
|
|| manifest.target !== target
|
|
40
|
-
|| manifest.
|
|
41
|
-
|| manifest.
|
|
41
|
+
|| manifest.schemaVersion !== 2
|
|
42
|
+
|| manifest.minimumNodeVersion !== MINIMUM_NODE_VERSION
|
|
42
43
|
|| !manifest.nativeFiles
|
|
43
|
-
|| typeof manifest.nativeFiles !== "object"
|
|
44
|
+
|| typeof manifest.nativeFiles !== "object"
|
|
45
|
+
|| Array.isArray(manifest.nativeFiles)) {
|
|
44
46
|
throw new Error(`invalid AgentRoam runtime manifest: ${manifestPath}`);
|
|
45
47
|
}
|
|
48
|
+
assertSupportedNodeVersion();
|
|
46
49
|
return {
|
|
47
50
|
packageName,
|
|
48
51
|
runtimeRoot: dirname(runtimePackageJson),
|
|
@@ -28,8 +28,8 @@ describe("platform packages", () => {
|
|
|
28
28
|
writeFileSync(manifestPath, JSON.stringify({
|
|
29
29
|
packageVersion: AGENTROAM_VERSION,
|
|
30
30
|
target: "windows-amd64",
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
schemaVersion: 2,
|
|
32
|
+
minimumNodeVersion: "22.22.0",
|
|
33
33
|
nativeFiles: { "node_modules/example.node": "a".repeat(64) },
|
|
34
34
|
}));
|
|
35
35
|
const resolved = resolvePlatformRuntime("windows-amd64", fakeRequire({
|
|
@@ -48,8 +48,8 @@ describe("platform packages", () => {
|
|
|
48
48
|
writeFileSync(manifestPath, JSON.stringify({
|
|
49
49
|
packageVersion: "0.0.0",
|
|
50
50
|
target: "darwin-arm64",
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
schemaVersion: 2,
|
|
52
|
+
minimumNodeVersion: "22.22.0",
|
|
53
53
|
nativeFiles: {},
|
|
54
54
|
}));
|
|
55
55
|
expect(() => resolvePlatformRuntime("windows-amd64", fakeRequire({
|
|
@@ -61,6 +61,17 @@ describe("platform packages", () => {
|
|
|
61
61
|
const entry = resolvePlatformTui("windows-amd64", fakeRequire({ "/entry": "C:\\agentroam\\agent-tui.js" }));
|
|
62
62
|
expect(entry).toBe("C:\\agentroam\\agent-tui.js");
|
|
63
63
|
});
|
|
64
|
+
it("rejects legacy ABI manifests even when their release version matches", () => {
|
|
65
|
+
const root = mkdtempSync(resolve(tmpdir(), "agentroam-runtime-legacy-"));
|
|
66
|
+
const manifestPath = resolve(root, "manifest.json");
|
|
67
|
+
writeFileSync(manifestPath, JSON.stringify({
|
|
68
|
+
packageVersion: AGENTROAM_VERSION, target: "darwin-arm64",
|
|
69
|
+
nodeMajor: 22, nodeModuleAbi: 127, nativeFiles: {},
|
|
70
|
+
}));
|
|
71
|
+
expect(() => resolvePlatformRuntime("darwin-arm64", fakeRequire({
|
|
72
|
+
"/manifest.json": manifestPath, "/runtime": resolve(root, "runtime/package.json"),
|
|
73
|
+
}))).toThrow("invalid AgentRoam runtime manifest");
|
|
74
|
+
});
|
|
64
75
|
});
|
|
65
76
|
function fakeRequire(suffixes) {
|
|
66
77
|
return {
|
package/dist/platform.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertSupportedNodeVersion } from "../bin/runtime-policy.mjs";
|
|
1
2
|
export function detectPlatformTarget(platform = process.platform, arch = process.arch) {
|
|
2
3
|
if (platform === "darwin" && arch === "arm64")
|
|
3
4
|
return "darwin-arm64";
|
|
@@ -8,9 +9,6 @@ export function detectPlatformTarget(platform = process.platform, arch = process
|
|
|
8
9
|
throw Object.assign(new Error(`unsupported platform: ${platform}-${arch}`), { exitCode: 2 });
|
|
9
10
|
}
|
|
10
11
|
export function detectPlatform(platform = process.platform, arch = process.arch, nodeVersion = process.versions.node) {
|
|
11
|
-
|
|
12
|
-
if (major !== 22) {
|
|
13
|
-
throw Object.assign(new Error(`Node.js 22 required (current ${nodeVersion})`), { exitCode: 2 });
|
|
14
|
-
}
|
|
12
|
+
assertSupportedNodeVersion(nodeVersion);
|
|
15
13
|
return detectPlatformTarget(platform, arch);
|
|
16
14
|
}
|