alink-cli 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/bin/agentlink.js +268 -29
  2. package/dist/daemon.js +20369 -385
  3. package/package.json +8 -3
package/bin/agentlink.js CHANGED
@@ -16,7 +16,8 @@
16
16
  // 上的 token 只需出现一次。al1. 多租户凭证的第四段(E2EE 主密钥)也一并存进这
17
17
  // 个文件,从不上网;daemon 另会把它单独归档进 enckeys.json 供 E2EE 层使用。
18
18
 
19
- import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { spawn } from "node:child_process";
20
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
20
21
  import { homedir } from "node:os";
21
22
  import { dirname, join } from "node:path";
22
23
  import { fileURLToPath } from "node:url";
@@ -37,15 +38,15 @@ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
37
38
  }
38
39
 
39
40
  // 判断某个具名参数是否已由用户给出(同时认 `--name value` 与 `--name=value`)。
40
- function hasFlag(name) {
41
- return rawArgs.some((a) => a === `--${name}` || a.startsWith(`--${name}=`));
41
+ function hasFlag(name, args = rawArgs) {
42
+ return args.some((a) => a === `--${name}` || a.startsWith(`--${name}=`));
42
43
  }
43
44
 
44
45
  // 读取具名参数的值(用于取出用户显式给的 --token 整串)。
45
- function flagValue(name) {
46
- for (let i = 0; i < rawArgs.length; i++) {
47
- const a = rawArgs[i];
48
- if (a === `--${name}`) return rawArgs[i + 1];
46
+ function flagValue(name, args = rawArgs) {
47
+ for (let i = 0; i < args.length; i++) {
48
+ const a = args[i];
49
+ if (a === `--${name}`) return args[i + 1];
49
50
  if (a.startsWith(`--${name}=`)) return a.slice(name.length + 3);
50
51
  }
51
52
  return undefined;
@@ -90,24 +91,254 @@ if (rawArgs[0] === "qr") {
90
91
  process.exit(0);
91
92
  }
92
93
 
93
- // --tunnel 模式下 daemon 会自起本地 hub,且与 --hub 互斥,所以此时绝不注入 --hub。
94
+ // ---------------------------------------------------------------------
95
+ // 后台管理子命令:start / stop / status / logs / restart。
96
+ // `start` 把 daemon 挂到后台(detached,日志写 ~/.agentlink/daemon.log,进程
97
+ // 元数据写 daemon.json),其余子命令围着这两个文件 + daemon 自己维护的
98
+ // status.json 转。裸跑仍是前台——首次扫码配对必须能看见终端里的二维码。
99
+
100
+ const DAEMON_JSON = join(STATE_DIR, "daemon.json");
101
+ const DAEMON_LOG = join(STATE_DIR, "daemon.log");
102
+ const STATUS_JSON = join(STATE_DIR, "status.json");
103
+
104
+ if (["start", "stop", "status", "logs", "restart"].includes(rawArgs[0])) {
105
+ const [cmd, ...rest] = rawArgs;
106
+ if (cmd === "start") await ctlStart(rest);
107
+ else if (cmd === "stop") await ctlStop();
108
+ else if (cmd === "status") ctlStatus();
109
+ else if (cmd === "logs") await ctlLogs(rest);
110
+ else await ctlRestart(rest);
111
+ process.exit(0);
112
+ }
113
+
114
+ function readJsonFile(path) {
115
+ try {
116
+ return JSON.parse(readFileSync(path, "utf-8"));
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ function pidAlive(pid) {
123
+ try {
124
+ process.kill(pid, 0);
125
+ return true;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ // start 记录的后台进程(daemon.json)——pid 还活着才算数。
132
+ function runningDaemon() {
133
+ const meta = readJsonFile(DAEMON_JSON);
134
+ return meta && typeof meta.pid === "number" && pidAlive(meta.pid) ? meta : null;
135
+ }
136
+
137
+ function fmtUptime(ms) {
138
+ const s = Math.max(0, Math.floor(ms / 1000));
139
+ const h = Math.floor(s / 3600);
140
+ const m = Math.floor((s % 3600) / 60);
141
+ return h > 0 ? `${h}h${m}m` : m > 0 ? `${m}m${s % 60}s` : `${s}s`;
142
+ }
143
+
144
+ function sleep(ms) {
145
+ return new Promise((r) => setTimeout(r, ms));
146
+ }
147
+
148
+ async function ctlStart(args) {
149
+ if (hasFlag("tunnel", args)) {
150
+ console.error("[agentlink] --tunnel 模式带着 cloudflared 子进程,请前台运行(不支持 start)。");
151
+ process.exit(1);
152
+ }
153
+ if (hasFlag("pair", args)) {
154
+ console.error("[agentlink] 配对需要扫终端里的二维码,请先前台跑一次 `npx alink-cli` 完成绑定,再 start。");
155
+ process.exit(1);
156
+ }
157
+ const already = runningDaemon();
158
+ if (already) {
159
+ console.error(`[agentlink] 已在后台运行 (pid ${already.pid})。要重启用 alink-cli restart。`);
160
+ process.exit(1);
161
+ }
162
+ // 凭证前置检查:后台进程没法扫码配对(二维码只会写进日志文件),所以
163
+ // 无凭证 + 官方 hub 直接拒绝,指引先前台配对。
164
+ const hubArg = flagValue("hub", args);
165
+ const hasCredential = hasFlag("token", args) || process.env.AGENTLINK_TOKEN || readCredentialFile();
166
+ if (!hasCredential && (!hubArg || hubArg === OFFICIAL_HUB)) {
167
+ console.error("[agentlink] 这台电脑还没绑定。先前台跑一次 `npx alink-cli`,用手机扫码完成配对,再 start。");
168
+ process.exit(1);
169
+ }
170
+
171
+ // 每次 start 换新日志(旧日志随进程一起翻篇),元数据记下参数供 restart 复用。
172
+ mkdirSync(STATE_DIR, { recursive: true });
173
+ writeFileSync(DAEMON_LOG, "");
174
+ const logFd = openSync(DAEMON_LOG, "a");
175
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...args], {
176
+ detached: true,
177
+ stdio: ["ignore", logFd, logFd],
178
+ env: process.env,
179
+ });
180
+ child.unref();
181
+ writeFileSync(DAEMON_JSON, JSON.stringify({ pid: child.pid, args, startedAt: Date.now() }));
182
+
183
+ // 等到 daemon 报告连上(status.json state=connected)再回话;起不来就把
184
+ // 日志尾巴打出来。连不上但还在重试(网络/hub 抖动)不算失败。
185
+ const deadline = Date.now() + 12_000;
186
+ for (;;) {
187
+ await sleep(250);
188
+ if (!pidAlive(child.pid)) {
189
+ console.error("[agentlink] 后台 daemon 启动即退出。日志尾部:");
190
+ printLogTail(30);
191
+ rmSync(DAEMON_JSON, { force: true });
192
+ process.exit(1);
193
+ }
194
+ const st = readJsonFile(STATUS_JSON);
195
+ if (st && st.pid === child.pid && st.state === "connected") {
196
+ console.log(`[agentlink] 已在后台启动并连上 hub (pid ${child.pid})。`);
197
+ console.log(`[agentlink] 日志: ${DAEMON_LOG}`);
198
+ console.log(`[agentlink] 常用命令: alink-cli status / logs -f / stop`);
199
+ return;
200
+ }
201
+ if (Date.now() > deadline) {
202
+ console.log(`[agentlink] 已在后台启动 (pid ${child.pid}),但还没连上 hub——会按退避自动重试。`);
203
+ console.log(`[agentlink] 用 alink-cli status 或 alink-cli logs -f 观察进展。`);
204
+ return;
205
+ }
206
+ }
207
+ }
208
+
209
+ async function ctlStop() {
210
+ const meta = runningDaemon();
211
+ if (!meta) {
212
+ rmSync(DAEMON_JSON, { force: true }); // 清掉可能残留的陈旧记录
213
+ console.log("[agentlink] 没有在后台运行的 daemon。");
214
+ return;
215
+ }
216
+ process.kill(meta.pid, "SIGTERM");
217
+ for (let i = 0; i < 20 && pidAlive(meta.pid); i++) await sleep(250);
218
+ if (pidAlive(meta.pid)) {
219
+ console.error(`[agentlink] pid ${meta.pid} 不理会 SIGTERM,改发 SIGKILL。`);
220
+ process.kill(meta.pid, "SIGKILL");
221
+ for (let i = 0; i < 8 && pidAlive(meta.pid); i++) await sleep(250);
222
+ }
223
+ rmSync(DAEMON_JSON, { force: true });
224
+ console.log(`[agentlink] 已停止 (pid ${meta.pid})。`);
225
+ }
226
+
227
+ function ctlStatus() {
228
+ const meta = runningDaemon();
229
+ const st = readJsonFile(STATUS_JSON);
230
+ // 不是 start 管的,但 status.json 里的 pid 还活着 → 前台/别处跑着的 daemon。
231
+ if (!meta && st && typeof st.pid === "number" && pidAlive(st.pid)) {
232
+ console.log(`● 运行中 (pid ${st.pid},非后台托管——大概率是前台在跑)`);
233
+ printStatusDetail(st);
234
+ return;
235
+ }
236
+ if (!meta) {
237
+ console.log("● 未运行");
238
+ if (st && st.state === "stopped") console.log(` 上次退出码 ${st.code ?? "?"}`);
239
+ if (existsSync(DAEMON_LOG)) console.log(` 日志: ${DAEMON_LOG}`);
240
+ process.exit(1);
241
+ }
242
+ const stateLabel =
243
+ st && st.pid === meta.pid
244
+ ? { starting: "启动中", pairing: "配对中", connected: "已连接", reconnecting: `重连中(第 ${st.attempt ?? "?"} 次)`, stopped: "已停止" }[st.state] ?? st.state
245
+ : "未知";
246
+ console.log(`● 运行中 (pid ${meta.pid}, 已运行 ${fmtUptime(Date.now() - meta.startedAt)})`);
247
+ console.log(` 状态: ${stateLabel}`);
248
+ printStatusDetail(st && st.pid === meta.pid ? st : null);
249
+ }
250
+
251
+ function printStatusDetail(st) {
252
+ if (st) {
253
+ console.log(` hub: ${st.hub}`);
254
+ if (st.machineId) console.log(` machine: ${st.machineId}`);
255
+ if (st.daemonVersion) console.log(` 版本: ${st.daemonVersion}`);
256
+ }
257
+ console.log(` 日志: ${DAEMON_LOG}`);
258
+ }
259
+
260
+ function printLogTail(n) {
261
+ try {
262
+ const lines = readFileSync(DAEMON_LOG, "utf-8").trimEnd().split("\n");
263
+ for (const line of lines.slice(-n)) console.error(` ${line}`);
264
+ } catch {
265
+ console.error(" (没有日志)");
266
+ }
267
+ }
268
+
269
+ async function ctlLogs(args) {
270
+ const follow = args.includes("-f") || args.includes("--follow");
271
+ const nIdx = args.indexOf("-n");
272
+ const n = nIdx >= 0 ? Number(args[nIdx + 1]) || 50 : 50;
273
+ if (!existsSync(DAEMON_LOG)) {
274
+ console.error(`[agentlink] 还没有日志(${DAEMON_LOG})。先 alink-cli start。`);
275
+ process.exit(1);
276
+ }
277
+ const content = readFileSync(DAEMON_LOG, "utf-8");
278
+ const lines = content.split("\n");
279
+ if (lines[lines.length - 1] === "") lines.pop();
280
+ if (lines.length > 0) process.stdout.write(lines.slice(-n).join("\n") + "\n");
281
+ if (!follow) return;
282
+ // 跟随:轮询文件增量(够用且零依赖;Ctrl+C 退出)。
283
+ let offset = Buffer.byteLength(content);
284
+ for (;;) {
285
+ await sleep(500);
286
+ let size;
287
+ try {
288
+ size = statSync(DAEMON_LOG).size;
289
+ } catch {
290
+ continue; // 日志被轮换/删除,等它回来
291
+ }
292
+ if (size < offset) offset = 0; // start 截断了日志——从头跟
293
+ if (size > offset) {
294
+ const fd = openSync(DAEMON_LOG, "r");
295
+ const buf = Buffer.alloc(size - offset);
296
+ readSync(fd, buf, 0, buf.length, offset);
297
+ closeSync(fd);
298
+ process.stdout.write(buf.toString("utf-8"));
299
+ offset = size;
300
+ }
301
+ }
302
+ }
303
+
304
+ async function ctlRestart(args) {
305
+ const prev = readJsonFile(DAEMON_JSON);
306
+ const startArgs = args.length > 0 ? args : (prev?.args ?? []);
307
+ await ctlStop();
308
+ await ctlStart(startArgs);
309
+ }
310
+
311
+ // `local` 子命令 = 同机驾驶舱:本地起 single 模式 hub + daemon,自动开浏览器,
312
+ // 不遥控。归一成 daemon 认的 --local flag(daemon 参数不收 positional)。既接受
313
+ // `npx alink-cli local`,也接受 `npx alink-cli --local`。
314
+ if (rawArgs[0] === "local") rawArgs[0] = "--local";
315
+
316
+ // --tunnel / --local 模式下 daemon 会自起本地 hub,且与 --hub 互斥,此时绝不注入 --hub。
94
317
  const tunnelMode = hasFlag("tunnel");
318
+ const localMode = hasFlag("local");
95
319
 
96
- // 1) 补 --hub 默认值:官方 hub
97
- if (!hasFlag("hub") && !tunnelMode) {
320
+ // 1) 补 --hub 默认值:官方 hub。本地模式(tunnel/local)自带 localhost hub,跳过。
321
+ if (!hasFlag("hub") && !tunnelMode && !localMode) {
98
322
  rawArgs.push("--hub", OFFICIAL_HUB);
99
323
  }
100
324
 
101
325
  // 2) 解析 token 来源,并决定是否需要“成功连接后落盘”。
102
- // 优先级:命令行 --token > AGENTLINK_TOKEN 环境变量 > 凭证文件。
326
+ // 优先级:显式 --pair(重新配对)> 命令行 --token > AGENTLINK_TOKEN 环境变量
327
+ // > 凭证文件 > (官方 hub)扫码配对。
103
328
  let tokenToPersist; // 仅当用户这次显式给了新 --token 时,连上后写盘
104
329
  const explicitToken = hasFlag("token") ? flagValue("token") : undefined;
105
- if (explicitToken) {
330
+ if (hasFlag("pair")) {
331
+ // 显式 --pair:忽略已存凭证,重新走扫码配对(换账号/重绑)。凭证由 daemon
332
+ // 配对成功后自行落盘;--pair 与 --token 的互斥由 daemon 的参数校验把关。
333
+ } else if (explicitToken) {
106
334
  // 用户显式给了 token——daemon 会直接用 argv 里的 --token。若它和已存凭证不同,
107
335
  // 安排在成功连上 hub 后落盘(避免把连不上的坏 token 也存下来)。
108
336
  if (explicitToken !== readCredentialFile()) tokenToPersist = explicitToken;
109
337
  } else if (process.env.AGENTLINK_TOKEN) {
110
338
  // 环境变量已给——daemon 自己会读 AGENTLINK_TOKEN,这里什么都不用做。
339
+ } else if (localMode) {
340
+ // --local:single 模式本地 hub,daemon 自己现生成一个 UUID token 并打印/开浏览器。
341
+ // 不复用落盘凭证(那多半是官方 al1. 多租户串,喂给本地 single hub 是错的),也不配对。
111
342
  } else {
112
343
  // 命令行和环境变量都没有——回落到落盘凭证(若有),塞进 AGENTLINK_TOKEN 供
113
344
  // daemon 读取。
@@ -115,14 +346,12 @@ if (explicitToken) {
115
346
  if (saved) {
116
347
  process.env.AGENTLINK_TOKEN = saved;
117
348
  } else if (!tunnelMode && (!hasFlag("hub") || flagValue("hub") === OFFICIAL_HUB)) {
118
- // --tunnel 与自建 --hub 不需要预铸凭证:daemon 自己生成 token 并打印链接。
119
- // 什么凭证都没有且连的是官方 hub:直接给指引并退出——否则 daemon 会生成
120
- // 随机 token 去连 multi hub 然后被 4401 拒,报错对新用户完全不可解。
121
- // (自建 --hub 的 single 模式仍保留旧行为:daemon 自己生成 token 并打印。)
122
- console.error("还没有这台电脑的凭证。");
123
- console.error("请在手机 AgentLink App(或电脑控制台)的「添加机器」里生成命令,");
124
- console.error("复制到这里运行:npx alink-cli --token al1.…");
125
- process.exit(1);
349
+ // 什么凭证都没有且连的是官方 hub:进入扫码配对模式——daemon 本地生成
350
+ // E2EE 密钥 + 配对码,终端打二维码,手机 AgentLink「添加机器」扫码即绑定。
351
+ // 密钥只走二维码这条光学通道,不经过网络与服务器。
352
+ // (--tunnel 与自建 --hub 的 single 模式仍保留旧行为:daemon 自己生成
353
+ // token 并打印链接。自建 multi hub 可显式 `--pair --hub wss://…` 配对。)
354
+ rawArgs.push("--pair");
126
355
  }
127
356
  }
128
357
 
@@ -158,28 +387,38 @@ function printHelp() {
158
387
  const self = "alink-cli";
159
388
  process.stdout.write(
160
389
  [
161
- `用法: npx ${self} [--token <al1.…>] [--hub <wss://…>] [--dir <目录>]…`,
390
+ `用法: npx ${self} [--hub <wss://…>] [--dir <目录>]…`,
391
+ ` npx ${self} local [--dir …] # 同机驾驶舱:本地起 hub 并自动开浏览器,不遥控`,
162
392
  ` npx ${self} qr # 终端打印本机凭证二维码(给新手机录密钥)`,
393
+ ` npx ${self} start [--dir …] # 后台运行(先前台配对过一次)`,
394
+ ` npx ${self} status|logs [-f]|stop|restart`,
163
395
  ``,
164
396
  `把当前这台工作机接入 AgentLink,随时随地遥控本机的编码 agent。`,
397
+ `首次裸跑会进入扫码配对:加密密钥在这台电脑上生成,终端打出二维码,`,
398
+ `用手机 AgentLink 的「添加机器」扫一下即绑定——密钥只走二维码,不经过服务器。`,
399
+ `绑定后可用 start 挂到后台(日志 ~/.agentlink/daemon.log),status 看连接`,
400
+ `状态,logs -f 跟日志,stop/restart 停止或重启。`,
165
401
  ``,
166
402
  `参数:`,
167
- ` --token <token> 机器凭证(控制台里“添加机器”生成的 al1.… 整串)。`,
168
- ` 首次成功连上后会存到 ~/.agentlink/credential(0600),`,
169
- ` 之后裸跑 npx ${self} 自动复用。也可用环境变量`,
170
- ` AGENTLINK_TOKEN 提供(--token 优先)。`,
403
+ ` --pair 强制重新扫码配对(换账号 / 重绑这台机器)。`,
404
+ ` --token <token> 直接给机器凭证(al1.… 整串,如从别处迁移)。首次成功连上后`,
405
+ ` 存到 ~/.agentlink/credential(0600),之后裸跑自动复用。`,
406
+ ` 也可用环境变量 AGENTLINK_TOKEN 提供(--token 优先)。`,
171
407
  ` --hub <url> hub 地址,缺省官方 hub ${OFFICIAL_HUB}。`,
172
408
  ` --dir <path> 工作目录根,可重复;缺省当前目录。运行时可用其下任意子目录。`,
173
409
  ` --tunnel 不连任何 hub:本地起 hub + 免费 Cloudflare 隧道并打印公网链接`,
174
410
  ` (需已安装 cloudflared)。与 --hub 互斥。`,
411
+ ` local 同机场景:本地起 single 模式 hub + daemon,自动用浏览器打开`,
412
+ ` 控制台(localhost),把这台电脑上的多个 agent 统一管起来。`,
413
+ ` 不遥控、不联网、无需扫码。--port 可改本地 hub 端口(默认 8080)。`,
175
414
  ` --help, -h 显示本帮助。`,
176
415
  ``,
177
416
  `示例:`,
178
- ` npx ${self} --token al1.xxxxx # 首次接入,token 只需给这一次`,
179
- ` npx ${self} # 之后裸跑,复用已存凭证`,
417
+ ` npx ${self} # 首次:终端出二维码,手机扫码绑定;`,
418
+ ` # 之后裸跑复用已存凭证,直接上线`,
180
419
  ``,
181
- `连接是出站的(走 NAT/防火墙无需端口转发),断线自动重连。al1. 凭证的加密`,
182
- `密钥段只留在本机、从不上网(端到端加密)。`,
420
+ `连接是出站的(走 NAT/防火墙无需端口转发),断线自动重连。加密密钥只留在`,
421
+ `本机和扫码的手机上、从不上网(端到端加密)。`,
183
422
  ``,
184
423
  ].join("\n"),
185
424
  );