agentroam 0.2.0-preview.15 → 0.2.0-preview.17

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.
@@ -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+\.\d+\.\d+)(?:\s|$)/)?.[1] ?? null;
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 !== OPENCODE_RUNTIME_VERSION) {
187
- throw new Error(`OpenCode ${version} at ${executable} is incompatible; AgentRoam requires ${OPENCODE_RUNTIME_VERSION}`);
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
- export const AGENTROAM_VERSION = "0.2.0-preview.15";
4
+ import { MINIMUM_NODE_VERSION, assertSupportedNodeVersion } from "../bin/runtime-policy.mjs";
5
+ export const AGENTROAM_VERSION = "0.2.0-preview.17";
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.nodeMajor !== 22
41
- || manifest.nodeModuleAbi !== 127
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
- nodeMajor: 22,
32
- nodeModuleAbi: 127,
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
- nodeMajor: 22,
52
- nodeModuleAbi: 127,
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
- const major = Number(nodeVersion.split(".")[0]);
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
  }
@@ -2,15 +2,15 @@ import { describe, expect, it } from "vitest";
2
2
  import { detectPlatform, detectPlatformTarget } from "./platform.js";
3
3
  describe("detectPlatform", () => {
4
4
  it("maps supported targets", () => {
5
- expect(detectPlatform("darwin", "arm64", "22.1.0")).toBe("darwin-arm64");
6
- expect(detectPlatform("win32", "x64", "22.1.0")).toBe("windows-amd64");
5
+ expect(detectPlatform("darwin", "arm64", "22.22.0")).toBe("darwin-arm64");
6
+ expect(detectPlatform("win32", "x64", "25.8.0")).toBe("windows-amd64");
7
7
  });
8
8
  it("detects a target before Node version validation", () => {
9
9
  expect(detectPlatformTarget("darwin", "arm64")).toBe("darwin-arm64");
10
10
  expect(detectPlatformTarget("win32", "x64")).toBe("windows-amd64");
11
11
  });
12
12
  it("rejects unsupported versions", () => {
13
- expect(() => detectPlatform("darwin", "arm64", "23.0.0")).toThrow("Node.js 22");
13
+ expect(() => detectPlatform("darwin", "arm64", "22.21.9")).toThrow("Node.js >=22.22.0");
14
14
  });
15
15
  it("rejects unsupported platforms", () => {
16
16
  expect(() => detectPlatformTarget("linux", "x64")).toThrow("unsupported platform");
@@ -1,5 +1,6 @@
1
1
  import { mkdir } from "node:fs/promises";
2
2
  import { delimiter, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { resolveCodexRuntime, } from "./codex-runtime-manager.js";
4
5
  import { repairNativeRuntimePermissions } from "./native-runtime.js";
5
6
  import { resolveOpenCodeRuntime, } from "./opencode-runtime-manager.js";
@@ -39,6 +40,9 @@ export class RuntimeManager {
39
40
  AGENT_TRUST_TUNNEL_PROXY: "1",
40
41
  AGENT_PAIRING_HASH: pairing?.hashHex || "",
41
42
  AGENT_PAIRING_EXPIRES_AT: pairing?.expiresAt || "",
43
+ AGENTROAM_VERSION: resolvePlatformRuntime(target).manifest.packageVersion,
44
+ AGENTROAM_DATA_DIR: dataDir,
45
+ AGENTROAM_CLI_PATH: fileURLToPath(new URL("../bin/agentroam.mjs", import.meta.url)),
42
46
  },
43
47
  });
44
48
  const log = (stream) => stream?.on("data", (chunk) => process.stderr.write(chunk));
@@ -4,6 +4,7 @@ import { detectPlatform } from "../platform.js";
4
4
  import { renderQr } from "../qr.js";
5
5
  import { MacLaunchAgent } from "./macos-launch-agent.js";
6
6
  import { WindowsTaskService } from "./windows-task-service.js";
7
+ import { assertSupportedNodeVersion } from "../../bin/runtime-policy.mjs";
7
8
  export async function runServiceCommand(options, context) {
8
9
  const platform = context.platform ?? process.platform;
9
10
  const log = context.log ?? console.log;
@@ -19,9 +20,7 @@ export async function runServiceCommand(options, context) {
19
20
  switch (options.serviceAction) {
20
21
  case "install": {
21
22
  const nodeVersion = context.nodeVersion ?? process.versions.node;
22
- if (Number(nodeVersion.split(".")[0]) !== 22) {
23
- throw cliError(`Node.js 22 is required to install the service (current ${nodeVersion})`);
24
- }
23
+ assertSupportedNodeVersion(nodeVersion);
25
24
  const now = context.now?.() ?? new Date();
26
25
  const environment = context.environment ?? process.env;
27
26
  const environmentPath = buildServiceEnvironmentPath(context.nodePath, environment.PATH ?? environment.Path, platform);
@@ -2,6 +2,26 @@ import { resolve } from "node:path";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
  import { buildServiceEnvironmentPath, runServiceCommand } from "./service-command.js";
4
4
  describe("runServiceCommand", () => {
5
+ it.each(["22.22.0", "24.0.0", "25.8.0", "26.0.0"])("keeps the selected Node %s when installing a service", async (nodeVersion) => {
6
+ const install = vi.fn(async () => ({ definition: "/test/service.plist" }));
7
+ const nodePath = `/runtimes/node-${nodeVersion}/bin/node`;
8
+ const codexResolver = vi.fn(async () => ({ executable: "/tools/codex", version: "test", source: "global" }));
9
+ await runServiceCommand(options("install"), {
10
+ platform: "darwin", arch: "arm64", nodeVersion, nodePath,
11
+ cliPath: "/agentroam.mjs", version: "test", environment: { PATH: "/usr/bin" },
12
+ codexResolver, controller: controller({ install }), log: vi.fn(),
13
+ });
14
+ expect(install).toHaveBeenCalledWith(expect.objectContaining({ nodePath }));
15
+ expect(codexResolver).toHaveBeenCalledWith(expect.objectContaining({ nodeExecutable: nodePath }));
16
+ });
17
+ it("rejects a below-minimum service runtime before installation", async () => {
18
+ const install = vi.fn();
19
+ await expect(runServiceCommand(options("install"), {
20
+ platform: "darwin", nodePath: "/node", nodeVersion: "22.21.9", cliPath: "/agentroam.mjs",
21
+ version: "test", controller: controller({ install }),
22
+ })).rejects.toThrow("Node.js >=22.22.0");
23
+ expect(install).not.toHaveBeenCalled();
24
+ });
5
25
  it("installs the service using absolute runtime context and parsed start options", async () => {
6
26
  const install = vi.fn(async (config) => ({
7
27
  paths: { plistPath: "/Users/test/Library/LaunchAgents/com.agentroam.service.plist" },
@@ -22,7 +22,7 @@ export async function waitForPublicReadiness(publicUrl, options = {}) {
22
22
  try {
23
23
  const response = await fetchImpl(endpoint, {
24
24
  cache: "no-store",
25
- headers: { "user-agent": "agentroam-readiness/0.2.0-preview.15" },
25
+ headers: { "user-agent": "agentroam-readiness/0.2.0-preview.17" },
26
26
  signal: options.signal,
27
27
  });
28
28
  if (response.ok) {
@@ -0,0 +1,38 @@
1
+ const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-preview\.(0|[1-9]\d*))?$/;
2
+ export function parseAgentRoamVersion(value) {
3
+ const match = VERSION.exec(value);
4
+ if (!match)
5
+ return null;
6
+ return {
7
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
8
+ preview: match[4] === undefined ? null : Number(match[4]),
9
+ };
10
+ }
11
+ // A prerelease installation follows the preview channel; a stable installation
12
+ // follows latest. Preview users are also offered a newer stable release so they
13
+ // can migrate to the stable track without reinstalling.
14
+ export function resolveUpdateChannel(currentVersion) {
15
+ const parsed = parseAgentRoamVersion(currentVersion);
16
+ if (!parsed)
17
+ throw new Error("invalid AgentRoam version");
18
+ return parsed.preview === null ? "latest" : "preview";
19
+ }
20
+ export function resolveReleaseChannel(version) {
21
+ const parsed = parseAgentRoamVersion(version);
22
+ if (!parsed)
23
+ throw new Error("invalid AgentRoam version");
24
+ return parsed.preview === null ? "latest" : "preview";
25
+ }
26
+ export function registryUrlsForChannel(channel, registryBase) {
27
+ return channel === "preview" ? [`${registryBase}/latest`, `${registryBase}/preview`] : [`${registryBase}/latest`];
28
+ }
29
+ export function parseChannelVersion(value, channel) {
30
+ if (typeof value !== "string")
31
+ return null;
32
+ const parsed = parseAgentRoamVersion(value);
33
+ if (!parsed)
34
+ return null;
35
+ if (parsed.preview === null)
36
+ return value;
37
+ return channel === "preview" ? value : null;
38
+ }
@@ -0,0 +1,102 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { fileURLToPath } from "node:url";
5
+ import { resolve } from "node:path";
6
+ import { updatePaths, writeUpdateState } from "./update-state.js";
7
+ import { parseChannelVersion, registryUrlsForChannel, resolveReleaseChannel, resolveUpdateChannel, } from "./update-channel.js";
8
+ const REGISTRY_BASE = "https://registry.npmjs.org/agentroam";
9
+ const RELEASE_BASE = "https://gitee.com/caoqu/team-agent/releases/download";
10
+ const SHA = /^[a-f0-9]{64}$/;
11
+ export async function resolveUpdate(options, fetchImpl = fetch) {
12
+ const channel = resolveUpdateChannel(options.currentVersion);
13
+ const target = await readRegistryCandidate(fetchImpl, channel);
14
+ if (!target)
15
+ throw new Error("unable to check the npm release channel");
16
+ if (options.requestedVersion && options.requestedVersion !== target)
17
+ throw new Error("requested version is not the current npm candidate");
18
+ if (compare(target, options.currentVersion) <= 0)
19
+ throw new Error("AgentRoam is already up to date");
20
+ const response = await fetchWithTimeout(fetchImpl, `${RELEASE_BASE}/v${target}/release-manifest.json`);
21
+ if (!response.ok)
22
+ throw new Error("release manifest is unavailable");
23
+ const manifest = await response.json();
24
+ const asset = manifest?.installers?.cli?.[options.target];
25
+ const fileName = options.target === "darwin-arm64" ? "install-agentroam.sh" : "install-agentroam.ps1";
26
+ if (manifest?.schemaVersion !== 2 || manifest.version !== target || manifest.channel !== resolveReleaseChannel(target) || asset?.fileName !== fileName || !SHA.test(asset?.sha256 ?? "")) {
27
+ throw new Error("release manifest is invalid for this platform");
28
+ }
29
+ return { targetVersion: target, fileName, sha256: asset.sha256 };
30
+ }
31
+ // Preview installations follow the higher of the preview and latest npm
32
+ // dist-tags so a newer stable release can move them onto the stable track.
33
+ async function readRegistryCandidate(fetchImpl, channel) {
34
+ let best = null;
35
+ for (const url of registryUrlsForChannel(channel, REGISTRY_BASE)) {
36
+ const response = await fetchWithTimeout(fetchImpl, url).catch(() => null);
37
+ if (!response?.ok)
38
+ continue;
39
+ const candidate = parseChannelVersion((await response.json()).version, channel);
40
+ if (!candidate)
41
+ continue;
42
+ if (!best || compare(candidate, best) > 0)
43
+ best = candidate;
44
+ }
45
+ return best;
46
+ }
47
+ export async function startUpdate(options) {
48
+ const release = await resolveUpdate(options);
49
+ const paths = updatePaths(options.dataDir);
50
+ const service = await readServiceConfig();
51
+ const state = {
52
+ schemaVersion: 1,
53
+ phase: "downloading",
54
+ currentVersion: options.currentVersion,
55
+ ...release,
56
+ dataDir: resolve(options.dataDir),
57
+ roots: service?.roots?.length ? service.roots : [process.cwd()],
58
+ port: service?.port ?? null,
59
+ relay: service?.relay ?? "auto",
60
+ tunnelCommand: service?.tunnelCommand ?? null,
61
+ localOnly: service?.localOnly ?? false,
62
+ cliPath: resolve(options.cliPath),
63
+ updatedAt: Date.now(),
64
+ };
65
+ await writeUpdateState(paths.stateFile, state);
66
+ const child = spawn(process.execPath, [options.cliPath, "update-worker", paths.stateFile], { detached: true, stdio: "ignore", windowsHide: true });
67
+ child.unref();
68
+ return state;
69
+ }
70
+ async function readServiceConfig() {
71
+ try {
72
+ return JSON.parse(await readFile(resolve(homedir(), ".agentroam", "service", "config.json"), "utf8"));
73
+ }
74
+ catch { }
75
+ return null;
76
+ }
77
+ async function fetchWithTimeout(fetchImpl, url) {
78
+ const controller = new AbortController();
79
+ const timer = setTimeout(() => controller.abort(), 4_000);
80
+ timer.unref?.();
81
+ try {
82
+ return await fetchImpl(url, { headers: { accept: "application/json" }, signal: controller.signal });
83
+ }
84
+ finally {
85
+ clearTimeout(timer);
86
+ }
87
+ }
88
+ function compare(left, right) {
89
+ const parse = (value) => {
90
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-preview\.(\d+))?$/.exec(value);
91
+ if (!match)
92
+ throw new Error("invalid AgentRoam version");
93
+ return [...match.slice(1, 4).map(Number), match[4] === undefined ? Number.MAX_SAFE_INTEGER : Number(match[4])];
94
+ };
95
+ const a = parse(left);
96
+ const b = parse(right);
97
+ for (let i = 0; i < a.length; i += 1)
98
+ if (a[i] !== b[i])
99
+ return a[i] > b[i] ? 1 : -1;
100
+ return 0;
101
+ }
102
+ export const currentCliPath = () => fileURLToPath(new URL("../../bin/agentroam.mjs", import.meta.url));
@@ -0,0 +1,70 @@
1
+ import { mkdtemp } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { resolve } from "node:path";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { resolveUpdate } from "./update-command.js";
6
+ import { acquireUpdateLock, readUpdateState, updatePaths, writeUpdateState } from "./update-state.js";
7
+ const sha256 = "c".repeat(64);
8
+ const response = (body) => new Response(JSON.stringify(body), { status: 200 });
9
+ describe("resolveUpdate", () => {
10
+ it("accepts only npm latest with an audited Gitee installer", async () => {
11
+ const fetcher = vi.fn()
12
+ .mockResolvedValueOnce(response({ version: "1.2.3" }))
13
+ .mockResolvedValueOnce(response({ schemaVersion: 2, version: "1.2.3", channel: "latest", installers: { cli: { "windows-amd64": { fileName: "install-agentroam.ps1", sha256 } } } }));
14
+ await expect(resolveUpdate({ currentVersion: "1.2.2", requestedVersion: null, dataDir: "data", target: "windows-amd64", cliPath: "cli" }, fetcher))
15
+ .resolves.toEqual({ targetVersion: "1.2.3", fileName: "install-agentroam.ps1", sha256 });
16
+ });
17
+ it("rejects a requested version that is not npm latest", async () => {
18
+ const fetcher = vi.fn().mockResolvedValue(response({ version: "1.2.3" }));
19
+ await expect(resolveUpdate({ currentVersion: "1.0.0", requestedVersion: "1.2.2", dataDir: "data", target: "darwin-arm64", cliPath: "cli" }, fetcher)).rejects.toThrow("not the current npm candidate");
20
+ });
21
+ it("follows the preview channel and offers a higher preview", async () => {
22
+ const fetcher = vi.fn((url) => {
23
+ const u = String(url);
24
+ if (u.endsWith("/preview"))
25
+ return Promise.resolve(response({ version: "1.2.3-preview.4" }));
26
+ if (u.endsWith("/latest"))
27
+ return Promise.resolve(response({ version: "1.2.2" }));
28
+ return Promise.resolve(response({ schemaVersion: 2, version: "1.2.3-preview.4", channel: "preview", installers: { cli: { "darwin-arm64": { fileName: "install-agentroam.sh", sha256 } } } }));
29
+ });
30
+ await expect(resolveUpdate({ currentVersion: "1.2.3-preview.3", requestedVersion: null, dataDir: "data", target: "darwin-arm64", cliPath: "cli" }, fetcher))
31
+ .resolves.toEqual({ targetVersion: "1.2.3-preview.4", fileName: "install-agentroam.sh", sha256 });
32
+ });
33
+ it("offers a newer stable release to preview users", async () => {
34
+ const fetcher = vi.fn((url) => {
35
+ const u = String(url);
36
+ if (u.endsWith("/preview"))
37
+ return Promise.resolve(response({ version: "1.2.3-preview.4" }));
38
+ if (u.endsWith("/latest"))
39
+ return Promise.resolve(response({ version: "1.2.4" }));
40
+ return Promise.resolve(response({ schemaVersion: 2, version: "1.2.4", channel: "latest", installers: { cli: { "darwin-arm64": { fileName: "install-agentroam.sh", sha256 } } } }));
41
+ });
42
+ await expect(resolveUpdate({ currentVersion: "1.2.3-preview.4", requestedVersion: null, dataDir: "data", target: "darwin-arm64", cliPath: "cli" }, fetcher))
43
+ .resolves.toEqual({ targetVersion: "1.2.4", fileName: "install-agentroam.sh", sha256 });
44
+ });
45
+ it("stable channel never queries the preview tag", async () => {
46
+ const fetcher = vi.fn((url) => {
47
+ const u = String(url);
48
+ expect(u).not.toContain("/preview");
49
+ if (u.endsWith("/latest"))
50
+ return Promise.resolve(response({ version: "1.2.4" }));
51
+ return Promise.resolve(response({ schemaVersion: 2, version: "1.2.4", channel: "latest", installers: { cli: { "darwin-arm64": { fileName: "install-agentroam.sh", sha256 } } } }));
52
+ });
53
+ await resolveUpdate({ currentVersion: "1.2.3", requestedVersion: null, dataDir: "data", target: "darwin-arm64", cliPath: "cli" }, fetcher);
54
+ expect(fetcher.mock.calls.map((call) => String(call[0])).some((url) => url.includes("/preview"))).toBe(false);
55
+ });
56
+ it("writes durable state atomically and excludes concurrent workers", async () => {
57
+ const dataDir = await mkdtemp(resolve(tmpdir(), "agentroam-update-state-"));
58
+ const paths = updatePaths(dataDir);
59
+ const state = {
60
+ schemaVersion: 1, phase: "downloading", currentVersion: "1.0.0", targetVersion: "1.1.0",
61
+ dataDir, roots: ["D:\\project"], port: 3000, relay: "auto", tunnelCommand: null,
62
+ localOnly: false, fileName: "install-agentroam.ps1", sha256, cliPath: "C:\\agentroam.mjs", updatedAt: 1,
63
+ };
64
+ await writeUpdateState(paths.stateFile, state);
65
+ await expect(readUpdateState(paths.stateFile)).resolves.toEqual(state);
66
+ const release = await acquireUpdateLock(paths.lockFile);
67
+ await expect(acquireUpdateLock(paths.lockFile)).rejects.toThrow("already running");
68
+ await release();
69
+ });
70
+ });
@@ -0,0 +1,43 @@
1
+ import { mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ export function updatePaths(dataDir) {
4
+ const directory = resolve(dataDir, "updates");
5
+ return { directory, stateFile: resolve(directory, "state.json"), lockFile: resolve(directory, "update.lock") };
6
+ }
7
+ export async function readUpdateState(path) {
8
+ try {
9
+ const value = JSON.parse(await readFile(path, "utf8"));
10
+ return value?.schemaVersion === 1 ? value : null;
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ export async function writeUpdateState(path, state) {
17
+ await mkdir(dirname(path), { recursive: true });
18
+ const temp = `${path}.${process.pid}.tmp`;
19
+ await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
20
+ await rename(temp, path);
21
+ }
22
+ export async function acquireUpdateLock(path, now = Date.now()) {
23
+ await mkdir(dirname(path), { recursive: true });
24
+ try {
25
+ const handle = await open(path, "wx", 0o600);
26
+ await handle.writeFile(`${process.pid}\n${now}\n`);
27
+ await handle.close();
28
+ }
29
+ catch (error) {
30
+ if (error.code !== "EEXIST")
31
+ throw error;
32
+ const age = now - (await stat(path)).mtimeMs;
33
+ if (age <= 30 * 60 * 1_000)
34
+ throw new Error("an AgentRoam update is already running");
35
+ await unlink(path);
36
+ return acquireUpdateLock(path, now);
37
+ }
38
+ return async () => { await unlink(path).catch(() => undefined); };
39
+ }
40
+ export function sanitizeUpdateError(error) {
41
+ const value = error instanceof Error ? error.message : String(error);
42
+ return value.replace(/[A-Za-z]:\\[^\s]+|\/(?:Users|home|var|tmp)\/[^\s]+/g, "<path>").slice(0, 300);
43
+ }
@@ -0,0 +1,71 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createWriteStream } from "node:fs";
3
+ import { chmod, readFile, unlink } from "node:fs/promises";
4
+ import { basename, resolve } from "node:path";
5
+ import { pipeline } from "node:stream/promises";
6
+ import { Readable } from "node:stream";
7
+ import { spawn } from "node:child_process";
8
+ import { acquireUpdateLock, readUpdateState, sanitizeUpdateError, updatePaths, writeUpdateState } from "./update-state.js";
9
+ const RELEASE_BASE = "https://gitee.com/caoqu/team-agent/releases/download";
10
+ export async function runUpdateWorker(stateFile, fetchImpl = fetch) {
11
+ const initial = await readUpdateState(stateFile);
12
+ if (!initial)
13
+ throw new Error("update state is missing");
14
+ const paths = updatePaths(initial.dataDir);
15
+ if (resolve(stateFile) !== paths.stateFile)
16
+ throw new Error("update state is outside the data directory");
17
+ const releaseLock = await acquireUpdateLock(paths.lockFile);
18
+ const partial = resolve(paths.directory, `${basename(initial.fileName)}.partial`);
19
+ let state = initial;
20
+ const update = async (next) => {
21
+ state = { ...state, ...next, updatedAt: Date.now() };
22
+ await writeUpdateState(stateFile, state);
23
+ };
24
+ try {
25
+ const response = await fetchImpl(`${RELEASE_BASE}/v${state.targetVersion}/${encodeURIComponent(state.fileName)}`);
26
+ if (!response.ok || !response.body)
27
+ throw new Error("installer download failed");
28
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(partial, { mode: 0o700 }));
29
+ const actual = createHash("sha256").update(await readFile(partial)).digest("hex");
30
+ if (actual !== state.sha256)
31
+ throw new Error("installer checksum mismatch");
32
+ await chmod(partial, 0o700).catch(() => undefined);
33
+ await update({ phase: "installing" });
34
+ const command = process.platform === "win32" ? "powershell.exe" : "/bin/sh";
35
+ const args = process.platform === "win32" ? ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", partial] : [partial];
36
+ const code = await run(command, args, { ...process.env, AGENTROAM_DATA_DIR: state.dataDir, AGENTROAM_ROOT: state.roots[0], AGENTROAM_INSTALL_SKIP_SERVICE: "1" });
37
+ if (code !== 0)
38
+ throw new Error(`installer exited with code ${code}`);
39
+ const nextEntry = resolve(state.dataDir, "launcher", state.targetVersion, "node_modules", "agentroam", "bin", "agentroam.mjs");
40
+ const serviceArgs = [nextEntry, "service", "install"];
41
+ for (const root of state.roots)
42
+ serviceArgs.push("--root", root);
43
+ serviceArgs.push("--data-dir", state.dataDir, "--relay", state.relay, "--no-qr");
44
+ if (state.port)
45
+ serviceArgs.push("--port", String(state.port));
46
+ if (state.tunnelCommand)
47
+ serviceArgs.push("--tunnel-command", state.tunnelCommand);
48
+ if (state.localOnly)
49
+ serviceArgs.push("--local-only");
50
+ await update({ phase: "reconnecting" });
51
+ const serviceCode = await run(process.execPath, serviceArgs, process.env);
52
+ if (serviceCode !== 0)
53
+ throw new Error(`service activation exited with code ${serviceCode}`);
54
+ await update({ phase: "complete", message: `AgentRoam ${state.targetVersion} installed` });
55
+ }
56
+ catch (error) {
57
+ await update({ phase: "failed", message: sanitizeUpdateError(error) });
58
+ throw error;
59
+ }
60
+ finally {
61
+ await unlink(partial).catch(() => undefined);
62
+ await releaseLock();
63
+ }
64
+ }
65
+ function run(command, args, env) {
66
+ return new Promise((resolveRun, reject) => {
67
+ const child = spawn(command, args, { env, stdio: "ignore", windowsHide: true });
68
+ child.once("error", reject);
69
+ child.once("exit", resolveRun);
70
+ });
71
+ }
package/install/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # AgentRoam bootstrap installers
2
2
 
3
- These versioned installers support macOS Apple Silicon and Windows x64. They reuse a visible Node.js 22 installation when available. Otherwise they install the verified private Node.js `22.22.0` runtime under the AgentRoam data directory. They then register and start the current-user AgentRoam background service. They never modify, replace, or uninstall system Node.js or global power settings.
3
+ These versioned installers support macOS Apple Silicon and Windows x64. They reuse the active Node.js installation when it meets `>=22.22.0`, with no upper version bound. Otherwise they search NVM for the highest compatible version, then fall back to the verified private Node.js `22.22.0` runtime under the AgentRoam data directory. They then register and start the current-user AgentRoam background service. They never modify, replace, or uninstall system Node.js or global power settings.
4
4
 
5
5
  ## macOS Apple Silicon
6
6
 
7
7
  ```sh
8
- curl -fsSL https://gitee.com/caoqu/team-agent/releases/download/v0.2.0-preview.15/install-agentroam.sh | sh
8
+ curl -fsSL https://gitee.com/caoqu/team-agent/releases/download/v0.2.0-preview.17/install-agentroam.sh | sh
9
9
  ```
10
10
 
11
11
  The command installs the wrapper at `~/.local/bin/agentroam`. If that directory is not already in `PATH`, the installer prints the required shell setting without editing shell profiles.
@@ -13,7 +13,7 @@ The command installs the wrapper at `~/.local/bin/agentroam`. If that directory
13
13
  ## Windows x64
14
14
 
15
15
  ```powershell
16
- irm https://gitee.com/caoqu/team-agent/releases/download/v0.2.0-preview.15/install-agentroam.ps1 | iex
16
+ irm https://gitee.com/caoqu/team-agent/releases/download/v0.2.0-preview.17/install-agentroam.ps1 | iex
17
17
  ```
18
18
 
19
19
  The command installs `%USERPROFILE%\.agentroam\bin\agentroam.cmd` and adds only that user directory to the user-level `PATH`. It does not require administrator access or change the machine-level `PATH`.
@@ -40,7 +40,7 @@ agentroam service uninstall
40
40
 
41
41
  ## Data and updates
42
42
 
43
- Set `AGENTROAM_DATA_DIR` before running either installer to override the default `~/.agentroam` data directory. Node is stored at `<data-dir>/runtimes/node/22.22.0`, and the launcher is stored at `<data-dir>/launcher/0.2.0-preview.15`.
43
+ Set `AGENTROAM_DATA_DIR` before running either installer to override the default `~/.agentroam` data directory. Node is stored at `<data-dir>/runtimes/node/22.22.0`, and the launcher is stored at `<data-dir>/launcher/0.2.0-preview.17`.
44
44
 
45
45
  Each AgentRoam release pins its own Node patch and installer assets. Updates happen only by running a newer versioned installer or installing a newer AgentRoam release; startup never follows a moving Node.js release channel.
46
46