agentroam 0.2.0-preview.37 → 0.2.0-preview.39
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/bin/cli-progress.d.mts +9 -0
- package/bin/cli-progress.mjs +65 -0
- package/bin/desktop-download.mjs +2 -1
- package/bin/node-preflight.mjs +6 -6
- package/dist/args.js +12 -1
- package/dist/cli.js +40 -15
- package/dist/codex-desktop-setup.js +2 -1
- package/dist/desktop-authorization.js +148 -0
- package/dist/device-pairing.js +20 -40
- package/dist/instance-takeover.js +25 -4
- package/dist/local-desktop-url.js +1 -1
- package/dist/local-service-request.js +52 -0
- package/dist/runtime-manager.js +4 -3
- package/dist/service/macos-launch-agent.js +13 -7
- package/dist/service/service-command.js +40 -22
- package/dist/service/windows-task-service.js +10 -6
- package/dist/unlock-service.js +16 -8
- package/install/install-agentroam.ps1 +1 -1
- package/install/install-agentroam.sh +72 -8
- package/install/install-manifest.json +4 -4
- package/package.json +7 -7
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface CliProgressOptions {
|
|
2
|
+
enabled?: boolean;
|
|
3
|
+
environment?: NodeJS.ProcessEnv;
|
|
4
|
+
log?: (line: string) => void;
|
|
5
|
+
delayMs?: number;
|
|
6
|
+
intervalMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export function withCliProgress<T>(label: string, operation: () => T | Promise<T>, options?: CliProgressOptions): Promise<T>;
|
|
9
|
+
export function withoutCliProgress<T>(operation: () => T | Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
|
|
3
|
+
const context = new AsyncLocalStorage();
|
|
4
|
+
|
|
5
|
+
// Append complete lines to stderr so stdout (URLs, QR codes, JSON) stays usable.
|
|
6
|
+
class ProgressReporter {
|
|
7
|
+
constructor(options) {
|
|
8
|
+
const environment = options.environment ?? process.env;
|
|
9
|
+
this.enabled = options.enabled ?? (environment.AGENTROAM_SERVICE !== '1' && environment.AGENTROAM_NO_PROGRESS !== '1');
|
|
10
|
+
this.log = options.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
11
|
+
this.delayMs = options.delayMs ?? 800;
|
|
12
|
+
this.intervalMs = options.intervalMs ?? 5000;
|
|
13
|
+
this.steps = [];
|
|
14
|
+
this.pauses = 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
begin(label) {
|
|
18
|
+
const step = { label, started: Date.now() };
|
|
19
|
+
this.steps.push(step);
|
|
20
|
+
this.schedule(this.delayMs);
|
|
21
|
+
return () => {
|
|
22
|
+
const wasActive = this.steps.at(-1) === step;
|
|
23
|
+
this.steps.splice(this.steps.indexOf(step), 1);
|
|
24
|
+
if (wasActive) this.schedule(this.delayMs);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
schedule(milliseconds) {
|
|
29
|
+
clearTimeout(this.timer);
|
|
30
|
+
if (!this.enabled || this.pauses || !this.steps.length) return;
|
|
31
|
+
this.timer = setTimeout(() => {
|
|
32
|
+
const step = this.steps.at(-1);
|
|
33
|
+
const seconds = Math.floor((Date.now() - step.started) / 1000);
|
|
34
|
+
this.log(`… ${step.label}(已等待 ${seconds} 秒)`);
|
|
35
|
+
this.schedule(this.intervalMs);
|
|
36
|
+
}, milliseconds);
|
|
37
|
+
this.timer.unref?.();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
pause() {
|
|
41
|
+
this.pauses++;
|
|
42
|
+
clearTimeout(this.timer);
|
|
43
|
+
return () => {
|
|
44
|
+
this.pauses--;
|
|
45
|
+
this.schedule(this.delayMs);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Nested stages share one heartbeat. Timers always stop, including on failure. */
|
|
51
|
+
export async function withCliProgress(label, operation, options = {}) {
|
|
52
|
+
const reporter = context.getStore() ?? new ProgressReporter(options);
|
|
53
|
+
return context.run(reporter, async () => {
|
|
54
|
+
const finish = reporter.begin(label);
|
|
55
|
+
try { return await operation(); }
|
|
56
|
+
finally { finish(); }
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Suspend every nested heartbeat while a terminal prompt owns the cursor. */
|
|
61
|
+
export async function withoutCliProgress(operation) {
|
|
62
|
+
const resume = context.getStore()?.pause();
|
|
63
|
+
try { return await operation(); }
|
|
64
|
+
finally { resume?.(); }
|
|
65
|
+
}
|
package/bin/desktop-download.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import { Transform, Readable } from 'node:stream';
|
|
9
9
|
import { pipeline } from 'node:stream/promises';
|
|
10
10
|
import { execFile } from 'node:child_process';
|
|
11
|
+
import { withCliProgress } from './cli-progress.mjs';
|
|
11
12
|
|
|
12
13
|
// 桌面端安装包是构建产物(dmg / exe),进不了只放源码的仓库,只能走 Releases 资产。
|
|
13
14
|
// 资产由 `node scripts/publish-agentroam-release.mjs sync-github --commit <sha>` 上传;
|
|
@@ -57,7 +58,7 @@ export async function downloadDesktop(version, { platform = process.platform, ar
|
|
|
57
58
|
|
|
58
59
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
59
60
|
try {
|
|
60
|
-
const destination = await downloadDesktop(process.argv[2]);
|
|
61
|
+
const destination = await withCliProgress('正在下载并校验桌面端安装包', () => downloadDesktop(process.argv[2]));
|
|
61
62
|
console.log(`桌面端安装包已下载:${destination}\n请打开安装包完成安装。首次打开 AgentRoam 后,按提示授权远程桌面。`);
|
|
62
63
|
if (process.platform === 'darwin') execFile('/usr/bin/open', ['-R', destination], () => undefined);
|
|
63
64
|
else if (process.platform === 'win32') execFile('explorer.exe', [`/select,${destination}`], () => undefined);
|
package/bin/node-preflight.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { delimiter, dirname, resolve } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
import { MINIMUM_NODE_VERSION, compareNodeVersions, isSupportedNodeVersion, parseNodeVersion } from "./runtime-policy.mjs";
|
|
8
|
+
import { withCliProgress } from "./cli-progress.mjs";
|
|
8
9
|
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
// 安装引导地址:指向仓库主分支上的安装脚本(raw 路径)。
|
|
@@ -35,10 +36,9 @@ export async function runNodePreflight(options = {}) {
|
|
|
35
36
|
const target = detectManagedTarget(platform, arch);
|
|
36
37
|
const dataDir = parsePreflightDataDir(argv, environment, options.homeDir ?? homedir());
|
|
37
38
|
const findSystemNode = options.findSystemNode ?? defaultFindSystemNode;
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (!executable) {
|
|
39
|
+
const executable = await withCliProgress("正在查找或安装兼容的 Node.js", async () => {
|
|
40
|
+
const systemNode = await findSystemNode(environment, platform, options.currentExecutable ?? process.execPath);
|
|
41
|
+
if (systemNode) return systemNode;
|
|
42
42
|
const ensureNode = options.ensureNode ?? (async (installOptions) => {
|
|
43
43
|
const { ensureManagedNode } = await import("../dist/node-runtime-manager.js");
|
|
44
44
|
return ensureManagedNode(installOptions);
|
|
@@ -50,8 +50,8 @@ export async function runNodePreflight(options = {}) {
|
|
|
50
50
|
arch,
|
|
51
51
|
onProgress: options.onProgress ?? ((message) => processHost.stderr.write(`${message}\n`)),
|
|
52
52
|
});
|
|
53
|
-
|
|
54
|
-
}
|
|
53
|
+
return resolution.executable;
|
|
54
|
+
}, { environment, log: (line) => processHost.stderr.write(`${line}\n`) });
|
|
55
55
|
|
|
56
56
|
const spawnChild = options.spawnChild ?? defaultSpawnChild;
|
|
57
57
|
const child = spawnChild(executable, [launcherPath, ...argv], {
|
package/dist/args.js
CHANGED
|
@@ -3,11 +3,18 @@ import { resolve } from "node:path";
|
|
|
3
3
|
export function parseArgs(argv) {
|
|
4
4
|
const values = [...argv];
|
|
5
5
|
let command = "start";
|
|
6
|
-
if (values[0] && ["start", "doctor", "version", "service", "unlock-service", "update", "update-worker", "pair", "devices", "revoke", "approvals", "approve", "deny", "lock", "unlock", "audit", "codex-desktop"].includes(values[0])) {
|
|
6
|
+
if (values[0] && ["start", "doctor", "version", "service", "unlock-service", "update", "update-worker", "pair", "devices", "revoke", "approvals", "approve", "deny", "lock", "unlock", "audit", "codex-desktop", "desktop"].includes(values[0])) {
|
|
7
7
|
command = values.shift();
|
|
8
8
|
}
|
|
9
9
|
let serviceAction = null;
|
|
10
10
|
let unlockServiceAction = null;
|
|
11
|
+
let desktopAction;
|
|
12
|
+
if (command === "desktop") {
|
|
13
|
+
const action = values.shift();
|
|
14
|
+
if (!action || !["authorize", "status", "disable"].includes(action))
|
|
15
|
+
throw cliError("desktop requires authorize, status, or disable");
|
|
16
|
+
desktopAction = action;
|
|
17
|
+
}
|
|
11
18
|
if (command === "service") {
|
|
12
19
|
const action = values.shift();
|
|
13
20
|
if (!action || !["install", "start", "stop", "status", "url", "logs", "restart", "uninstall"].includes(action)) {
|
|
@@ -67,9 +74,13 @@ export function parseArgs(argv) {
|
|
|
67
74
|
...(approvalRequestId ? { approvalRequestId } : {}),
|
|
68
75
|
updateVersion,
|
|
69
76
|
updateStateFile,
|
|
77
|
+
...(desktopAction ? { desktopAction } : {}),
|
|
70
78
|
};
|
|
71
79
|
for (let index = 0; index < values.length && command !== "update-worker"; index++) {
|
|
72
80
|
const arg = values[index];
|
|
81
|
+
if (command === "desktop" && !["--data-dir", "--help", "-h"].includes(arg)) {
|
|
82
|
+
throw cliError("desktop accepts only --data-dir");
|
|
83
|
+
}
|
|
73
84
|
if (command === "codex-desktop" && !["--restart", "--dry-run", "--setup", "--data-dir", "--help", "-h"].includes(arg)) {
|
|
74
85
|
throw cliError("codex-desktop accepts only --setup, --restart, --dry-run and --data-dir");
|
|
75
86
|
}
|
package/dist/cli.js
CHANGED
|
@@ -23,6 +23,8 @@ import { runUnlockServiceCommand } from "./unlock-service.js";
|
|
|
23
23
|
import { createCliErrorLog } from "./error-log.js";
|
|
24
24
|
import { runCodexDesktopCommand } from "./codex-desktop-command.js";
|
|
25
25
|
import { setupCodexDesktop } from "./codex-desktop-setup.js";
|
|
26
|
+
import { runDesktopCommand } from "./desktop-authorization.js";
|
|
27
|
+
import { withCliProgress } from "../bin/cli-progress.mjs";
|
|
26
28
|
const VERSION = AGENTROAM_VERSION;
|
|
27
29
|
export async function main(argv) {
|
|
28
30
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
@@ -40,6 +42,9 @@ export async function main(argv) {
|
|
|
40
42
|
agentroam revoke <设备ID> 移除一台设备
|
|
41
43
|
agentroam revoke --all 全部退出并取消待用配对码
|
|
42
44
|
agentroam service install|start|stop|restart|status|url|logs|uninstall
|
|
45
|
+
agentroam desktop authorize 本机弹窗授权远程桌面,无需浏览器;可重复执行
|
|
46
|
+
agentroam desktop status 重新检查远程桌面权限和共享状态
|
|
47
|
+
agentroam desktop disable 关闭远程桌面共享,保留系统权限
|
|
43
48
|
agentroam unlock-service install|uninstall|status 管理 Windows 远程解锁服务(安装时弹出 UAC)
|
|
44
49
|
agentroam codex-desktop [--dry-run] 通过共享后端启动官方 Codex 桌面端(macOS)
|
|
45
50
|
agentroam codex-desktop --restart 退出并重新连接官方桌面端,会中断它的全部运行会话
|
|
@@ -49,6 +54,19 @@ export async function main(argv) {
|
|
|
49
54
|
return;
|
|
50
55
|
}
|
|
51
56
|
const options = parseArgs(argv);
|
|
57
|
+
// Foreground start remains alive after readiness; only its startup stages load.
|
|
58
|
+
if (options.command === "start" || options.command === "update-worker")
|
|
59
|
+
return executeCommand(options, argv);
|
|
60
|
+
const label = options.command === "service" ? `正在处理后台服务:${options.serviceAction}`
|
|
61
|
+
: options.command === "desktop" ? "正在检查远程桌面授权"
|
|
62
|
+
: options.command === "codex-desktop" ? "正在配置官方 Codex 桌面连接"
|
|
63
|
+
: options.command === "doctor" ? "正在检查运行组件"
|
|
64
|
+
: options.command === "update" ? "正在检查并准备更新"
|
|
65
|
+
: options.command === "unlock-service" ? "正在处理 Windows 解锁服务,请留意管理员授权弹窗"
|
|
66
|
+
: "正在处理设备授权命令";
|
|
67
|
+
await withCliProgress(label, () => executeCommand(options, argv));
|
|
68
|
+
}
|
|
69
|
+
async function executeCommand(options, argv) {
|
|
52
70
|
// CLI 进程自身的按天错误日志:<dataDir>/logs/YYYY-MM-DD.log(NDJSON,与
|
|
53
71
|
// server/desktop 的 core logger 同格式)。uncaughtException 记录后按默认
|
|
54
72
|
// 语义退出(1),unhandledRejection 只记录。
|
|
@@ -98,6 +116,12 @@ export async function main(argv) {
|
|
|
98
116
|
return;
|
|
99
117
|
}
|
|
100
118
|
const target = detectPlatform();
|
|
119
|
+
if (options.command === "desktop") {
|
|
120
|
+
if (!argv.includes("--data-dir"))
|
|
121
|
+
options.dataDir = await installedDataDir(options.dataDir);
|
|
122
|
+
await runDesktopCommand(options);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
101
125
|
if (options.command === "codex-desktop") {
|
|
102
126
|
if (!argv.includes("--data-dir"))
|
|
103
127
|
options.dataDir = await installedDataDir(options.dataDir);
|
|
@@ -110,7 +134,7 @@ export async function main(argv) {
|
|
|
110
134
|
if (options.command === "unlock-service") {
|
|
111
135
|
if (target !== "windows-amd64")
|
|
112
136
|
throw new Error("远程解锁服务仅支持 Windows 10/11 x64");
|
|
113
|
-
runUnlockServiceCommand(options.unlockServiceAction, resolvePlatformRuntime(target).runtimeRoot);
|
|
137
|
+
await runUnlockServiceCommand(options.unlockServiceAction, resolvePlatformRuntime(target).runtimeRoot);
|
|
114
138
|
return;
|
|
115
139
|
}
|
|
116
140
|
if (options.command === "update") {
|
|
@@ -129,7 +153,7 @@ export async function main(argv) {
|
|
|
129
153
|
return;
|
|
130
154
|
}
|
|
131
155
|
console.log(`AgentRoam ${VERSION}\n✓ Node ${process.versions.node} · ${target}`);
|
|
132
|
-
const sleepInhibitor = await acquireSleepInhibitor();
|
|
156
|
+
const sleepInhibitor = await withCliProgress("正在准备启动环境", () => acquireSleepInhibitor());
|
|
133
157
|
const controller = new AbortController();
|
|
134
158
|
let runtime = null;
|
|
135
159
|
let relay = null;
|
|
@@ -145,23 +169,23 @@ export async function main(argv) {
|
|
|
145
169
|
return closePromise;
|
|
146
170
|
closing = true;
|
|
147
171
|
controller.abort();
|
|
148
|
-
closePromise = (async () => {
|
|
172
|
+
closePromise = withCliProgress("正在停止本机服务和访问连接", async () => {
|
|
149
173
|
await relay?.tunnel?.close().catch(() => { });
|
|
150
174
|
await runtime?.close().catch(() => { });
|
|
151
|
-
})
|
|
175
|
+
});
|
|
152
176
|
return closePromise;
|
|
153
177
|
};
|
|
154
178
|
const requestClose = () => void close();
|
|
155
179
|
process.once("SIGINT", requestClose);
|
|
156
180
|
process.once("SIGTERM", requestClose);
|
|
157
181
|
try {
|
|
158
|
-
releaseStartup = await lockInstanceStartup(options.dataDir);
|
|
159
|
-
await stopPreviousInstances(options.dataDir, console.log);
|
|
182
|
+
releaseStartup = await withCliProgress("正在等待启动锁", () => lockInstanceStartup(options.dataDir));
|
|
183
|
+
await withCliProgress("正在接管旧实例", () => stopPreviousInstances(options.dataDir, console.log));
|
|
160
184
|
if (controller.signal.aborted)
|
|
161
185
|
return;
|
|
162
186
|
await serviceReporter?.starting();
|
|
163
187
|
reportedStarting = Boolean(serviceReporter);
|
|
164
|
-
runtime = await new RuntimeManager().start(options, target);
|
|
188
|
+
runtime = await withCliProgress("正在启动本机服务", () => new RuntimeManager().start(options, target));
|
|
165
189
|
if (controller.signal.aborted) {
|
|
166
190
|
await runtime.close();
|
|
167
191
|
return;
|
|
@@ -172,18 +196,19 @@ export async function main(argv) {
|
|
|
172
196
|
if (!serviceReporter)
|
|
173
197
|
printLocalDesktopUrl(runtime.localUrl);
|
|
174
198
|
const lanUrl = options.localOnly ? findLanUrl(runtime.port) ?? runtime.localUrl : runtime.localUrl;
|
|
175
|
-
|
|
199
|
+
const relayRuntime = runtime;
|
|
200
|
+
relay = await withCliProgress("正在建立手机访问连接", () => selectRelay({
|
|
176
201
|
cli: options,
|
|
177
202
|
target,
|
|
178
|
-
localUrl:
|
|
203
|
+
localUrl: relayRuntime.localUrl,
|
|
179
204
|
lanUrl,
|
|
180
|
-
port:
|
|
205
|
+
port: relayRuntime.port,
|
|
181
206
|
signal: controller.signal,
|
|
182
207
|
log: (line) => process.stderr.write(`${line}\n`),
|
|
183
208
|
onAttempt: (provider) => console.log(`▲ Trying ${providerDisplayName(provider)} relay...`),
|
|
184
209
|
onFailure: (provider, message) => console.error(`⚠ ${providerDisplayName(provider)} unavailable: ${message}`),
|
|
185
210
|
allowLanFallback: options.localOnly,
|
|
186
|
-
});
|
|
211
|
+
}));
|
|
187
212
|
if (controller.signal.aborted) {
|
|
188
213
|
await relay.tunnel?.close();
|
|
189
214
|
return;
|
|
@@ -255,7 +280,7 @@ async function doctor(target, dataDir) {
|
|
|
255
280
|
["better-sqlite3", probeSQLiteRuntime],
|
|
256
281
|
]) {
|
|
257
282
|
try {
|
|
258
|
-
await probe(runtimeRequire);
|
|
283
|
+
await withCliProgress(`正在检查 ${module}`, () => probe(runtimeRequire));
|
|
259
284
|
console.log(`✓ ${module}`);
|
|
260
285
|
}
|
|
261
286
|
catch (error) {
|
|
@@ -270,18 +295,18 @@ async function doctor(target, dataDir) {
|
|
|
270
295
|
reportDoctorFailure(error, "agent-tui: ");
|
|
271
296
|
}
|
|
272
297
|
try {
|
|
273
|
-
const codex = await resolveCodexRuntime({
|
|
298
|
+
const codex = await withCliProgress("正在检查和准备 Codex", () => resolveCodexRuntime({
|
|
274
299
|
dataDir,
|
|
275
300
|
target,
|
|
276
301
|
onProgress: (message) => console.log(`… ${message}`),
|
|
277
|
-
});
|
|
302
|
+
}));
|
|
278
303
|
console.log(`✓ Codex ${codex.version} (${codex.source}) ${codex.executable}`);
|
|
279
304
|
}
|
|
280
305
|
catch (error) {
|
|
281
306
|
reportDoctorFailure(error, "Codex: ");
|
|
282
307
|
}
|
|
283
308
|
try {
|
|
284
|
-
const executable = await ensureCloudflared(target, dataDir);
|
|
309
|
+
const executable = await withCliProgress("正在检查和准备 Cloudflare 组件", () => ensureCloudflared(target, dataDir));
|
|
285
310
|
console.log(`✓ cloudflared ${executable}`);
|
|
286
311
|
}
|
|
287
312
|
catch (error) {
|
|
@@ -4,6 +4,7 @@ import { ReadStream, WriteStream } from "node:tty";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { runCodexDesktopCommand } from "./codex-desktop-command.js";
|
|
6
6
|
import { inspectCodexDesktop } from "./codex-desktop-status.js";
|
|
7
|
+
import { withoutCliProgress } from "../bin/cli-progress.mjs";
|
|
7
8
|
export async function setupCodexDesktop(options, context = {}) {
|
|
8
9
|
if ((context.platform ?? process.platform) !== "darwin")
|
|
9
10
|
return;
|
|
@@ -39,7 +40,7 @@ export async function setupCodexDesktop(options, context = {}) {
|
|
|
39
40
|
? `官方 Codex 桌面端当前有 ${snapshot.activity.activeCount} 个运行中的会话。`
|
|
40
41
|
: "无法可靠确认官方 Codex 桌面端的全部任务已结束。";
|
|
41
42
|
log(reason);
|
|
42
|
-
const answer = await (context.confirm ?? confirmDesktopRestart)("现在重启桌面端以共享会话?重启可能中断桌面端的所有运行任务;选择保留则暂不能共享原桌面会话 [y/N]:");
|
|
43
|
+
const answer = await withoutCliProgress(() => (context.confirm ?? confirmDesktopRestart)("现在重启桌面端以共享会话?重启可能中断桌面端的所有运行任务;选择保留则暂不能共享原桌面会话 [y/N]:"));
|
|
43
44
|
if (!answer || !/^(?:y|yes)$/i.test(answer.trim())) {
|
|
44
45
|
deferred();
|
|
45
46
|
return;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { resolveApprovalInput, terminalText } from "./device-pairing.js";
|
|
7
|
+
import { localServiceRequest } from "./local-service-request.js";
|
|
8
|
+
import { withCliProgress, withoutCliProgress } from "../bin/cli-progress.mjs";
|
|
9
|
+
const runFile = promisify(execFile);
|
|
10
|
+
const endpoint = "/api/remote-authorization";
|
|
11
|
+
async function requestAuthorization(dataDir, body) {
|
|
12
|
+
const status = await localServiceRequest(dataDir, endpoint, {
|
|
13
|
+
method: body ? "POST" : "GET", body, timeoutMs: body?.action === "request-permission" ? 135_000 : 30_000,
|
|
14
|
+
});
|
|
15
|
+
if (status.local !== true || typeof status.supported !== "boolean" || typeof status.installed !== "boolean"
|
|
16
|
+
|| typeof status.enabled !== "boolean" || typeof status.screen !== "boolean" || typeof status.accessibility !== "boolean"
|
|
17
|
+
|| typeof status.online !== "boolean" || typeof status.platform !== "string"
|
|
18
|
+
|| (status.error !== null && typeof status.error !== "string")) {
|
|
19
|
+
throw new Error("本机远程授权状态无效,请升级 CLI 后重试");
|
|
20
|
+
}
|
|
21
|
+
return status;
|
|
22
|
+
}
|
|
23
|
+
/** A native macOS dialog; no browser, shell interpolation, or piped stdin. */
|
|
24
|
+
export async function confirmDesktopAuthorization(platform) {
|
|
25
|
+
if (platform === "darwin") {
|
|
26
|
+
const script = 'display dialog "是否启用 AgentRoam 远程桌面?\n接下来将请求屏幕录制和辅助功能权限。授权后,已配对的设备可以查看和控制这台电脑。" with title "AgentRoam 远程桌面授权" buttons {"稍后", "开始授权"} default button "开始授权" cancel button "稍后" giving up after 120';
|
|
27
|
+
try {
|
|
28
|
+
const { stdout } = await runFile("/usr/bin/osascript", ["-e", script], { timeout: 125_000, maxBuffer: 4096 });
|
|
29
|
+
return stdout.includes("button returned:开始授权") && !stdout.includes("gave up:true");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const input = resolveApprovalInput({ interactive: true });
|
|
36
|
+
if (!input)
|
|
37
|
+
return false;
|
|
38
|
+
const reader = createInterface({ input, output: process.stdout });
|
|
39
|
+
try {
|
|
40
|
+
const answer = await withoutCliProgress(() => reader.question("启用远程桌面,允许已配对设备查看和控制这台电脑?[y/N]:"));
|
|
41
|
+
return /^(y|yes)$/i.test(answer.trim());
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
reader.close();
|
|
45
|
+
if (input !== process.stdin && input instanceof Readable)
|
|
46
|
+
input.destroy();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function recoveryHint(log) {
|
|
50
|
+
log("稍后重新授权:agentroam desktop authorize;也可在本机浏览器的“远程授权”面板中操作。");
|
|
51
|
+
}
|
|
52
|
+
function complete(status) {
|
|
53
|
+
return status.enabled && status.screen && status.accessibility && !status.error;
|
|
54
|
+
}
|
|
55
|
+
/** Request permissions without enabling sharing, then verify before activation. */
|
|
56
|
+
export async function authorizeRemoteDesktop(dataDir, context = {}) {
|
|
57
|
+
const log = context.log ?? console.log;
|
|
58
|
+
if (!(context.isTTY ?? process.stdout.isTTY === true)) {
|
|
59
|
+
log("未检测到交互终端,已跳过远程桌面授权。");
|
|
60
|
+
recoveryHint(log);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const request = context.request ?? requestAuthorization;
|
|
64
|
+
let status = await withCliProgress("正在检查本机远程桌面权限", () => request(dataDir));
|
|
65
|
+
if (!status.supported)
|
|
66
|
+
throw new Error("远程桌面支持 macOS 14 及以上的 Apple Silicon 电脑和 Windows 10/11 x64");
|
|
67
|
+
if (!status.installed)
|
|
68
|
+
throw new Error("当前 CLI 未包含远程授权组件,请升级 CLI");
|
|
69
|
+
if (status.enabled) {
|
|
70
|
+
status = await request(dataDir, { action: "recheck" });
|
|
71
|
+
if (complete(status)) {
|
|
72
|
+
log("✓ 远程桌面已授权并开启共享。");
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const platform = context.platform ?? process.platform;
|
|
77
|
+
const confirm = context.confirm ?? (() => confirmDesktopAuthorization(platform));
|
|
78
|
+
const confirmed = await (platform === "darwin"
|
|
79
|
+
? withCliProgress("正在等待本机授权弹窗确认", confirm)
|
|
80
|
+
: withoutCliProgress(confirm));
|
|
81
|
+
if (!confirmed) {
|
|
82
|
+
log("已跳过远程桌面授权。");
|
|
83
|
+
recoveryHint(log);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
status = await request(dataDir, { action: "recheck" });
|
|
87
|
+
const wait = context.wait ?? delay;
|
|
88
|
+
if (platform === "darwin") {
|
|
89
|
+
for (const permission of ["screen", "accessibility"]) {
|
|
90
|
+
if (status[permission])
|
|
91
|
+
continue;
|
|
92
|
+
const name = permission === "screen" ? "屏幕录制" : "辅助功能";
|
|
93
|
+
log(`正在请求${name}权限,请在系统设置中允许 AgentRoam Remote Desktop…`);
|
|
94
|
+
status = await withCliProgress(`正在等待${name}权限,请在系统弹窗或设置中允许`, async () => {
|
|
95
|
+
let current = await request(dataDir, { action: "request-permission", permission });
|
|
96
|
+
for (let attempt = 0; !current[permission] && attempt < (context.pollAttempts ?? 60); attempt++) {
|
|
97
|
+
await wait(2000);
|
|
98
|
+
current = await request(dataDir, { action: "recheck" });
|
|
99
|
+
}
|
|
100
|
+
return current;
|
|
101
|
+
});
|
|
102
|
+
if (!status[permission]) {
|
|
103
|
+
log(`${name}权限尚未授予,本次未开启共享。`);
|
|
104
|
+
recoveryHint(log);
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!status.screen || !status.accessibility) {
|
|
110
|
+
log("远程桌面尚不可用,请确认系统权限及当前桌面状态,本次未开启共享。");
|
|
111
|
+
recoveryHint(log);
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
status = await withCliProgress("正在开启并检查远程桌面共享", () => request(dataDir, { action: "enable" }));
|
|
115
|
+
if (!complete(status))
|
|
116
|
+
throw new Error(status.error ? terminalText(status.error) : "远程桌面共享未就绪,请重新检查系统权限");
|
|
117
|
+
log("✓ 远程桌面授权完成,已开启共享。手机首次连接仍需设备配对。");
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
export async function runDesktopCommand(options, context = {}) {
|
|
121
|
+
const log = context.log ?? console.log;
|
|
122
|
+
const request = context.request ?? requestAuthorization;
|
|
123
|
+
if (options.desktopAction === "authorize") {
|
|
124
|
+
try {
|
|
125
|
+
if (!await authorizeRemoteDesktop(options.dataDir, context))
|
|
126
|
+
process.exitCode = 1;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
recoveryHint(log);
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
let status = await request(options.dataDir);
|
|
135
|
+
if (options.desktopAction === "disable") {
|
|
136
|
+
status = await request(options.dataDir, { action: "disable" });
|
|
137
|
+
if (status.enabled)
|
|
138
|
+
throw new Error("远程桌面共享尚未关闭,请重试");
|
|
139
|
+
log("✓ 已关闭远程桌面共享,系统权限保留。重新开启:agentroam desktop authorize");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (status.supported && status.installed)
|
|
143
|
+
status = await request(options.dataDir, { action: "recheck" });
|
|
144
|
+
log(`远程桌面:${status.supported ? "支持" : "不支持"};共享:${status.enabled ? "已开启" : "未开启"};屏幕录制:${status.screen ? "已授权" : "未授权或桌面不可用"};辅助功能:${status.accessibility ? "已授权" : "未授权"}`);
|
|
145
|
+
if (status.error)
|
|
146
|
+
log(`状态提示:${terminalText(status.error)}`);
|
|
147
|
+
recoveryHint(log);
|
|
148
|
+
}
|
package/dist/device-pairing.js
CHANGED
|
@@ -1,49 +1,26 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { setTimeout as delay } from "node:timers/promises";
|
|
3
3
|
import { createReadStream, openSync } from "node:fs";
|
|
4
|
-
import {
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join, resolve } from "node:path";
|
|
7
7
|
import { renderQr } from "./qr.js";
|
|
8
|
+
import { localServiceRequest, LocalServiceHttpError } from "./local-service-request.js";
|
|
9
|
+
import { withCliProgress, withoutCliProgress } from "../bin/cli-progress.mjs";
|
|
8
10
|
/** Resolve a private local descriptor; never send the administrative token to a tunnel URL. */
|
|
9
11
|
export async function pairingAdmin(dataDir, action, body, options = {}) {
|
|
10
|
-
const request = options.request ?? fetch;
|
|
11
|
-
const directory = options.registry ?? process.env.AGENTROAM_DISCOVERY_DIR ?? join(homedir(), ".agentroam", "services");
|
|
12
|
-
const names = await readdir(directory).catch((error) => { if (error.code === "ENOENT")
|
|
13
|
-
return []; throw error; });
|
|
14
|
-
const candidates = [];
|
|
15
|
-
for (const name of names.filter((name) => name.endsWith(".json"))) {
|
|
16
|
-
try {
|
|
17
|
-
const d = JSON.parse(await readFile(join(directory, name), "utf8"));
|
|
18
|
-
const url = new URL(d.url);
|
|
19
|
-
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))
|
|
20
|
-
continue;
|
|
21
|
-
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/" || url.username || url.password || url.search || url.hash)
|
|
22
|
-
continue;
|
|
23
|
-
const response = await request(new URL("/api/desktop/identity", url), { headers: { "x-agentroam-desktop-token": d.token }, redirect: "error", signal: AbortSignal.timeout(1500) });
|
|
24
|
-
if (!response.ok)
|
|
25
|
-
continue;
|
|
26
|
-
const identity = await response.json();
|
|
27
|
-
if (identity.protocol === 1 && identity.instanceId === d.instanceId && identity.dataDir === d.dataDir)
|
|
28
|
-
candidates.push(d);
|
|
29
|
-
}
|
|
30
|
-
catch { /* Stale descriptors are expected after a process crash. */ }
|
|
31
|
-
}
|
|
32
|
-
if (candidates.length !== 1)
|
|
33
|
-
throw new Error(candidates.length ? "多个服务使用此数据目录,请先停止多余实例" : "未找到运行中的 AgentRoam 服务,请先启动服务或指定 --data-dir");
|
|
34
|
-
const d = candidates[0];
|
|
35
12
|
const readOnly = ["devices", "requests", "audit"].includes(action);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
13
|
+
try {
|
|
14
|
+
return await localServiceRequest(dataDir, `/api/pairing/admin/${action}`, {
|
|
15
|
+
...options, method: readOnly ? "GET" : "POST", ...(readOnly ? {} : { body: body ?? {} }),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (!(error instanceof LocalServiceHttpError))
|
|
20
|
+
throw error;
|
|
21
|
+
const message = error.status === 423 ? "远程访问已锁定,请先执行 agentroam unlock" : error.status === 410 ? "请求已过期或已处理" : error.status === 409 ? "核对短语不匹配" : `设备授权操作失败 (HTTP ${error.status})`;
|
|
44
22
|
throw new Error(message);
|
|
45
23
|
}
|
|
46
|
-
return response.json();
|
|
47
24
|
}
|
|
48
25
|
export function terminalText(value) { return value.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ""); }
|
|
49
26
|
export function pairingQrPayload(accessUrl, grant) {
|
|
@@ -53,7 +30,7 @@ export function pairingQrPayload(accessUrl, grant) {
|
|
|
53
30
|
return `${url.origin}/pair#pair=${grant}`;
|
|
54
31
|
}
|
|
55
32
|
export async function printPairingCode(dataDir, log = console.log, options = {}) {
|
|
56
|
-
const result = await pairingAdmin(dataDir, "code");
|
|
33
|
+
const result = await withCliProgress("正在生成设备配对码", () => pairingAdmin(dataDir, "code"));
|
|
57
34
|
log(`配对码:${result.code.slice(0, 4)} ${result.code.slice(4)}`);
|
|
58
35
|
log("5 分钟内有效,仅可使用一次。手机输入后仍需在电脑上确认核对短语。");
|
|
59
36
|
if ((options.qr ?? true) && (options.interactive ?? process.stdout.isTTY)) {
|
|
@@ -61,16 +38,19 @@ export async function printPairingCode(dataDir, log = console.log, options = {})
|
|
|
61
38
|
if (accessUrl) {
|
|
62
39
|
// Validate the public address before minting an invitation. Never persist the grant.
|
|
63
40
|
pairingQrPayload(accessUrl, "a".repeat(43));
|
|
64
|
-
const
|
|
41
|
+
const qr = await withCliProgress("正在生成手机连接二维码", async () => {
|
|
42
|
+
const { grant } = await pairingAdmin(dataDir, "qr");
|
|
43
|
+
return renderQr(pairingQrPayload(accessUrl, grant));
|
|
44
|
+
});
|
|
65
45
|
log("手机扫码直接连接(5 分钟、一次有效,请勿分享截图):");
|
|
66
|
-
log(
|
|
46
|
+
log(qr);
|
|
67
47
|
}
|
|
68
48
|
else
|
|
69
49
|
log("生成 App 授权二维码:agentroam pair --url <手机可访问的服务器地址>");
|
|
70
50
|
}
|
|
71
51
|
const approvalInput = resolveApprovalInput(options);
|
|
72
52
|
if (approvalInput) {
|
|
73
|
-
await watchPairingApproval(dataDir, result.expiresAt, { log, signal: options.signal, input: approvalInput });
|
|
53
|
+
await withCliProgress("正在等待手机扫码或输入配对码", () => watchPairingApproval(dataDir, result.expiresAt, { log, signal: options.signal, input: approvalInput }));
|
|
74
54
|
}
|
|
75
55
|
else
|
|
76
56
|
log("查看请求:agentroam approvals;批准:agentroam approve <请求ID> --phrase <手机核对短语>");
|
|
@@ -127,7 +107,7 @@ export async function watchPairingApproval(dataDir, codeExpiresAt, options = {})
|
|
|
127
107
|
log(`请求 ID:${request.id}`);
|
|
128
108
|
log(`核对短语:${request.phrase}`);
|
|
129
109
|
const confirm = options.confirm ?? ((request, signal) => confirmOnTerminal(request, signal, options.input));
|
|
130
|
-
const approved = await confirm(request, options.signal);
|
|
110
|
+
const approved = await withoutCliProgress(() => confirm(request, options.signal));
|
|
131
111
|
if (options.signal?.aborted)
|
|
132
112
|
return;
|
|
133
113
|
if (Date.now() >= request.expires) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readdir, readFile, mkdir, writeFile, rm, realpath } from "node:fs/promises";
|
|
1
|
+
import { readdir, readFile, mkdir, writeFile, rm, realpath, rename } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { dirname, join, resolve } from "node:path";
|
|
4
5
|
import { setTimeout as delay } from "node:timers/promises";
|
|
@@ -68,8 +69,11 @@ export async function stopPreviousInstances(dataDir, log, dependencies = {}) {
|
|
|
68
69
|
return []; throw error; });
|
|
69
70
|
for (const name of names.filter(name => name.endsWith(".json"))) {
|
|
70
71
|
let d;
|
|
72
|
+
let source;
|
|
73
|
+
const recordPath = join(registry, name);
|
|
71
74
|
try {
|
|
72
|
-
|
|
75
|
+
source = await readFile(recordPath, "utf8");
|
|
76
|
+
d = JSON.parse(source);
|
|
73
77
|
if (d.protocol !== 1 || !Number.isSafeInteger(d.pid) || d.pid <= 1 || typeof d.instanceId !== "string" || !/^[a-f0-9]{64}$/.test(d.token))
|
|
74
78
|
continue;
|
|
75
79
|
const actual = await realpath(d.dataDir);
|
|
@@ -90,8 +94,25 @@ export async function stopPreviousInstances(dataDir, log, dependencies = {}) {
|
|
|
90
94
|
identity = await response.json();
|
|
91
95
|
}
|
|
92
96
|
catch {
|
|
93
|
-
if ((dependencies.alive ?? alive)(d.pid))
|
|
94
|
-
|
|
97
|
+
if ((dependencies.alive ?? alive)(d.pid)) {
|
|
98
|
+
const processes = dependencies.processes ?? processList;
|
|
99
|
+
const owner = (await processes()).find(p => p.pid === d.pid);
|
|
100
|
+
// An unavailable gateway may still own live sessions. Only quarantine
|
|
101
|
+
// when the PID demonstrably belongs to a different kind of process.
|
|
102
|
+
if (!owner?.command.trim() || /[\\/]ws-server\.mjs(?:["\s]|$)|[\\/]agentroam[\\/]bin[\\/]agentroam\.mjs(?:["\s]|$)/.test(owner.command)) {
|
|
103
|
+
throw new Error("同一数据目录的旧实例无法核验,请先停止旧实例再重试");
|
|
104
|
+
}
|
|
105
|
+
const current = (await processes()).find(p => p.pid === d.pid);
|
|
106
|
+
if (!current || current.ppid !== owner.ppid || current.command !== owner.command) {
|
|
107
|
+
throw new Error("旧实例身份已变化,请重试");
|
|
108
|
+
}
|
|
109
|
+
if (await readFile(recordPath, "utf8") !== source)
|
|
110
|
+
throw new Error("旧实例记录已变化,请重试");
|
|
111
|
+
const backupDir = join(registry, "stale");
|
|
112
|
+
await mkdir(backupDir, { recursive: true, mode: 0o700 });
|
|
113
|
+
await rename(recordPath, join(backupDir, `${randomUUID()}-${name}`));
|
|
114
|
+
log("检测到旧服务记录的 PID 已被其他进程复用,已备份失效记录,继续启动。");
|
|
115
|
+
}
|
|
95
116
|
continue;
|
|
96
117
|
}
|
|
97
118
|
if (identity.protocol !== 1 || identity.instanceId !== d.instanceId || identity.dataDir !== d.dataDir)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export function printLocalDesktopUrl(localUrl, log = console.log, platform = process.platform) {
|
|
2
2
|
log(`远程桌面授权地址:${localUrl.replace(/\/$/, '')}/web`);
|
|
3
|
-
log('
|
|
3
|
+
log('重新授权:agentroam desktop authorize;浏览器备用入口:在这台电脑打开上述地址,配对后点击“远程授权”(盾牌图标)。');
|
|
4
4
|
if (platform === 'win32')
|
|
5
5
|
log('点击“开启共享”。请保持 Windows 已登录且未锁屏;暂不支持 UAC 安全桌面和管理员窗口控制。');
|
|
6
6
|
else
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
export class LocalServiceHttpError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
constructor(status) {
|
|
7
|
+
super(`本机服务请求失败 (HTTP ${status})`);
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Resolve an identity-checked loopback service; never send its token to a tunnel. */
|
|
12
|
+
export async function localServiceRequest(dataDir, path, options = {}) {
|
|
13
|
+
if (!path.startsWith("/api/"))
|
|
14
|
+
throw new Error("无效的本机服务路径");
|
|
15
|
+
const request = options.request ?? fetch;
|
|
16
|
+
const directory = options.registry ?? process.env.AGENTROAM_DISCOVERY_DIR ?? join(homedir(), ".agentroam", "services");
|
|
17
|
+
const names = await readdir(directory).catch((error) => { if (error.code === "ENOENT")
|
|
18
|
+
return []; throw error; });
|
|
19
|
+
const candidates = [];
|
|
20
|
+
for (const name of names.filter((name) => name.endsWith(".json"))) {
|
|
21
|
+
try {
|
|
22
|
+
const d = JSON.parse(await readFile(join(directory, name), "utf8"));
|
|
23
|
+
const url = new URL(d.url);
|
|
24
|
+
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))
|
|
25
|
+
continue;
|
|
26
|
+
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/" || url.username || url.password || url.search || url.hash)
|
|
27
|
+
continue;
|
|
28
|
+
const response = await request(new URL("/api/desktop/identity", url), { headers: { "x-agentroam-desktop-token": d.token }, redirect: "error", signal: AbortSignal.timeout(1500) });
|
|
29
|
+
if (!response.ok)
|
|
30
|
+
continue;
|
|
31
|
+
const identity = await response.json();
|
|
32
|
+
if (identity.protocol === 1 && identity.instanceId === d.instanceId && identity.dataDir === d.dataDir)
|
|
33
|
+
candidates.push(d);
|
|
34
|
+
}
|
|
35
|
+
catch { /* Stale descriptors are expected after a process crash. */ }
|
|
36
|
+
}
|
|
37
|
+
if (candidates.length !== 1)
|
|
38
|
+
throw new Error(candidates.length ? "多个服务使用此数据目录,请先停止多余实例" : "未找到运行中的 AgentRoam 服务,请先启动服务或指定 --data-dir");
|
|
39
|
+
const d = candidates[0];
|
|
40
|
+
const url = new URL(path, d.url);
|
|
41
|
+
if (url.origin !== new URL(d.url).origin)
|
|
42
|
+
throw new Error("无效的本机服务路径");
|
|
43
|
+
const response = await request(url, {
|
|
44
|
+
method: options.method ?? "GET",
|
|
45
|
+
headers: { "x-agentroam-desktop-token": d.token, "content-type": "application/json", origin: new URL(d.url).origin },
|
|
46
|
+
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
|
47
|
+
redirect: "error", signal: AbortSignal.timeout(options.timeoutMs ?? 5000),
|
|
48
|
+
});
|
|
49
|
+
if (!response.ok)
|
|
50
|
+
throw new LocalServiceHttpError(response.status);
|
|
51
|
+
return response.json();
|
|
52
|
+
}
|
package/dist/runtime-manager.js
CHANGED
|
@@ -7,6 +7,7 @@ import { resolveOpenCodeRuntime, } from "./opencode-runtime-manager.js";
|
|
|
7
7
|
import { resolvePlatformRuntime } from "./platform-packages.js";
|
|
8
8
|
import { findAvailablePort } from "./port.js";
|
|
9
9
|
import { ProcessSupervisor } from "./process-supervisor.js";
|
|
10
|
+
import { withCliProgress } from "../bin/cli-progress.mjs";
|
|
10
11
|
export class RuntimeManager {
|
|
11
12
|
supervisor;
|
|
12
13
|
codexResolver;
|
|
@@ -22,8 +23,8 @@ export class RuntimeManager {
|
|
|
22
23
|
const port = await findAvailablePort(options.port);
|
|
23
24
|
const dataDir = resolve(options.dataDir);
|
|
24
25
|
await Promise.all(["data", "bin", "cache", "logs"].map((name) => mkdir(resolve(dataDir, name), { recursive: true })));
|
|
25
|
-
const codexEnvironment = await prepareCodexRuntimeEnvironment(process.env, { dataDir, target }, this.codexResolver, this.report);
|
|
26
|
-
const childEnvironment = await prepareOpenCodeRuntimeEnvironment(codexEnvironment, { dataDir, target }, this.opencodeResolver, this.report);
|
|
26
|
+
const codexEnvironment = await withCliProgress("正在准备 Codex 组件", () => prepareCodexRuntimeEnvironment(process.env, { dataDir, target }, this.codexResolver, this.report));
|
|
27
|
+
const childEnvironment = await withCliProgress("正在准备 OpenCode 组件", () => prepareOpenCodeRuntimeEnvironment(codexEnvironment, { dataDir, target }, this.opencodeResolver, this.report));
|
|
27
28
|
const { runtimeRoot } = resolvePlatformRuntime(target);
|
|
28
29
|
await repairNativeRuntimePermissions(runtimeRoot);
|
|
29
30
|
const gateway = resolve(runtimeRoot, "ws-server.mjs");
|
|
@@ -53,7 +54,7 @@ export class RuntimeManager {
|
|
|
53
54
|
child.once("exit", (code, signal) => resolveExit({ code, signal }));
|
|
54
55
|
});
|
|
55
56
|
const localUrl = `http://127.0.0.1:${port}`;
|
|
56
|
-
const status = await waitForHealth(`${localUrl}/api/web-auth/status`, child, 20_000);
|
|
57
|
+
const status = await withCliProgress("正在等待本机服务就绪", () => waitForHealth(`${localUrl}/api/web-auth/status`, child, 20_000));
|
|
57
58
|
return {
|
|
58
59
|
port,
|
|
59
60
|
localUrl,
|
|
@@ -60,13 +60,13 @@ export class MacLaunchAgent {
|
|
|
60
60
|
const status = await this.status();
|
|
61
61
|
if (!status.installed)
|
|
62
62
|
throw new Error("AgentRoam service is not installed");
|
|
63
|
+
const paths = resolveServicePaths(this.homeDir, status.config?.dataDir);
|
|
63
64
|
if (status.running)
|
|
64
|
-
return
|
|
65
|
+
return this.waitForReady(paths);
|
|
65
66
|
if (status.loaded)
|
|
66
67
|
await this.runRequired("launchctl", ["kickstart", this.serviceTarget]);
|
|
67
68
|
else
|
|
68
69
|
await this.bootstrap(status.definition);
|
|
69
|
-
const paths = resolveServicePaths(this.homeDir, status.config?.dataDir);
|
|
70
70
|
return this.waitForReady(paths, status.state?.pid);
|
|
71
71
|
}
|
|
72
72
|
async stop() {
|
|
@@ -84,13 +84,14 @@ export class MacLaunchAgent {
|
|
|
84
84
|
const config = await readServiceConfig(basePaths);
|
|
85
85
|
const paths = resolveServicePaths(this.homeDir, config?.dataDir);
|
|
86
86
|
const installed = await pathExists(paths.plistPath);
|
|
87
|
-
const job = installed ? await this.inspectJob() : { loaded: false, running: false };
|
|
87
|
+
const job = installed ? await this.inspectJob() : { loaded: false, running: false, pid: undefined };
|
|
88
|
+
const state = await readServiceState(paths);
|
|
88
89
|
return {
|
|
89
90
|
installed,
|
|
90
91
|
loaded: job.loaded,
|
|
91
92
|
running: job.running,
|
|
92
93
|
config,
|
|
93
|
-
state:
|
|
94
|
+
state: job.running && state?.pid !== job.pid ? null : state,
|
|
94
95
|
definition: paths.plistPath,
|
|
95
96
|
};
|
|
96
97
|
}
|
|
@@ -157,9 +158,11 @@ export class MacLaunchAgent {
|
|
|
157
158
|
}
|
|
158
159
|
async inspectJob() {
|
|
159
160
|
const result = await this.runner("launchctl", ["print", this.serviceTarget]);
|
|
161
|
+
const pid = /\bpid\s*=\s*(\d+)\b/.exec(result.stdout);
|
|
160
162
|
return {
|
|
161
163
|
loaded: result.code === 0,
|
|
162
164
|
running: result.code === 0 && /\bstate\s*=\s*running\b/.test(result.stdout),
|
|
165
|
+
pid: pid ? Number(pid[1]) : undefined,
|
|
163
166
|
};
|
|
164
167
|
}
|
|
165
168
|
async bootstrap(definition) {
|
|
@@ -195,10 +198,13 @@ export class MacLaunchAgent {
|
|
|
195
198
|
const deadline = Date.now() + this.readyTimeoutMs;
|
|
196
199
|
do {
|
|
197
200
|
const state = await readServiceState(paths);
|
|
198
|
-
if (state?.status === "ready" && (!previousPid || state.pid !== previousPid))
|
|
199
|
-
|
|
201
|
+
if (state?.status === "ready" && state.accessUrl && (!previousPid || state.pid !== previousPid)) {
|
|
202
|
+
const job = await this.inspectJob();
|
|
203
|
+
if (job.running && job.pid === state.pid)
|
|
204
|
+
return state;
|
|
205
|
+
}
|
|
200
206
|
if (Date.now() >= deadline)
|
|
201
|
-
return state;
|
|
207
|
+
return state?.status === "ready" ? null : state;
|
|
202
208
|
await delay(this.pollIntervalMs);
|
|
203
209
|
} while (true);
|
|
204
210
|
}
|
|
@@ -9,6 +9,8 @@ import { WindowsTaskService } from "./windows-task-service.js";
|
|
|
9
9
|
import { assertSupportedNodeVersion } from "../../bin/runtime-policy.mjs";
|
|
10
10
|
import { cleanupOldLaunchers } from "./launcher-cleanup.js";
|
|
11
11
|
import { setupCodexDesktop } from "../codex-desktop-setup.js";
|
|
12
|
+
import { authorizeRemoteDesktop } from "../desktop-authorization.js";
|
|
13
|
+
import { withCliProgress } from "../../bin/cli-progress.mjs";
|
|
12
14
|
export async function runServiceCommand(options, context) {
|
|
13
15
|
const platform = context.platform ?? process.platform;
|
|
14
16
|
const log = context.log ?? console.log;
|
|
@@ -30,14 +32,14 @@ export async function runServiceCommand(options, context) {
|
|
|
30
32
|
const environmentPath = buildServiceEnvironmentPath(context.nodePath, environment.PATH ?? environment.Path, platform);
|
|
31
33
|
let codexPath;
|
|
32
34
|
try {
|
|
33
|
-
const codex = await (context.codexResolver ?? resolveCodexRuntime)({
|
|
35
|
+
const codex = await withCliProgress("正在检查和安装 Codex 组件", () => (context.codexResolver ?? resolveCodexRuntime)({
|
|
34
36
|
dataDir: options.dataDir,
|
|
35
37
|
target: detectPlatform(platform, context.arch ?? process.arch, nodeVersion),
|
|
36
38
|
environment: { ...environment, PATH: environmentPath },
|
|
37
39
|
platform,
|
|
38
40
|
nodeExecutable: context.nodePath,
|
|
39
41
|
onProgress: (message) => log(`… ${message}`),
|
|
40
|
-
});
|
|
42
|
+
}));
|
|
41
43
|
codexPath = codex.executable;
|
|
42
44
|
}
|
|
43
45
|
catch (error) {
|
|
@@ -58,36 +60,47 @@ export async function runServiceCommand(options, context) {
|
|
|
58
60
|
dataDir: options.dataDir,
|
|
59
61
|
installedAt: now.toISOString(),
|
|
60
62
|
};
|
|
61
|
-
const { definition, state } = await controller.install(config);
|
|
63
|
+
const { definition, state } = await withCliProgress("正在安装后台服务,等待服务和手机访问连接就绪", () => controller.install(config));
|
|
62
64
|
log(`✓ AgentRoam service installed: ${definition}`);
|
|
65
|
+
if (state?.localUrl && state.status !== "stopped")
|
|
66
|
+
printLocalDesktopUrl(state.localUrl, log, platform);
|
|
67
|
+
requireReadyService(state);
|
|
68
|
+
if (state?.localUrl && state.status !== "stopped" && (platform === "darwin" || platform === "win32")) {
|
|
69
|
+
if (environment.AGENTROAM_INSTALL_REMOTE_DESKTOP === "skip") {
|
|
70
|
+
log("已跳过安装时远程桌面授权,可稍后执行 agentroam desktop authorize。");
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
try {
|
|
74
|
+
await (context.desktopAuthorization ?? authorizeRemoteDesktop)(options.dataDir, { platform, isTTY: stdoutIsTTY, log });
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
log(`远程桌面授权未完成:${error instanceof Error ? error.message : error}`);
|
|
78
|
+
log("AgentRoam 安装继续;可稍后执行 agentroam desktop authorize,或在本机浏览器的“远程授权”面板中重试。");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
63
82
|
if (platform === "darwin" && !options.desktopSetup)
|
|
64
83
|
log("官方 Codex 桌面共享后端:agentroam codex-desktop --setup;预演:agentroam codex-desktop --dry-run。");
|
|
65
|
-
if (state
|
|
84
|
+
if (state.version === config.version)
|
|
66
85
|
await cleanupOldLaunchers(config, log);
|
|
67
|
-
if (state?.localUrl && state.status !== "stopped")
|
|
68
|
-
printLocalDesktopUrl(state.localUrl, log, platform);
|
|
69
86
|
if (options.desktopSetup && platform === "darwin") {
|
|
70
|
-
await (context.desktopSetup ?? setupCodexDesktop)(options, { platform, environment, service: config, log });
|
|
71
|
-
}
|
|
72
|
-
if (state?.status === "ready" && state.accessUrl) {
|
|
73
|
-
log(`Open: ${state.accessUrl}`);
|
|
74
|
-
await (context.pairingPrinter ?? printPairingCode)(options.dataDir, log, { accessUrl: state.accessUrl, qr: options.qr, interactive: stdoutIsTTY });
|
|
87
|
+
await withCliProgress("正在配置官方 Codex 桌面共享连接", () => (context.desktopSetup ?? setupCodexDesktop)(options, { platform, environment, service: config, log }));
|
|
75
88
|
}
|
|
76
|
-
|
|
77
|
-
|
|
89
|
+
log(`Open: ${state.accessUrl}`);
|
|
90
|
+
await (context.pairingPrinter ?? printPairingCode)(options.dataDir, log, { accessUrl: state.accessUrl, qr: options.qr, interactive: stdoutIsTTY });
|
|
78
91
|
return;
|
|
79
92
|
}
|
|
80
93
|
case "start": {
|
|
81
|
-
const state = await controller.start();
|
|
82
|
-
log("✓ AgentRoam service started");
|
|
83
|
-
if (state?.status === "ready" && state.accessUrl)
|
|
84
|
-
await logAccessUrl(state.accessUrl);
|
|
94
|
+
const state = await withCliProgress("正在启动后台服务,等待服务和手机访问连接就绪", () => controller.start());
|
|
85
95
|
if (state?.localUrl && state.status !== "stopped")
|
|
86
96
|
printLocalDesktopUrl(state.localUrl, log, platform);
|
|
97
|
+
requireReadyService(state);
|
|
98
|
+
log("✓ AgentRoam service started");
|
|
99
|
+
await logAccessUrl(state.accessUrl);
|
|
87
100
|
return;
|
|
88
101
|
}
|
|
89
102
|
case "stop":
|
|
90
|
-
await controller.stop();
|
|
103
|
+
await withCliProgress("正在停止后台服务", () => controller.stop());
|
|
91
104
|
log("✓ AgentRoam service stopped");
|
|
92
105
|
return;
|
|
93
106
|
case "status": {
|
|
@@ -122,12 +135,12 @@ export async function runServiceCommand(options, context) {
|
|
|
122
135
|
return;
|
|
123
136
|
}
|
|
124
137
|
case "restart": {
|
|
125
|
-
const state = await controller.restart();
|
|
126
|
-
log("✓ AgentRoam service restarted");
|
|
127
|
-
if (state?.status === "ready" && state.accessUrl)
|
|
128
|
-
await logAccessUrl(state.accessUrl);
|
|
138
|
+
const state = await withCliProgress("正在重启后台服务,等待服务和手机访问连接就绪", () => controller.restart());
|
|
129
139
|
if (state?.localUrl && state.status !== "stopped")
|
|
130
140
|
printLocalDesktopUrl(state.localUrl, log, platform);
|
|
141
|
+
requireReadyService(state);
|
|
142
|
+
log("✓ AgentRoam service restarted");
|
|
143
|
+
await logAccessUrl(state.accessUrl);
|
|
131
144
|
return;
|
|
132
145
|
}
|
|
133
146
|
case "uninstall": {
|
|
@@ -140,6 +153,11 @@ export async function runServiceCommand(options, context) {
|
|
|
140
153
|
throw cliError("service action is required");
|
|
141
154
|
}
|
|
142
155
|
}
|
|
156
|
+
function requireReadyService(state) {
|
|
157
|
+
if (state?.status !== "ready" || !state.accessUrl) {
|
|
158
|
+
throw Object.assign(new Error(`AgentRoam 后台服务未就绪(${state?.status ?? "未取得状态"})。请运行 agentroam service logs 查看原因,修复后运行 agentroam service start,再执行 agentroam pair。`), { exitCode: 1 });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
143
161
|
export function buildServiceEnvironmentPath(nodePath, pathValue, platform) {
|
|
144
162
|
const separator = platform === "win32" ? ";" : delimiter;
|
|
145
163
|
const nodeDirectory = platform === "win32" ? win32.dirname(nodePath) : dirname(nodePath);
|
|
@@ -55,9 +55,9 @@ export class WindowsTaskService {
|
|
|
55
55
|
const status = await this.status();
|
|
56
56
|
if (!status.installed)
|
|
57
57
|
throw new Error("AgentRoam service is not installed");
|
|
58
|
-
if (status.running)
|
|
59
|
-
return status.state;
|
|
60
58
|
const paths = resolveServicePaths(this.homeDir, status.config?.dataDir);
|
|
59
|
+
if (status.running)
|
|
60
|
+
return this.waitForReady(paths);
|
|
61
61
|
await Promise.all([removeIfExists(paths.statePath), removeIfExists(paths.urlPath)]);
|
|
62
62
|
await this.runRequired(buildStartTaskScript());
|
|
63
63
|
return this.waitForReady(paths, status.state?.pid);
|
|
@@ -88,12 +88,13 @@ export class WindowsTaskService {
|
|
|
88
88
|
const config = await readServiceConfig(basePaths);
|
|
89
89
|
const paths = resolveServicePaths(this.homeDir, config?.dataDir);
|
|
90
90
|
const task = await this.inspectTask();
|
|
91
|
+
const state = await readServiceState(paths);
|
|
91
92
|
return {
|
|
92
93
|
installed: task.installed,
|
|
93
94
|
loaded: task.installed,
|
|
94
95
|
running: task.running,
|
|
95
96
|
config,
|
|
96
|
-
state:
|
|
97
|
+
state: task.running && state && !this.processExists(state.pid) ? null : state,
|
|
97
98
|
definition: WINDOWS_SERVICE_NAME,
|
|
98
99
|
};
|
|
99
100
|
}
|
|
@@ -185,10 +186,13 @@ export class WindowsTaskService {
|
|
|
185
186
|
const deadline = Date.now() + this.readyTimeoutMs;
|
|
186
187
|
do {
|
|
187
188
|
const state = await readServiceState(paths);
|
|
188
|
-
if (state?.status === "ready" && (!previousPid || state.pid !== previousPid))
|
|
189
|
-
|
|
189
|
+
if (state?.status === "ready" && state.accessUrl && (!previousPid || state.pid !== previousPid)) {
|
|
190
|
+
const task = await this.inspectTask();
|
|
191
|
+
if (task.running && this.processExists(state.pid))
|
|
192
|
+
return state;
|
|
193
|
+
}
|
|
190
194
|
if (Date.now() >= deadline)
|
|
191
|
-
return state;
|
|
195
|
+
return state?.status === "ready" ? null : state;
|
|
192
196
|
await delay(this.pollIntervalMs);
|
|
193
197
|
} while (true);
|
|
194
198
|
}
|
package/dist/unlock-service.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
const SERVICE_NAME = "AgentRoamUnlock";
|
|
4
4
|
const DISPLAY_NAME = "AgentRoam Remote Unlock";
|
|
5
|
+
// Keep the event loop free while PowerShell waits for UAC or service control.
|
|
6
|
+
const runFile = (file, args, options) => new Promise((resolveResult) => {
|
|
7
|
+
execFile(file, [...args], options, (error, stdout, stderr) => {
|
|
8
|
+
resolveResult({ stdout, stderr, status: error ? 1 : 0,
|
|
9
|
+
...(error && typeof error.code !== "number" ? { error } : {}),
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
});
|
|
5
13
|
function psQuote(value) {
|
|
6
14
|
return `'${value.replaceAll("'", "''")}'`;
|
|
7
15
|
}
|
|
@@ -59,36 +67,36 @@ function runPowerShell(script, runner) {
|
|
|
59
67
|
"-EncodedCommand", encodePowerShell(script),
|
|
60
68
|
], { encoding: "utf8", windowsHide: true });
|
|
61
69
|
}
|
|
62
|
-
export function queryUnlockServiceStatus(runner =
|
|
70
|
+
export async function queryUnlockServiceStatus(runner = runFile) {
|
|
63
71
|
const script = `$service = Get-Service -Name '${SERVICE_NAME}' -ErrorAction SilentlyContinue
|
|
64
72
|
if (-not $service) { Write-Output 'missing' } elseif ($service.Status -eq 'Running') { Write-Output 'running' } else { Write-Output 'stopped' }
|
|
65
73
|
`;
|
|
66
|
-
const result = runPowerShell(script, runner);
|
|
74
|
+
const result = await runPowerShell(script, runner);
|
|
67
75
|
if (result.error)
|
|
68
76
|
throw result.error;
|
|
69
77
|
if (result.status !== 0)
|
|
70
78
|
throw new Error((result.stderr || "远程解锁服务状态查询失败").trim());
|
|
71
79
|
return parseUnlockServiceStatus(result.stdout || "");
|
|
72
80
|
}
|
|
73
|
-
export function runUnlockServiceCommand(action, runtimeRoot, options = {}) {
|
|
81
|
+
export async function runUnlockServiceCommand(action, runtimeRoot, options = {}) {
|
|
74
82
|
const platform = options.platform ?? process.platform;
|
|
75
|
-
const runner = options.runner ??
|
|
83
|
+
const runner = options.runner ?? runFile;
|
|
76
84
|
const log = options.log ?? console.log;
|
|
77
85
|
if (platform !== "win32")
|
|
78
86
|
throw new Error("远程解锁服务仅支持 Windows 10/11 x64");
|
|
79
87
|
if (action === "status") {
|
|
80
|
-
const status = queryUnlockServiceStatus(runner);
|
|
88
|
+
const status = await queryUnlockServiceStatus(runner);
|
|
81
89
|
log(status === "running" ? "远程解锁服务正在运行" : status === "stopped" ? "远程解锁服务已安装但未运行" : "远程解锁服务未安装");
|
|
82
90
|
return;
|
|
83
91
|
}
|
|
84
92
|
const executable = unlockServiceExecutable(runtimeRoot);
|
|
85
|
-
const result = runPowerShell(buildElevatedPowerShell(buildUnlockServiceAdminScript(action, executable)), runner);
|
|
93
|
+
const result = await runPowerShell(buildElevatedPowerShell(buildUnlockServiceAdminScript(action, executable)), runner);
|
|
86
94
|
if (result.error)
|
|
87
95
|
throw result.error;
|
|
88
96
|
if (result.status !== 0) {
|
|
89
97
|
throw new Error((result.stderr || (action === "install" ? "远程解锁服务安装失败或已取消管理员授权" : "远程解锁服务卸载失败或已取消管理员授权")).trim());
|
|
90
98
|
}
|
|
91
|
-
const status = queryUnlockServiceStatus(runner);
|
|
99
|
+
const status = await queryUnlockServiceStatus(runner);
|
|
92
100
|
if (action === "install" && status !== "running")
|
|
93
101
|
throw new Error("远程解锁服务安装后未能启动");
|
|
94
102
|
if (action === "uninstall" && status !== "missing")
|
|
@@ -232,7 +232,7 @@ if ($env:AGENTROAM_INSTALL_SKIP_SERVICE -eq "1") {
|
|
|
232
232
|
Write-Host "AgentRoam service registration skipped for isolated verification."
|
|
233
233
|
} else {
|
|
234
234
|
& $NodeBin $Entry service install --root $ServiceRoot --data-dir $DataDir
|
|
235
|
-
if ($LASTEXITCODE -ne 0) { throw "
|
|
235
|
+
if ($LASTEXITCODE -ne 0) { throw "CLI 已安装,但后台服务安装或启动失败(exit $LASTEXITCODE),尚不能扫码连接。请运行 agentroam service logs 查看原因,修复后运行 agentroam service start,再执行 agentroam pair。" }
|
|
236
236
|
}
|
|
237
237
|
Write-Host "`nAgentRoam $AgentRoamVersion installed: $WrapperPath"
|
|
238
238
|
Write-Host "Open a new terminal, then run: agentroam service status"
|
|
@@ -28,6 +28,47 @@ fi
|
|
|
28
28
|
TEMP_ROOT=""
|
|
29
29
|
OWN_NODE_LOCK=0
|
|
30
30
|
OWN_LAUNCHER_LOCK=0
|
|
31
|
+
PROGRESS_PID=""
|
|
32
|
+
|
|
33
|
+
# Works before Node exists and never reads stdin (curl | sh owns it).
|
|
34
|
+
stop_progress() {
|
|
35
|
+
if [ -n "$PROGRESS_PID" ]; then
|
|
36
|
+
kill "$PROGRESS_PID" 2>/dev/null || true
|
|
37
|
+
wait "$PROGRESS_PID" 2>/dev/null || true
|
|
38
|
+
PROGRESS_PID=""
|
|
39
|
+
fi
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
start_progress() {
|
|
43
|
+
stop_progress
|
|
44
|
+
[ "${AGENTROAM_NO_PROGRESS:-}" != "1" ] || return 0
|
|
45
|
+
printf '… %s(已等待 0 秒)\n' "$1" >&2
|
|
46
|
+
(
|
|
47
|
+
progress_label=$1
|
|
48
|
+
progress_started=$(date +%s)
|
|
49
|
+
progress_sleep_pid=""
|
|
50
|
+
trap 'if [ -n "$progress_sleep_pid" ]; then kill "$progress_sleep_pid" 2>/dev/null || true; wait "$progress_sleep_pid" 2>/dev/null || true; fi' EXIT
|
|
51
|
+
trap 'exit 0' HUP INT TERM
|
|
52
|
+
while :; do
|
|
53
|
+
sleep 5 &
|
|
54
|
+
progress_sleep_pid=$!
|
|
55
|
+
wait "$progress_sleep_pid" || exit 0
|
|
56
|
+
progress_sleep_pid=""
|
|
57
|
+
progress_now=$(date +%s)
|
|
58
|
+
printf '… %s(已等待 %s 秒)\n' "$progress_label" "$((progress_now - progress_started))" >&2
|
|
59
|
+
done
|
|
60
|
+
) </dev/null &
|
|
61
|
+
PROGRESS_PID=$!
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
run_with_progress() {
|
|
65
|
+
progress_command_label=$1
|
|
66
|
+
shift
|
|
67
|
+
start_progress "$progress_command_label"
|
|
68
|
+
if "$@"; then progress_command_status=0; else progress_command_status=$?; fi
|
|
69
|
+
stop_progress
|
|
70
|
+
return "$progress_command_status"
|
|
71
|
+
}
|
|
31
72
|
|
|
32
73
|
fail() {
|
|
33
74
|
printf 'agentroam installer: %s\n' "$*" >&2
|
|
@@ -35,6 +76,7 @@ fail() {
|
|
|
35
76
|
}
|
|
36
77
|
|
|
37
78
|
cleanup() {
|
|
79
|
+
stop_progress
|
|
38
80
|
if [ -n "$TEMP_ROOT" ] && [ -d "$TEMP_ROOT" ]; then
|
|
39
81
|
rm -rf "$TEMP_ROOT"
|
|
40
82
|
fi
|
|
@@ -121,6 +163,7 @@ acquire_directory_lock() {
|
|
|
121
163
|
done
|
|
122
164
|
}
|
|
123
165
|
|
|
166
|
+
start_progress "正在检查可用的 Node.js"
|
|
124
167
|
SYSTEM_NODE=$(command -v node 2>/dev/null || true)
|
|
125
168
|
if [ "${AGENTROAM_BOOTSTRAP_TEST:-}" = "1" ] && [ "${AGENTROAM_FORCE_PRIVATE_NODE:-}" = "1" ]; then
|
|
126
169
|
SYSTEM_NODE=""
|
|
@@ -138,7 +181,9 @@ else
|
|
|
138
181
|
printf 'Using Node.js %s from NVM: %s\n' "$("$NODE_BIN" --version)" "$NODE_BIN"
|
|
139
182
|
else
|
|
140
183
|
if ! runtime_is_valid; then
|
|
184
|
+
start_progress "正在等待 Node.js 安装锁"
|
|
141
185
|
acquire_directory_lock "$NODE_LOCK"
|
|
186
|
+
stop_progress
|
|
142
187
|
OWN_NODE_LOCK=1
|
|
143
188
|
if ! runtime_is_valid; then
|
|
144
189
|
TEMP_ROOT=$(mktemp -d "$NODE_PARENT/.node-$NODE_VERSION.XXXXXX")
|
|
@@ -146,6 +191,7 @@ else
|
|
|
146
191
|
extract_path="$TEMP_ROOT/extract"
|
|
147
192
|
mkdir "$extract_path"
|
|
148
193
|
printf 'Downloading Node.js %s...\n' "$NODE_VERSION"
|
|
194
|
+
start_progress "正在下载 Node.js $NODE_VERSION"
|
|
149
195
|
if [ -n "${AGENTROAM_NODE_ARCHIVE_FILE:-}" ]; then
|
|
150
196
|
cp "$AGENTROAM_NODE_ARCHIVE_FILE" "$archive_path"
|
|
151
197
|
else
|
|
@@ -156,8 +202,10 @@ else
|
|
|
156
202
|
sleep "$download_attempt"
|
|
157
203
|
done
|
|
158
204
|
fi
|
|
205
|
+
start_progress "正在校验 Node.js 安装包"
|
|
159
206
|
actual_sha=$(shasum -a 256 "$archive_path" | awk '{print $1}')
|
|
160
207
|
[ "$actual_sha" = "$NODE_SHA256" ] || fail "Node.js archive checksum mismatch"
|
|
208
|
+
start_progress "正在解压并验证 Node.js"
|
|
161
209
|
/usr/bin/tar -xJf "$archive_path" -C "$extract_path"
|
|
162
210
|
entry_count=$(find "$extract_path" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')
|
|
163
211
|
[ "$entry_count" = "1" ] || fail "unexpected Node.js archive layout"
|
|
@@ -177,6 +225,7 @@ else
|
|
|
177
225
|
NODE_BIN="$NODE_ROOT/bin/node"
|
|
178
226
|
fi
|
|
179
227
|
fi
|
|
228
|
+
stop_progress
|
|
180
229
|
|
|
181
230
|
if [ "${AGENTROAM_BOOTSTRAP_TEST:-}" = "1" ] && [ "${AGENTROAM_BOOTSTRAP_NODE_DISCOVERY_ONLY:-}" = "1" ]; then
|
|
182
231
|
printf '%s\n' "$NODE_BIN"
|
|
@@ -205,7 +254,9 @@ case "$AGENTROAM_VERSION_REQUEST" in
|
|
|
205
254
|
*)
|
|
206
255
|
# A dist-tag (default "preview") — resolve to the newest published version
|
|
207
256
|
# so installs always pick up the latest release without touching this file.
|
|
257
|
+
start_progress "正在查询 AgentRoam $AGENTROAM_VERSION_REQUEST 最新版本"
|
|
208
258
|
resolved_version="$("$NODE_BIN" "$NPM_CLI" view "agentroam@$AGENTROAM_VERSION_REQUEST" version --registry "$NPM_REGISTRY" 2>/dev/null | tail -1 | tr -d '[:space:]')"
|
|
259
|
+
stop_progress
|
|
209
260
|
[ -n "$resolved_version" ] || fail "could not resolve agentroam@$AGENTROAM_VERSION_REQUEST from $NPM_REGISTRY"
|
|
210
261
|
AGENTROAM_VERSION="$resolved_version"
|
|
211
262
|
printf 'Installing AgentRoam %s (resolved from %s)\n' "$AGENTROAM_VERSION" "$AGENTROAM_VERSION_REQUEST"
|
|
@@ -225,14 +276,18 @@ launcher_is_valid() {
|
|
|
225
276
|
&& [ "$("$NODE_BIN" "$LAUNCHER_ROOT/node_modules/agentroam/bin/agentroam.mjs" version 2>/dev/null | awk '{print $2}' || true)" = "$AGENTROAM_VERSION" ]
|
|
226
277
|
}
|
|
227
278
|
|
|
279
|
+
start_progress "正在检查已安装的 CLI"
|
|
228
280
|
if ! launcher_is_valid; then
|
|
281
|
+
start_progress "正在等待 CLI 安装锁"
|
|
229
282
|
acquire_directory_lock "$LAUNCHER_LOCK"
|
|
283
|
+
stop_progress
|
|
230
284
|
OWN_LAUNCHER_LOCK=1
|
|
231
285
|
if ! launcher_is_valid; then
|
|
232
286
|
TEMP_ROOT=$(mktemp -d "$LAUNCHER_PARENT/.launcher-$AGENTROAM_VERSION.XXXXXX")
|
|
233
287
|
# Test-only extra specs are whitespace-delimited artifact paths in isolated CI directories.
|
|
234
288
|
# shellcheck disable=SC2086
|
|
235
|
-
"$NODE_BIN" "$NPM_CLI" install --no-audit --no-fund --registry "$NPM_REGISTRY" --prefix "$TEMP_ROOT" $EXTRA_PACKAGE_SPECS "$PACKAGE_SPEC"
|
|
289
|
+
run_with_progress "正在下载和安装 CLI 及平台组件" "$NODE_BIN" "$NPM_CLI" install --no-audit --no-fund --registry "$NPM_REGISTRY" --prefix "$TEMP_ROOT" $EXTRA_PACKAGE_SPECS "$PACKAGE_SPEC"
|
|
290
|
+
start_progress "正在验证并启用 CLI"
|
|
236
291
|
entry="$TEMP_ROOT/node_modules/agentroam/bin/agentroam.mjs"
|
|
237
292
|
[ -f "$entry" ] || fail "AgentRoam launcher is missing after npm install"
|
|
238
293
|
[ "$("$NODE_BIN" "$entry" version | awk '{print $2}')" = "$AGENTROAM_VERSION" ] || fail "AgentRoam version validation failed"
|
|
@@ -243,6 +298,7 @@ if ! launcher_is_valid; then
|
|
|
243
298
|
rmdir "$LAUNCHER_LOCK" 2>/dev/null || true
|
|
244
299
|
OWN_LAUNCHER_LOCK=0
|
|
245
300
|
fi
|
|
301
|
+
stop_progress
|
|
246
302
|
|
|
247
303
|
entry="$LAUNCHER_ROOT/node_modules/agentroam/bin/agentroam.mjs"
|
|
248
304
|
wrapper_temp=$(mktemp "$WRAPPER_DIR/.agentroam.XXXXXX")
|
|
@@ -253,18 +309,26 @@ wrapper_temp=$(mktemp "$WRAPPER_DIR/.agentroam.XXXXXX")
|
|
|
253
309
|
chmod 755 "$wrapper_temp"
|
|
254
310
|
mv "$wrapper_temp" "$WRAPPER_PATH"
|
|
255
311
|
|
|
256
|
-
if ! "$NODE_BIN" "$entry" doctor --data-dir "$DATA_DIR"; then
|
|
312
|
+
if ! run_with_progress "正在检查运行组件" env AGENTROAM_NO_PROGRESS=1 "$NODE_BIN" "$entry" doctor --data-dir "$DATA_DIR"; then
|
|
257
313
|
printf 'Warning: some component checks failed; continuing background service installation. Affected features may be unavailable. Run agentroam doctor to retry.\n' >&2
|
|
258
314
|
fi
|
|
259
315
|
if [ "${AGENTROAM_INSTALL_SKIP_SERVICE:-}" = "1" ]; then
|
|
260
316
|
printf 'AgentRoam service registration skipped for isolated verification.\n'
|
|
261
317
|
else
|
|
318
|
+
# The CLI owns progress here so it can pause for confirmations and print QR intact.
|
|
319
|
+
printf '… 正在安装后台服务;随后检查远程桌面授权并生成手机连接二维码\n' >&2
|
|
262
320
|
codex_setup_module="$(dirname "$entry")/../dist/codex-desktop-setup.js"
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
321
|
+
install_background_service() {
|
|
322
|
+
if [ -f "$codex_setup_module" ]; then
|
|
323
|
+
"$NODE_BIN" "$entry" service install --root "$SERVICE_ROOT" --data-dir "$DATA_DIR" --setup-codex-desktop
|
|
324
|
+
else
|
|
325
|
+
printf '当前发布的 CLI 尚未包含官方 Codex 桌面共享配置;扫码可连接 AgentRoam,原桌面会话暂未共享,请升级 CLI 后重试。\n'
|
|
326
|
+
"$NODE_BIN" "$entry" service install --root "$SERVICE_ROOT" --data-dir "$DATA_DIR"
|
|
327
|
+
fi
|
|
328
|
+
}
|
|
329
|
+
if ! install_background_service; then
|
|
330
|
+
printf 'CLI 已安装,但后台服务安装或启动失败,尚不能扫码连接。请运行 agentroam service logs 查看原因,修复后运行 agentroam service start,再执行 agentroam pair。\n' >&2
|
|
331
|
+
exit 1
|
|
268
332
|
fi
|
|
269
333
|
fi
|
|
270
334
|
printf '\nAgentRoam %s installed: %s\n' "$AGENTROAM_VERSION" "$WRAPPER_PATH"
|
|
@@ -288,7 +352,7 @@ case "$desktop_choice" in
|
|
|
288
352
|
y|Y|yes|YES|是)
|
|
289
353
|
desktop_helper="$LAUNCHER_ROOT/node_modules/agentroam/bin/desktop-download.mjs"
|
|
290
354
|
if [ -f "$desktop_helper" ]; then
|
|
291
|
-
"$NODE_BIN" "$desktop_helper" "$AGENTROAM_VERSION" || printf 'CLI 安装已完成,桌面端可稍后重新下载。\n'
|
|
355
|
+
run_with_progress "正在下载并校验桌面端安装包" env AGENTROAM_NO_PROGRESS=1 "$NODE_BIN" "$desktop_helper" "$AGENTROAM_VERSION" || printf 'CLI 安装已完成,桌面端可稍后重新下载。\n'
|
|
292
356
|
else
|
|
293
357
|
printf '当前发布的 CLI 尚未包含桌面下载功能,请在新版发布后重试。CLI 安装已完成。\n'
|
|
294
358
|
fi
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
"installers": {
|
|
4
4
|
"darwin-arm64": {
|
|
5
5
|
"fileName": "install-agentroam.sh",
|
|
6
|
-
"sha256": "
|
|
7
|
-
"size":
|
|
6
|
+
"sha256": "9fad4cb6528e0b71224c40c4eece94881a67191b165ccb673b980498b8646487",
|
|
7
|
+
"size": 14575
|
|
8
8
|
},
|
|
9
9
|
"windows-amd64": {
|
|
10
10
|
"fileName": "install-agentroam.ps1",
|
|
11
|
-
"sha256": "
|
|
12
|
-
"size":
|
|
11
|
+
"sha256": "5df75c9a139997cfee382ef824216b92bf4a68eba679fbc5be01102019ce1652",
|
|
12
|
+
"size": 13309
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
}
|
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.39",
|
|
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.39",
|
|
30
|
+
"agentroam-runtime-win32-x64": "0.2.0-preview.39",
|
|
31
|
+
"agentroam-cloudflared-darwin-arm64": "0.2.0-preview.39",
|
|
32
|
+
"agentroam-cloudflared-win32-x64": "0.2.0-preview.39",
|
|
33
|
+
"agentroam-tui-darwin-arm64": "0.2.0-preview.39",
|
|
34
|
+
"@caoqu/agentroam-tui-win32-x64": "0.2.0-preview.39"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/node": "^22.0.0",
|