agentroam 0.2.0-preview.19 → 0.2.0-preview.20
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 +8 -8
- package/bin/desktop-download.mjs +3 -3
- package/dist/cli.js +28 -7
- package/dist/install-script-contract.test.js +34 -1
- package/dist/instance-takeover.js +146 -0
- package/dist/instance-takeover.test.js +121 -0
- package/dist/platform-packages.js +2 -3
- package/dist/service/launcher-cleanup.js +61 -0
- package/dist/service/launcher-cleanup.test.js +81 -0
- package/dist/service/service-command.js +8 -2
- package/dist/service/service-command.test.js +3 -1
- package/install/README.md +1 -1
- package/install/install-agentroam.ps1 +1 -1
- package/install/install-agentroam.sh +4 -2
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -20,20 +20,20 @@ npx agentroam@preview
|
|
|
20
20
|
Or install from tarball:
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
|
-
npm install ./agentroam-cloudflared-darwin-arm64-0.2.0-preview.
|
|
24
|
-
./agentroam-runtime-darwin-arm64-0.2.0-preview.
|
|
25
|
-
./agentroam-tui-darwin-arm64-0.2.0-preview.
|
|
26
|
-
./agentroam-0.2.0-preview.
|
|
23
|
+
npm install ./agentroam-cloudflared-darwin-arm64-0.2.0-preview.20.tgz \
|
|
24
|
+
./agentroam-runtime-darwin-arm64-0.2.0-preview.20.tgz \
|
|
25
|
+
./agentroam-tui-darwin-arm64-0.2.0-preview.20.tgz \
|
|
26
|
+
./agentroam-0.2.0-preview.20.tgz
|
|
27
27
|
npx agentroam
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
Windows PowerShell:
|
|
31
31
|
|
|
32
32
|
```powershell
|
|
33
|
-
npm install ./agentroam-cloudflared-win32-x64-0.2.0-preview.
|
|
34
|
-
./agentroam-runtime-win32-x64-0.2.0-preview.
|
|
35
|
-
./caoqu-agentroam-tui-win32-x64-0.2.0-preview.
|
|
36
|
-
./agentroam-0.2.0-preview.
|
|
33
|
+
npm install ./agentroam-cloudflared-win32-x64-0.2.0-preview.20.tgz `
|
|
34
|
+
./agentroam-runtime-win32-x64-0.2.0-preview.20.tgz `
|
|
35
|
+
./caoqu-agentroam-tui-win32-x64-0.2.0-preview.20.tgz `
|
|
36
|
+
./agentroam-0.2.0-preview.20.tgz
|
|
37
37
|
npx agentroam
|
|
38
38
|
```
|
|
39
39
|
|
package/bin/desktop-download.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
1
|
import { createHash } from 'node:crypto';
|
|
3
2
|
import { createWriteStream } from 'node:fs';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { mkdir, mkdtemp, rename, rm } from 'node:fs/promises';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { join, resolve } from 'node:path';
|
|
@@ -24,8 +24,8 @@ export function desktopAsset(manifest, version, platform, arch) {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export async function downloadDesktop(version, { platform = process.platform, arch = process.arch, directory = join(homedir(), 'Downloads'), fetchImpl = fetch } = {}) {
|
|
27
|
-
const launcher = JSON.parse(readFileSync(new URL(
|
|
28
|
-
if (version === launcher.version) version = launcher.optionalDependencies[
|
|
27
|
+
const launcher = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
28
|
+
if (version === launcher.version) version = launcher.optionalDependencies['agentroam-runtime-darwin-arm64'];
|
|
29
29
|
// Validate coordinates before making any request.
|
|
30
30
|
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-preview\.(0|[1-9]\d*))?$/.test(version)) throw new Error('Invalid AgentRoam version');
|
|
31
31
|
const response = await fetchImpl(`${RELEASE_BASE}/v${version}/release-manifest.json`, { signal: AbortSignal.timeout(30_000) });
|
package/dist/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ import { resolveServicePaths } from "./service/service-files.js";
|
|
|
16
16
|
import { selectRelay } from "./tunnel/relay-orchestrator.js";
|
|
17
17
|
import { currentCliPath, startUpdate } from "./update/update-command.js";
|
|
18
18
|
import { runUpdateWorker } from "./update/update-worker.js";
|
|
19
|
+
import { lockInstanceStartup, stopPreviousInstances } from "./instance-takeover.js";
|
|
19
20
|
const VERSION = AGENTROAM_VERSION;
|
|
20
21
|
export async function main(argv) {
|
|
21
22
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
@@ -102,23 +103,38 @@ export async function main(argv) {
|
|
|
102
103
|
let runtime = null;
|
|
103
104
|
let relay = null;
|
|
104
105
|
let closing = false;
|
|
106
|
+
let releaseStartup;
|
|
107
|
+
let closePromise;
|
|
108
|
+
let reportedStarting = false;
|
|
105
109
|
const serviceReporter = process.env.AGENTROAM_SERVICE === "1"
|
|
106
110
|
? new ServiceRuntimeReporter(resolveServicePaths(undefined, options.dataDir), VERSION)
|
|
107
111
|
: null;
|
|
108
|
-
const close =
|
|
109
|
-
if (
|
|
110
|
-
return;
|
|
112
|
+
const close = () => {
|
|
113
|
+
if (closePromise)
|
|
114
|
+
return closePromise;
|
|
111
115
|
closing = true;
|
|
112
116
|
controller.abort();
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
closePromise = (async () => {
|
|
118
|
+
await relay?.tunnel?.close().catch(() => { });
|
|
119
|
+
await runtime?.close().catch(() => { });
|
|
120
|
+
})();
|
|
121
|
+
return closePromise;
|
|
115
122
|
};
|
|
116
123
|
const requestClose = () => void close();
|
|
117
124
|
process.once("SIGINT", requestClose);
|
|
118
125
|
process.once("SIGTERM", requestClose);
|
|
119
126
|
try {
|
|
127
|
+
releaseStartup = await lockInstanceStartup(options.dataDir);
|
|
128
|
+
await stopPreviousInstances(options.dataDir, console.log);
|
|
129
|
+
if (controller.signal.aborted)
|
|
130
|
+
return;
|
|
120
131
|
await serviceReporter?.starting();
|
|
132
|
+
reportedStarting = Boolean(serviceReporter);
|
|
121
133
|
runtime = await new RuntimeManager().start(options, target);
|
|
134
|
+
if (controller.signal.aborted) {
|
|
135
|
+
await runtime.close();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
122
138
|
void runtime.exited.then(() => controller.abort());
|
|
123
139
|
console.log(`✓ Local server: ${runtime.localUrl}/web`);
|
|
124
140
|
const lanUrl = options.localOnly ? findLanUrl(runtime.port) ?? runtime.localUrl : runtime.localUrl;
|
|
@@ -134,8 +150,10 @@ export async function main(argv) {
|
|
|
134
150
|
onFailure: (provider, message) => console.error(`⚠ ${providerDisplayName(provider)} unavailable: ${message}`),
|
|
135
151
|
allowLanFallback: options.localOnly,
|
|
136
152
|
});
|
|
137
|
-
if (controller.signal.aborted)
|
|
153
|
+
if (controller.signal.aborted) {
|
|
154
|
+
await relay.tunnel?.close();
|
|
138
155
|
return;
|
|
156
|
+
}
|
|
139
157
|
if (relay.provider === "lan") {
|
|
140
158
|
if (relay.failures.length > 0)
|
|
141
159
|
console.error(` Continuing with local network access: ${relay.publicUrl}/web`);
|
|
@@ -156,6 +174,7 @@ export async function main(argv) {
|
|
|
156
174
|
accessUrl,
|
|
157
175
|
provider: relay.provider,
|
|
158
176
|
});
|
|
177
|
+
await releaseStartup();
|
|
159
178
|
if (serviceReporter) {
|
|
160
179
|
console.log("✓ Background service URL written to the private state file.");
|
|
161
180
|
}
|
|
@@ -179,7 +198,9 @@ export async function main(argv) {
|
|
|
179
198
|
process.removeListener("SIGINT", requestClose);
|
|
180
199
|
process.removeListener("SIGTERM", requestClose);
|
|
181
200
|
await close();
|
|
182
|
-
await
|
|
201
|
+
await releaseStartup?.();
|
|
202
|
+
if (reportedStarting)
|
|
203
|
+
await serviceReporter?.stopped().catch(() => { });
|
|
183
204
|
await sleepInhibitor.release();
|
|
184
205
|
}
|
|
185
206
|
}
|
|
@@ -1,11 +1,44 @@
|
|
|
1
1
|
import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
-
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { execFileSync, spawnSync } 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
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 === "win32")("continues after failed diagnostics but stops when service registration fails", async () => {
|
|
10
|
+
const root = await mkdtemp(resolve(process.env.TMPDIR || "/tmp", "agentroam-install-diagnostics-"));
|
|
11
|
+
try {
|
|
12
|
+
const script = await readFile(resolve(installRoot, "install-agentroam.sh"), "utf8");
|
|
13
|
+
// Execute the actual post-install flow with a fake CLI, without touching launchd.
|
|
14
|
+
const start = script.indexOf('if ! "$NODE_BIN" "$entry" doctor');
|
|
15
|
+
expect(start).toBeGreaterThan(0);
|
|
16
|
+
const node = resolve(root, "node");
|
|
17
|
+
const calls = resolve(root, "calls");
|
|
18
|
+
await writeFile(node, `#!/bin/sh\nprintf '%s\\n' "$2" >> "$TEST_CALLS"\ncase "$2" in\n doctor) exit 1 ;;\n service) exit "$TEST_SERVICE_EXIT" ;;\nesac\n`);
|
|
19
|
+
await chmod(node, 0o755);
|
|
20
|
+
for (const serviceExit of [0, 1]) {
|
|
21
|
+
await writeFile(calls, "");
|
|
22
|
+
const result = spawnSync("/bin/sh", ["-c", `set -eu\n${script.slice(start)}`], {
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
env: { ...process.env, NODE_BIN: node, entry: "fake-cli", DATA_DIR: root, SERVICE_ROOT: root,
|
|
25
|
+
WRAPPER_PATH: "agentroam", WRAPPER_DIR: root, AGENTROAM_VERSION: "test",
|
|
26
|
+
AGENTROAM_INSTALL_SKIP_SERVICE: "0", AGENTROAM_INSTALL_DESKTOP: "no",
|
|
27
|
+
TEST_CALLS: calls, TEST_SERVICE_EXIT: String(serviceExit) },
|
|
28
|
+
});
|
|
29
|
+
expect(await readFile(calls, "utf8")).toBe("doctor\nservice\n");
|
|
30
|
+
expect(result.stderr).toContain("some component checks failed");
|
|
31
|
+
expect(result.status).toBe(serviceExit);
|
|
32
|
+
if (serviceExit === 0)
|
|
33
|
+
expect(result.stdout).toContain("installed:");
|
|
34
|
+
else
|
|
35
|
+
expect(result.stdout).not.toContain("installed:");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
await rm(root, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
9
42
|
it.skipIf(process.platform !== "darwin" || process.arch !== "arm64")("allows installation from home and keeps a compatible active Node", async () => {
|
|
10
43
|
const root = await mkdtemp(resolve(process.env.TMPDIR || "/tmp", "agentroam-active-node-"));
|
|
11
44
|
try {
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { readdir, readFile, mkdir, writeFile, rm, realpath } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import { runCommand } from "./service/service-controller.js";
|
|
6
|
+
import { MacLaunchAgent } from "./service/macos-launch-agent.js";
|
|
7
|
+
import { WindowsTaskService } from "./service/windows-task-service.js";
|
|
8
|
+
function alive(pid) {
|
|
9
|
+
try {
|
|
10
|
+
process.kill(pid, 0);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
return error.code !== "ESRCH";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function processList() {
|
|
18
|
+
if (process.platform === "win32") {
|
|
19
|
+
const result = await runCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress"]);
|
|
20
|
+
if (result.code !== 0)
|
|
21
|
+
throw new Error("无法核验旧 CLI 进程");
|
|
22
|
+
return JSON.parse(result.stdout).map((p) => ({ pid: p.ProcessId, ppid: p.ParentProcessId, command: p.CommandLine ?? "" }));
|
|
23
|
+
}
|
|
24
|
+
const result = await runCommand("ps", ["-axo", "pid=,ppid=,command="]);
|
|
25
|
+
if (result.code !== 0)
|
|
26
|
+
throw new Error("无法核验旧 CLI 进程");
|
|
27
|
+
return result.stdout.split("\n").flatMap(line => {
|
|
28
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line);
|
|
29
|
+
return match ? [{ pid: Number(match[1]), ppid: Number(match[2]), command: match[3] }] : [];
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async function stopCli(pid) {
|
|
33
|
+
const service = process.platform === "win32" ? new WindowsTaskService() : new MacLaunchAgent();
|
|
34
|
+
const status = await service.status();
|
|
35
|
+
if (status.running && status.state?.pid === pid) {
|
|
36
|
+
await service.stop();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (process.platform === "win32") {
|
|
40
|
+
const result = await runCommand("taskkill", ["/PID", String(pid), "/T", "/F"]);
|
|
41
|
+
if (result.code !== 0)
|
|
42
|
+
throw new Error("旧 CLI 进程树停止失败");
|
|
43
|
+
}
|
|
44
|
+
else
|
|
45
|
+
process.kill(pid, "SIGTERM");
|
|
46
|
+
}
|
|
47
|
+
async function isPackagedRuntime(command) {
|
|
48
|
+
const match = /(?:^|\s)(?:"([^"]+[\\/]ws-server\.mjs)"|(\S+[\\/]ws-server\.mjs))(?=\s|$)/.exec(command);
|
|
49
|
+
const gateway = match?.[1] ?? match?.[2];
|
|
50
|
+
if (!gateway)
|
|
51
|
+
return false;
|
|
52
|
+
try {
|
|
53
|
+
const path = await realpath(gateway);
|
|
54
|
+
if (!/[\\/]node_modules[\\/]agentroam-runtime-(?:darwin-arm64|win32-x64)[\\/]runtime[\\/]ws-server\.mjs$/.test(path))
|
|
55
|
+
return false;
|
|
56
|
+
const manifest = JSON.parse(await readFile(join(dirname(dirname(path)), "package.json"), "utf8"));
|
|
57
|
+
return ["agentroam-runtime-darwin-arm64", "agentroam-runtime-win32-x64"].includes(manifest.name);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Authenticate the local server, then verify its CLI parent before sending any signal. */
|
|
64
|
+
export async function stopPreviousInstances(dataDir, log, dependencies = {}) {
|
|
65
|
+
const canonical = await realpath(dataDir);
|
|
66
|
+
const registry = dependencies.registry ?? process.env.AGENTROAM_DISCOVERY_DIR ?? join(homedir(), ".agentroam/services");
|
|
67
|
+
const names = await readdir(registry).catch((error) => { if (error.code === "ENOENT")
|
|
68
|
+
return []; throw error; });
|
|
69
|
+
for (const name of names.filter(name => name.endsWith(".json"))) {
|
|
70
|
+
let d;
|
|
71
|
+
try {
|
|
72
|
+
d = JSON.parse(await readFile(join(registry, name), "utf8"));
|
|
73
|
+
if (d.protocol !== 1 || !Number.isSafeInteger(d.pid) || d.pid <= 1 || typeof d.instanceId !== "string" || !/^[a-f0-9]{64}$/.test(d.token))
|
|
74
|
+
continue;
|
|
75
|
+
const actual = await realpath(d.dataDir);
|
|
76
|
+
if (actual !== canonical && actual !== join(canonical, "data"))
|
|
77
|
+
continue;
|
|
78
|
+
const url = new URL(d.url);
|
|
79
|
+
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/" || url.username || url.password || url.search || url.hash)
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
let identity;
|
|
86
|
+
try {
|
|
87
|
+
const response = await (dependencies.request ?? fetch)(new URL("/api/desktop/identity", d.url), { headers: { "x-agentroam-desktop-token": d.token }, redirect: "error", signal: AbortSignal.timeout(1500) });
|
|
88
|
+
if (!response.ok)
|
|
89
|
+
throw new Error("实例认证失败");
|
|
90
|
+
identity = await response.json();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
if ((dependencies.alive ?? alive)(d.pid))
|
|
94
|
+
throw new Error("同一数据目录的旧实例无法核验,请先停止旧实例再重试");
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (identity.protocol !== 1 || identity.instanceId !== d.instanceId || identity.dataDir !== d.dataDir)
|
|
98
|
+
throw new Error("旧实例身份不匹配,已取消启动");
|
|
99
|
+
const processes = await (dependencies.processes ?? processList)();
|
|
100
|
+
const server = processes.find(p => p.pid === d.pid);
|
|
101
|
+
const parent = processes.find(p => p.pid === server?.ppid);
|
|
102
|
+
const orphan = server && (server.ppid === 1 || !parent) && await isPackagedRuntime(server.command);
|
|
103
|
+
if (!server || server.pid === process.pid || (!orphan && (!parent || parent.pid === process.pid || !/[\\/]ws-server\.mjs(?:["\s]|$)/.test(server.command) || !/[\\/]agentroam[\\/]bin[\\/]agentroam\.mjs(?:["\s]|$)/.test(parent.command)))) {
|
|
104
|
+
throw new Error("同一数据目录已有服务,但无法确认它是可接管的 CLI 实例,请先停止该服务");
|
|
105
|
+
}
|
|
106
|
+
// Re-read the process relationship immediately before stopping, to reject stale PID records.
|
|
107
|
+
const current = await (dependencies.processes ?? processList)();
|
|
108
|
+
if (!current.some(p => p.pid === server.pid && p.ppid === server.ppid && p.command === server.command) || (!orphan && !current.some(p => p.pid === parent.pid && p.command === parent.command)))
|
|
109
|
+
throw new Error("旧实例身份已变化,请重试");
|
|
110
|
+
const targetPid = orphan ? server.pid : parent.pid;
|
|
111
|
+
log(orphan ? "检测到旧 CLI 遗留的服务进程,正在停止…" : "检测到同一数据目录的旧 CLI,正在停止…");
|
|
112
|
+
await (dependencies.stop ?? stopCli)(targetPid);
|
|
113
|
+
const deadline = Date.now() + (dependencies.timeoutMs ?? 15_000);
|
|
114
|
+
while ((dependencies.alive ?? alive)(targetPid) || (dependencies.alive ?? alive)(server.pid)) {
|
|
115
|
+
if (Date.now() >= deadline)
|
|
116
|
+
throw new Error("旧 CLI 尚未退出,已取消新实例启动,请稍后重试");
|
|
117
|
+
await delay(100);
|
|
118
|
+
}
|
|
119
|
+
log("✓ 旧 CLI 已停止,继续启动新实例");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Serialize startup within a data directory; never remove a live owner's lock. */
|
|
123
|
+
export async function lockInstanceStartup(dataDir) {
|
|
124
|
+
await mkdir(dataDir, { recursive: true });
|
|
125
|
+
const path = resolve(await realpath(dataDir), ".cli-start.lock");
|
|
126
|
+
try {
|
|
127
|
+
await mkdir(path, { mode: 0o700 });
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error.code !== "EEXIST")
|
|
131
|
+
throw error;
|
|
132
|
+
throw new Error("此数据目录已有 CLI 正在启动;请等它完成后重试。若上次异常退出,请确认无启动进程后移除 .cli-start.lock。");
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
await writeFile(join(path, "pid"), String(process.pid), { mode: 0o600 });
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
await rm(path, { recursive: true, force: true });
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
let released = false;
|
|
142
|
+
return async () => { if (!released) {
|
|
143
|
+
released = true;
|
|
144
|
+
await rm(path, { recursive: true, force: true });
|
|
145
|
+
} };
|
|
146
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { once } from "node:events";
|
|
2
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm, realpath } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { lockInstanceStartup, stopPreviousInstances } from "./instance-takeover.js";
|
|
7
|
+
const temporary = [];
|
|
8
|
+
afterEach(async () => { await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true }))); });
|
|
9
|
+
async function fixture() {
|
|
10
|
+
const root = await realpath(await mkdtemp(join(tmpdir(), "cli-takeover-")));
|
|
11
|
+
temporary.push(root);
|
|
12
|
+
const registry = join(root, "registry");
|
|
13
|
+
const dataDir = join(root, "data-dir");
|
|
14
|
+
await mkdir(registry);
|
|
15
|
+
await mkdir(join(dataDir, "data"), { recursive: true });
|
|
16
|
+
const descriptor = { protocol: 1, instanceId: "test-instance", pid: 200, url: "http://127.0.0.1:12345", dataDir: join(dataDir, "data"), token: "a".repeat(64) };
|
|
17
|
+
const save = () => writeFile(join(registry, "test.json"), JSON.stringify(descriptor));
|
|
18
|
+
await save();
|
|
19
|
+
const rows = [{ pid: 200, ppid: 100, command: "/node /runtime/ws-server.mjs" }, { pid: 100, ppid: 1, command: "/node /cache/node_modules/agentroam/bin/agentroam.mjs" }];
|
|
20
|
+
const stop = vi.fn(async () => { });
|
|
21
|
+
const deps = { registry, request: vi.fn(async () => Response.json(descriptor)), processes: vi.fn(async () => rows), stop, alive: () => false, timeoutMs: 1 };
|
|
22
|
+
return { descriptor, save, deps, stop, dataDir, rows };
|
|
23
|
+
}
|
|
24
|
+
it("authenticates the same data directory and stops the CLI parent before proceeding", async () => {
|
|
25
|
+
const f = await fixture();
|
|
26
|
+
await stopPreviousInstances(f.dataDir, () => { }, f.deps);
|
|
27
|
+
expect(f.stop).toHaveBeenCalledTimes(1);
|
|
28
|
+
expect(f.stop).toHaveBeenCalledWith(100);
|
|
29
|
+
expect(f.deps.processes).toHaveBeenCalledTimes(2);
|
|
30
|
+
});
|
|
31
|
+
it("leaves a different data directory untouched without making an authenticated request", async () => {
|
|
32
|
+
const f = await fixture();
|
|
33
|
+
f.descriptor.dataDir = tmpdir();
|
|
34
|
+
await f.save();
|
|
35
|
+
await stopPreviousInstances(f.dataDir, () => { }, f.deps);
|
|
36
|
+
expect(f.deps.request).not.toHaveBeenCalled();
|
|
37
|
+
expect(f.stop).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
it("does not send the private token to non-loopback addresses", async () => {
|
|
40
|
+
const f = await fixture();
|
|
41
|
+
f.descriptor.url = "https://example.com";
|
|
42
|
+
await f.save();
|
|
43
|
+
await stopPreviousInstances(f.dataDir, () => { }, f.deps);
|
|
44
|
+
expect(f.deps.request).not.toHaveBeenCalled();
|
|
45
|
+
expect(f.stop).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
it("rejects a matching live server with a non-CLI parent", async () => {
|
|
48
|
+
const f = await fixture();
|
|
49
|
+
f.rows[1].command = "/Applications/Other.app/server";
|
|
50
|
+
await expect(stopPreviousInstances(f.dataDir, () => { }, f.deps)).rejects.toThrow("无法确认");
|
|
51
|
+
expect(f.stop).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
it("does not signal a reused PID", async () => {
|
|
54
|
+
const f = await fixture();
|
|
55
|
+
f.deps.processes.mockResolvedValueOnce(f.rows).mockResolvedValueOnce([{ ...f.rows[0], ppid: 300 }, f.rows[1]]);
|
|
56
|
+
await expect(stopPreviousInstances(f.dataDir, () => { }, f.deps)).rejects.toThrow("身份已变化");
|
|
57
|
+
expect(f.stop).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
it("refuses to launch while the previous process still runs", async () => {
|
|
60
|
+
const f = await fixture();
|
|
61
|
+
f.deps.alive = () => true;
|
|
62
|
+
await expect(stopPreviousInstances(f.dataDir, () => { }, f.deps)).rejects.toThrow("尚未退出");
|
|
63
|
+
});
|
|
64
|
+
it("ignores stale dead descriptors but blocks unreachable live instances", async () => {
|
|
65
|
+
const f = await fixture();
|
|
66
|
+
f.deps.request = vi.fn(async () => { throw new Error("offline"); });
|
|
67
|
+
await stopPreviousInstances(f.dataDir, () => { }, f.deps);
|
|
68
|
+
expect(f.stop).not.toHaveBeenCalled();
|
|
69
|
+
f.deps.alive = () => true;
|
|
70
|
+
await expect(stopPreviousInstances(f.dataDir, () => { }, f.deps)).rejects.toThrow("无法核验");
|
|
71
|
+
});
|
|
72
|
+
it("serializes startup and releases the lock for the next launch", async () => {
|
|
73
|
+
const f = await fixture();
|
|
74
|
+
const release = await lockInstanceStartup(f.dataDir);
|
|
75
|
+
await expect(lockInstanceStartup(f.dataDir)).rejects.toThrow("正在启动");
|
|
76
|
+
await release();
|
|
77
|
+
await release();
|
|
78
|
+
await (await lockInstanceStartup(f.dataDir))();
|
|
79
|
+
});
|
|
80
|
+
it.skipIf(process.platform === "win32")("stops an authenticated real CLI parent and waits for its server child to exit", async () => {
|
|
81
|
+
const { spawn } = await import("node:child_process");
|
|
82
|
+
const f = await fixture();
|
|
83
|
+
const cli = join(f.dataDir, "node_modules/agentroam/bin/agentroam.mjs");
|
|
84
|
+
const server = join(f.dataDir, "runtime/ws-server.mjs");
|
|
85
|
+
await mkdir(join(f.dataDir, "node_modules/agentroam/bin"), { recursive: true });
|
|
86
|
+
await mkdir(join(f.dataDir, "runtime"));
|
|
87
|
+
await writeFile(server, `import http from 'node:http';\nimport {writeFileSync} from 'node:fs';\nconst d=JSON.parse(process.env.DESCRIPTOR);d.pid=process.pid;\nconst s=http.createServer((req,res)=>{if(req.headers['x-agentroam-desktop-token']!==d.token){res.writeHead(401).end();return;}res.setHeader('content-type','application/json');res.end(JSON.stringify(d));});\ns.listen(0,'127.0.0.1',()=>{d.url='http://127.0.0.1:'+s.address().port;writeFileSync(process.env.RECORD,JSON.stringify(d));console.log('ready');});`);
|
|
88
|
+
await writeFile(cli, `import {spawn} from 'node:child_process';\nimport {writeFileSync} from 'node:fs';\nconst child=spawn(process.execPath,[process.env.SERVER],{stdio:['ignore','inherit','inherit']});\nprocess.on('SIGTERM',()=>{writeFileSync(process.env.MARKER,'graceful');child.kill('SIGTERM');});\nchild.on('exit',()=>process.exit(0));`);
|
|
89
|
+
const marker = join(f.dataDir, "stopped");
|
|
90
|
+
const child = spawn(process.execPath, [cli], { env: { ...process.env, SERVER: server, RECORD: join(f.deps.registry, "test.json"), DESCRIPTOR: JSON.stringify(f.descriptor), MARKER: marker }, stdio: ["ignore", "pipe", "pipe"] });
|
|
91
|
+
const exited = once(child, "exit");
|
|
92
|
+
try {
|
|
93
|
+
await once(child.stdout, "data");
|
|
94
|
+
await stopPreviousInstances(f.dataDir, () => { }, { registry: f.deps.registry, stop: async (pid) => { expect(pid).toBe(child.pid); process.kill(pid, "SIGTERM"); } });
|
|
95
|
+
await exited;
|
|
96
|
+
const { readFile } = await import("node:fs/promises");
|
|
97
|
+
expect(await readFile(marker, "utf8")).toBe("graceful");
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
if (child.exitCode === null) {
|
|
101
|
+
child.kill("SIGTERM");
|
|
102
|
+
await exited;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
it("stops an authenticated orphan only when its executable belongs to a runtime package", async () => {
|
|
107
|
+
const f = await fixture();
|
|
108
|
+
const pkg = join(f.dataDir, "launcher/0.2.0-preview.18/node_modules/agentroam-runtime-darwin-arm64");
|
|
109
|
+
await mkdir(join(pkg, "runtime"), { recursive: true });
|
|
110
|
+
await writeFile(join(pkg, "package.json"), JSON.stringify({ name: "agentroam-runtime-darwin-arm64" }));
|
|
111
|
+
await writeFile(join(pkg, "runtime/ws-server.mjs"), "// fixture");
|
|
112
|
+
f.rows.splice(0, f.rows.length, { pid: 200, ppid: 1, command: `/node ${join(pkg, "runtime/ws-server.mjs")}` });
|
|
113
|
+
await stopPreviousInstances(f.dataDir, () => { }, f.deps);
|
|
114
|
+
expect(f.stop).toHaveBeenCalledWith(200);
|
|
115
|
+
});
|
|
116
|
+
it("does not stop an orphan merely because its script is named ws-server.mjs", async () => {
|
|
117
|
+
const f = await fixture();
|
|
118
|
+
f.rows.splice(0, f.rows.length, { pid: 200, ppid: 1, command: "/node /unrelated/ws-server.mjs" });
|
|
119
|
+
await expect(stopPreviousInstances(f.dataDir, () => { }, f.deps)).rejects.toThrow("无法确认");
|
|
120
|
+
expect(f.stop).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
@@ -2,9 +2,8 @@ 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
|
-
const
|
|
6
|
-
export const
|
|
7
|
-
export const PLATFORM_DEPENDENCY_VERSIONS = launcherManifest.optionalDependencies;
|
|
5
|
+
export const AGENTROAM_VERSION = "0.2.0-preview.20";
|
|
6
|
+
export const PLATFORM_DEPENDENCY_VERSIONS = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).optionalDependencies;
|
|
8
7
|
export const PLATFORM_PACKAGES = {
|
|
9
8
|
"darwin-arm64": {
|
|
10
9
|
runtime: "agentroam-runtime-darwin-arm64",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { lstat, readFile, readdir, realpath, rm } from "node:fs/promises";
|
|
2
|
+
import { relative, resolve, sep } from "node:path";
|
|
3
|
+
function versionParts(version) {
|
|
4
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-preview\.(0|[1-9]\d*))?$/.exec(version);
|
|
5
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3]), match[4] === undefined ? Infinity : Number(match[4])] : null;
|
|
6
|
+
}
|
|
7
|
+
function older(candidate, current) {
|
|
8
|
+
const left = versionParts(candidate), right = versionParts(current);
|
|
9
|
+
if (!left || !right)
|
|
10
|
+
return false;
|
|
11
|
+
for (let i = 0; i < left.length; i++)
|
|
12
|
+
if (left[i] !== right[i])
|
|
13
|
+
return left[i] < right[i];
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
function contains(parent, child) {
|
|
17
|
+
const path = relative(parent, child);
|
|
18
|
+
return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !path.startsWith(sep));
|
|
19
|
+
}
|
|
20
|
+
/** Remove only recognized managed launcher versions after the replacement service is ready. */
|
|
21
|
+
export async function cleanupOldLaunchers(config, log) {
|
|
22
|
+
try {
|
|
23
|
+
const dataDir = await realpath(config.dataDir);
|
|
24
|
+
const launcherDir = resolve(dataDir, "launcher");
|
|
25
|
+
if (!(await lstat(launcherDir)).isDirectory() || (await lstat(launcherDir)).isSymbolicLink())
|
|
26
|
+
return;
|
|
27
|
+
const currentDir = resolve(launcherDir, config.version);
|
|
28
|
+
const expectedEntry = resolve(currentDir, "node_modules/agentroam/bin/agentroam.mjs");
|
|
29
|
+
if (await realpath(config.cliPath) !== expectedEntry)
|
|
30
|
+
return;
|
|
31
|
+
const roots = await Promise.all(config.roots.map(async (root) => realpath(root).catch(() => resolve(root))));
|
|
32
|
+
for (const entry of await readdir(launcherDir, { withFileTypes: true })) {
|
|
33
|
+
if (!entry.isDirectory() || !older(entry.name, config.version))
|
|
34
|
+
continue;
|
|
35
|
+
const candidate = resolve(launcherDir, entry.name);
|
|
36
|
+
try {
|
|
37
|
+
if (await lstat(`${candidate}.lock`).then(() => true, () => false))
|
|
38
|
+
continue;
|
|
39
|
+
if (roots.some(root => contains(candidate, root)))
|
|
40
|
+
continue;
|
|
41
|
+
const files = await readdir(candidate);
|
|
42
|
+
if (files.some(file => !["node_modules", "package.json", "package-lock.json"].includes(file)))
|
|
43
|
+
continue;
|
|
44
|
+
const manifestPath = resolve(candidate, "node_modules/agentroam/package.json");
|
|
45
|
+
if (await realpath(manifestPath) !== manifestPath)
|
|
46
|
+
continue;
|
|
47
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
48
|
+
if (manifest.name !== "agentroam" || manifest.version !== entry.name)
|
|
49
|
+
continue;
|
|
50
|
+
await rm(candidate, { recursive: true });
|
|
51
|
+
log(`✓ 已清理旧版 CLI ${entry.name},配置和数据已保留`);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
log(`旧版 CLI ${entry.name} 暂未清理,将在下次安装时重试。`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
log("旧版 CLI 清理已跳过,新版服务不受影响。");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterEach, expect, it } from "vitest";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, readFile, rm, symlink, realpath } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { cleanupOldLaunchers } from "./launcher-cleanup.js";
|
|
6
|
+
const temporary = [];
|
|
7
|
+
afterEach(async () => { await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true }))); });
|
|
8
|
+
async function fixture() {
|
|
9
|
+
const dataDir = await realpath(await mkdtemp(resolve(tmpdir(), "launcher-cleanup-")));
|
|
10
|
+
temporary.push(dataDir);
|
|
11
|
+
async function install(version) {
|
|
12
|
+
const root = resolve(dataDir, "launcher", version);
|
|
13
|
+
const pkg = resolve(root, "node_modules/agentroam");
|
|
14
|
+
await mkdir(resolve(pkg, "bin"), { recursive: true });
|
|
15
|
+
await writeFile(resolve(pkg, "package.json"), JSON.stringify({ name: "agentroam", version }));
|
|
16
|
+
await writeFile(resolve(pkg, "bin/agentroam.mjs"), "// fixture");
|
|
17
|
+
return root;
|
|
18
|
+
}
|
|
19
|
+
const current = await install("0.2.0-preview.19");
|
|
20
|
+
const config = { dataDir, version: "0.2.0-preview.19", cliPath: resolve(current, "node_modules/agentroam/bin/agentroam.mjs"), roots: [] };
|
|
21
|
+
return { dataDir, current, config, install };
|
|
22
|
+
}
|
|
23
|
+
it("removes an older managed program while retaining current, newer versions and user data", async () => {
|
|
24
|
+
const f = await fixture();
|
|
25
|
+
const old = await f.install("0.2.0-preview.18");
|
|
26
|
+
const newer = await f.install("0.2.0-preview.20");
|
|
27
|
+
for (const file of ["config.json", "agent.db", "pairing.db"])
|
|
28
|
+
await writeFile(resolve(f.dataDir, file), "preserve");
|
|
29
|
+
await cleanupOldLaunchers(f.config, () => { });
|
|
30
|
+
await expect(readFile(resolve(old, "node_modules/agentroam/package.json"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
31
|
+
for (const root of [f.current, newer])
|
|
32
|
+
expect(await readFile(resolve(root, "node_modules/agentroam/package.json"), "utf8")).toContain("agentroam");
|
|
33
|
+
for (const file of ["config.json", "agent.db", "pairing.db"])
|
|
34
|
+
expect(await readFile(resolve(f.dataDir, file), "utf8")).toBe("preserve");
|
|
35
|
+
});
|
|
36
|
+
it("preserves project roots, unexpected data, locked installs, and linked versions", async () => {
|
|
37
|
+
const f = await fixture();
|
|
38
|
+
const project = await f.install("0.2.0-preview.15");
|
|
39
|
+
f.config.roots = [project];
|
|
40
|
+
const data = await f.install("0.2.0-preview.16");
|
|
41
|
+
await writeFile(resolve(data, "session.db"), "preserve");
|
|
42
|
+
const locked = await f.install("0.2.0-preview.17");
|
|
43
|
+
await mkdir(`${locked}.lock`);
|
|
44
|
+
await symlink(data, resolve(f.dataDir, "launcher/0.2.0-preview.14"));
|
|
45
|
+
await cleanupOldLaunchers(f.config, () => { });
|
|
46
|
+
for (const root of [project, data, locked])
|
|
47
|
+
expect(await readFile(resolve(root, "node_modules/agentroam/package.json"), "utf8")).toContain("agentroam");
|
|
48
|
+
});
|
|
49
|
+
it("does not clean when the active CLI is outside the managed installation", async () => {
|
|
50
|
+
const f = await fixture();
|
|
51
|
+
const old = await f.install("0.2.0-preview.18");
|
|
52
|
+
f.config.cliPath = resolve(f.dataDir, "npx/agentroam.mjs");
|
|
53
|
+
await cleanupOldLaunchers(f.config, () => { });
|
|
54
|
+
expect(await readFile(resolve(old, "node_modules/agentroam/package.json"), "utf8")).toContain("agentroam");
|
|
55
|
+
});
|
|
56
|
+
it.each(["starting", "failed", "ready"])("cleans only after a replacement service is ready (%s)", async (status) => {
|
|
57
|
+
const { runServiceCommand } = await import("./service-command.js");
|
|
58
|
+
const { parseArgs } = await import("../args.js");
|
|
59
|
+
const f = await fixture();
|
|
60
|
+
const old = await f.install("0.2.0-preview.18");
|
|
61
|
+
const install = async () => {
|
|
62
|
+
if (status === "failed")
|
|
63
|
+
throw new Error("activation failed");
|
|
64
|
+
return { definition: "test", state: { status, version: f.config.version } };
|
|
65
|
+
};
|
|
66
|
+
const action = runServiceCommand(parseArgs(["service", "install", "--data-dir", f.dataDir]), {
|
|
67
|
+
platform: "darwin", arch: "arm64", nodeVersion: "25.8.0", nodePath: process.execPath,
|
|
68
|
+
cliPath: f.config.cliPath, version: f.config.version, log: () => { },
|
|
69
|
+
codexResolver: async () => ({ executable: "/test/codex", version: "test", source: "global" }),
|
|
70
|
+
controller: { install },
|
|
71
|
+
});
|
|
72
|
+
if (status === "failed")
|
|
73
|
+
await expect(action).rejects.toThrow("activation failed");
|
|
74
|
+
else
|
|
75
|
+
await action;
|
|
76
|
+
const manifest = readFile(resolve(old, "node_modules/agentroam/package.json"), "utf8");
|
|
77
|
+
if (status === "ready")
|
|
78
|
+
await expect(manifest).rejects.toMatchObject({ code: "ENOENT" });
|
|
79
|
+
else
|
|
80
|
+
expect(await manifest).toContain("agentroam");
|
|
81
|
+
});
|
|
@@ -6,6 +6,7 @@ import { renderQr } from "../qr.js";
|
|
|
6
6
|
import { MacLaunchAgent } from "./macos-launch-agent.js";
|
|
7
7
|
import { WindowsTaskService } from "./windows-task-service.js";
|
|
8
8
|
import { assertSupportedNodeVersion } from "../../bin/runtime-policy.mjs";
|
|
9
|
+
import { cleanupOldLaunchers } from "./launcher-cleanup.js";
|
|
9
10
|
export async function runServiceCommand(options, context) {
|
|
10
11
|
const platform = context.platform ?? process.platform;
|
|
11
12
|
const log = context.log ?? console.log;
|
|
@@ -57,10 +58,15 @@ export async function runServiceCommand(options, context) {
|
|
|
57
58
|
};
|
|
58
59
|
const { definition, state } = await controller.install(config);
|
|
59
60
|
log(`✓ AgentRoam service installed: ${definition}`);
|
|
61
|
+
if (state?.status === "ready" && state.version === config.version)
|
|
62
|
+
await cleanupOldLaunchers(config, log);
|
|
60
63
|
if (state?.status === "ready" && state.accessUrl) {
|
|
61
64
|
log(`Open: ${state.accessUrl}`);
|
|
62
|
-
if (state.localUrl)
|
|
63
|
-
log(
|
|
65
|
+
if (state.localUrl) {
|
|
66
|
+
log(`远程桌面授权地址:${state.localUrl.replace(/\/$/, "")}/web`);
|
|
67
|
+
log('请在运行 CLI 的这台电脑上用浏览器打开此地址,配对后点击“远程授权”(盾牌图标)。');
|
|
68
|
+
log('按提示授予“屏幕录制”和“辅助功能”权限,然后点击“开启共享”。');
|
|
69
|
+
}
|
|
64
70
|
await (context.pairingPrinter ?? printPairingCode)(options.dataDir, log, { accessUrl: state.accessUrl, qr: options.qr, interactive: stdoutIsTTY });
|
|
65
71
|
}
|
|
66
72
|
else
|
|
@@ -25,7 +25,7 @@ describe("runServiceCommand", () => {
|
|
|
25
25
|
it("installs the service using absolute runtime context and parsed start options", async () => {
|
|
26
26
|
const install = vi.fn(async (config) => ({
|
|
27
27
|
paths: { plistPath: "/Users/test/Library/LaunchAgents/com.agentroam.service.plist" },
|
|
28
|
-
state: { status: "ready", accessUrl: "https://ready.example/web" },
|
|
28
|
+
state: { status: "ready", accessUrl: "https://ready.example/web", localUrl: "http://127.0.0.1:4317/" },
|
|
29
29
|
definition: "/Users/test/Library/LaunchAgents/com.agentroam.service.plist",
|
|
30
30
|
}));
|
|
31
31
|
const log = vi.fn();
|
|
@@ -53,6 +53,8 @@ describe("runServiceCommand", () => {
|
|
|
53
53
|
installedAt: "2026-09-03T00:00:00.000Z",
|
|
54
54
|
}));
|
|
55
55
|
expect(log).toHaveBeenCalledWith("Open: https://ready.example/web");
|
|
56
|
+
expect(log).toHaveBeenCalledWith("远程桌面授权地址:http://127.0.0.1:4317/web");
|
|
57
|
+
expect(log).toHaveBeenCalledWith(expect.stringContaining("屏幕录制"));
|
|
56
58
|
});
|
|
57
59
|
it("prints a scannable QR code after the URL on interactive terminals", async () => {
|
|
58
60
|
const log = vi.fn();
|
package/install/README.md
CHANGED
|
@@ -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.
|
|
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.20`.
|
|
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
|
|
|
@@ -226,7 +226,7 @@ if ($PathParts -notcontains $WrapperDir) {
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
& $NodeBin $Entry doctor --data-dir $DataDir
|
|
229
|
-
if ($LASTEXITCODE -ne 0) {
|
|
229
|
+
if ($LASTEXITCODE -ne 0) { Write-Warning "Some component checks failed; continuing background service installation. Affected features may be unavailable. Run agentroam doctor to retry." }
|
|
230
230
|
if ($env:AGENTROAM_INSTALL_SKIP_SERVICE -eq "1") {
|
|
231
231
|
Write-Host "AgentRoam service registration skipped for isolated verification."
|
|
232
232
|
} else {
|
|
@@ -4,7 +4,7 @@ set -eu
|
|
|
4
4
|
NODE_VERSION="22.22.0"
|
|
5
5
|
MINIMUM_NODE_VERSION="22.22.0"
|
|
6
6
|
# Default to the newest published preview; pin an exact version via
|
|
7
|
-
# AGENTROAM_VERSION=0.2.0-preview.
|
|
7
|
+
# AGENTROAM_VERSION=0.2.0-preview.20 sh install-agentroam.sh
|
|
8
8
|
AGENTROAM_VERSION_REQUEST="${AGENTROAM_VERSION:-preview}"
|
|
9
9
|
AGENTROAM_VERSION=""
|
|
10
10
|
NODE_ARCHIVE="node-v22.22.0-darwin-arm64.tar.xz"
|
|
@@ -253,7 +253,9 @@ wrapper_temp=$(mktemp "$WRAPPER_DIR/.agentroam.XXXXXX")
|
|
|
253
253
|
chmod 755 "$wrapper_temp"
|
|
254
254
|
mv "$wrapper_temp" "$WRAPPER_PATH"
|
|
255
255
|
|
|
256
|
-
"$NODE_BIN" "$entry" doctor --data-dir "$DATA_DIR"
|
|
256
|
+
if ! "$NODE_BIN" "$entry" doctor --data-dir "$DATA_DIR"; then
|
|
257
|
+
printf 'Warning: some component checks failed; continuing background service installation. Affected features may be unavailable. Run agentroam doctor to retry.\n' >&2
|
|
258
|
+
fi
|
|
257
259
|
if [ "${AGENTROAM_INSTALL_SKIP_SERVICE:-}" = "1" ]; then
|
|
258
260
|
printf 'AgentRoam service registration skipped for isolated verification.\n'
|
|
259
261
|
else
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentroam",
|
|
3
|
-
"version": "0.2.0-preview.
|
|
3
|
+
"version": "0.2.0-preview.20",
|
|
4
4
|
"description": "Zero-install mobile remote terminal and file console",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,12 +26,12 @@
|
|
|
26
26
|
"undici": "^6.25.0"
|
|
27
27
|
},
|
|
28
28
|
"optionalDependencies": {
|
|
29
|
-
"agentroam-runtime-darwin-arm64": "0.2.0-preview.
|
|
30
|
-
"agentroam-runtime-win32-x64": "0.2.0-preview.
|
|
31
|
-
"agentroam-cloudflared-darwin-arm64": "0.2.0-preview.
|
|
32
|
-
"agentroam-cloudflared-win32-x64": "0.2.0-preview.
|
|
33
|
-
"agentroam-tui-darwin-arm64": "0.2.0-preview.
|
|
34
|
-
"@caoqu/agentroam-tui-win32-x64": "0.2.0-preview.
|
|
29
|
+
"agentroam-runtime-darwin-arm64": "0.2.0-preview.20",
|
|
30
|
+
"agentroam-runtime-win32-x64": "0.2.0-preview.20",
|
|
31
|
+
"agentroam-cloudflared-darwin-arm64": "0.2.0-preview.20",
|
|
32
|
+
"agentroam-cloudflared-win32-x64": "0.2.0-preview.20",
|
|
33
|
+
"agentroam-tui-darwin-arm64": "0.2.0-preview.20",
|
|
34
|
+
"@caoqu/agentroam-tui-win32-x64": "0.2.0-preview.20"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/node": "^22.0.0",
|