alink-cli 0.4.2 → 0.5.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.
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,6 +91,223 @@ if (rawArgs[0] === "qr") {
90
91
  process.exit(0);
91
92
  }
92
93
 
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
+
93
311
  // --tunnel 模式下 daemon 会自起本地 hub,且与 --hub 互斥,所以此时绝不注入 --hub。
94
312
  const tunnelMode = hasFlag("tunnel");
95
313
 
@@ -99,10 +317,14 @@ if (!hasFlag("hub") && !tunnelMode) {
99
317
  }
100
318
 
101
319
  // 2) 解析 token 来源,并决定是否需要“成功连接后落盘”。
102
- // 优先级:命令行 --token > AGENTLINK_TOKEN 环境变量 > 凭证文件。
320
+ // 优先级:显式 --pair(重新配对)> 命令行 --token > AGENTLINK_TOKEN 环境变量
321
+ // > 凭证文件 > (官方 hub)扫码配对。
103
322
  let tokenToPersist; // 仅当用户这次显式给了新 --token 时,连上后写盘
104
323
  const explicitToken = hasFlag("token") ? flagValue("token") : undefined;
105
- if (explicitToken) {
324
+ if (hasFlag("pair")) {
325
+ // 显式 --pair:忽略已存凭证,重新走扫码配对(换账号/重绑)。凭证由 daemon
326
+ // 配对成功后自行落盘;--pair 与 --token 的互斥由 daemon 的参数校验把关。
327
+ } else if (explicitToken) {
106
328
  // 用户显式给了 token——daemon 会直接用 argv 里的 --token。若它和已存凭证不同,
107
329
  // 安排在成功连上 hub 后落盘(避免把连不上的坏 token 也存下来)。
108
330
  if (explicitToken !== readCredentialFile()) tokenToPersist = explicitToken;
@@ -115,14 +337,12 @@ if (explicitToken) {
115
337
  if (saved) {
116
338
  process.env.AGENTLINK_TOKEN = saved;
117
339
  } 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);
340
+ // 什么凭证都没有且连的是官方 hub:进入扫码配对模式——daemon 本地生成
341
+ // E2EE 密钥 + 配对码,终端打二维码,手机 AgentLink「添加机器」扫码即绑定。
342
+ // 密钥只走二维码这条光学通道,不经过网络与服务器。
343
+ // (--tunnel 与自建 --hub 的 single 模式仍保留旧行为:daemon 自己生成
344
+ // token 并打印链接。自建 multi hub 可显式 `--pair --hub wss://…` 配对。)
345
+ rawArgs.push("--pair");
126
346
  }
127
347
  }
128
348
 
@@ -158,16 +378,22 @@ function printHelp() {
158
378
  const self = "alink-cli";
159
379
  process.stdout.write(
160
380
  [
161
- `用法: npx ${self} [--token <al1.…>] [--hub <wss://…>] [--dir <目录>]…`,
381
+ `用法: npx ${self} [--hub <wss://…>] [--dir <目录>]…`,
162
382
  ` npx ${self} qr # 终端打印本机凭证二维码(给新手机录密钥)`,
383
+ ` npx ${self} start [--dir …] # 后台运行(先前台配对过一次)`,
384
+ ` npx ${self} status|logs [-f]|stop|restart`,
163
385
  ``,
164
386
  `把当前这台工作机接入 AgentLink,随时随地遥控本机的编码 agent。`,
387
+ `首次裸跑会进入扫码配对:加密密钥在这台电脑上生成,终端打出二维码,`,
388
+ `用手机 AgentLink 的「添加机器」扫一下即绑定——密钥只走二维码,不经过服务器。`,
389
+ `绑定后可用 start 挂到后台(日志 ~/.agentlink/daemon.log),status 看连接`,
390
+ `状态,logs -f 跟日志,stop/restart 停止或重启。`,
165
391
  ``,
166
392
  `参数:`,
167
- ` --token <token> 机器凭证(控制台里“添加机器”生成的 al1.… 整串)。`,
168
- ` 首次成功连上后会存到 ~/.agentlink/credential(0600),`,
169
- ` 之后裸跑 npx ${self} 自动复用。也可用环境变量`,
170
- ` AGENTLINK_TOKEN 提供(--token 优先)。`,
393
+ ` --pair 强制重新扫码配对(换账号 / 重绑这台机器)。`,
394
+ ` --token <token> 直接给机器凭证(al1.… 整串,如从别处迁移)。首次成功连上后`,
395
+ ` 存到 ~/.agentlink/credential(0600),之后裸跑自动复用。`,
396
+ ` 也可用环境变量 AGENTLINK_TOKEN 提供(--token 优先)。`,
171
397
  ` --hub <url> hub 地址,缺省官方 hub ${OFFICIAL_HUB}。`,
172
398
  ` --dir <path> 工作目录根,可重复;缺省当前目录。运行时可用其下任意子目录。`,
173
399
  ` --tunnel 不连任何 hub:本地起 hub + 免费 Cloudflare 隧道并打印公网链接`,
@@ -175,11 +401,11 @@ function printHelp() {
175
401
  ` --help, -h 显示本帮助。`,
176
402
  ``,
177
403
  `示例:`,
178
- ` npx ${self} --token al1.xxxxx # 首次接入,token 只需给这一次`,
179
- ` npx ${self} # 之后裸跑,复用已存凭证`,
404
+ ` npx ${self} # 首次:终端出二维码,手机扫码绑定;`,
405
+ ` # 之后裸跑复用已存凭证,直接上线`,
180
406
  ``,
181
- `连接是出站的(走 NAT/防火墙无需端口转发),断线自动重连。al1. 凭证的加密`,
182
- `密钥段只留在本机、从不上网(端到端加密)。`,
407
+ `连接是出站的(走 NAT/防火墙无需端口转发),断线自动重连。加密密钥只留在`,
408
+ `本机和扫码的手机上、从不上网(端到端加密)。`,
183
409
  ``,
184
410
  ].join("\n"),
185
411
  );