alink-cli 0.6.2 → 0.7.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/README.md +14 -48
- package/bin/agentlink.js +159 -454
- package/dist/NodeSqliteClient-BUlNQAY0.mjs +191 -0
- package/dist/NodeSqliteClient-BUlNQAY0.mjs.map +1 -0
- package/dist/bin.mjs +68667 -0
- package/dist/bin.mjs.map +1 -0
- package/dist/daemon.js +168 -147
- package/dist/qr.js +22 -22
- package/package.json +34 -27
package/bin/agentlink.js
CHANGED
|
@@ -1,488 +1,193 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// bin/agentlink.js — `npx alink-cli` 的薄入口。
|
|
3
|
-
//
|
|
4
|
-
// 它自己几乎不做事:把命令行参数补齐默认值后,直接 import 打好的 daemon
|
|
5
|
-
// bundle(dist/daemon.js),由 daemon 完成探测 agent、连 hub、跑任务这套完整
|
|
6
|
-
// 流程。daemon 在被 import 时会读 `process.argv` / `AGENTLINK_TOKEN`,所以这里
|
|
7
|
-
// 必须在 import 之前把 argv 和 env 准备好。
|
|
8
|
-
//
|
|
9
|
-
// 相对 daemon 收敛的默认值(让工作机上一条命令即接入官方 hub):
|
|
10
|
-
// --hub 缺省 wss://link.harmopath.com(daemon 裸跑缺省是 localhost,只适合开发)
|
|
11
|
-
// --dir 缺省 process.cwd()(daemon 本就如此,无需干预)
|
|
12
|
-
// --token 缺省依次取 AGENTLINK_TOKEN 环境变量 → ~/.agentlink/credential 落盘凭证
|
|
13
|
-
//
|
|
14
|
-
// 凭证落盘:首次带 `--token` 成功连上 hub 后,把整串 token 写到
|
|
15
|
-
// ~/.agentlink/credential(0600)。之后裸跑 `npx alink-cli` 直接复用——命令行
|
|
16
|
-
// 上的 token 只需出现一次。al1. 多租户凭证的第四段(E2EE 主密钥)也一并存进这
|
|
17
|
-
// 个文件,从不上网;daemon 另会把它单独归档进 enckeys.json 供 E2EE 层使用。
|
|
18
|
-
|
|
19
2
|
import { spawn } from "node:child_process";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { hostname, homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
23
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
+
import WebSocket from "ws";
|
|
24
9
|
|
|
25
10
|
const OFFICIAL_HUB = process.env.AGENTLINK_DEFAULT_HUB || "wss://link.harmopath.com";
|
|
26
|
-
|
|
27
|
-
// 凭证文件与 daemon 的状态目录同根(默认 ~/.agentlink,可用 AGENTLINK_STATE_DIR
|
|
28
|
-
// 覆盖,测试用),文件名固定为 credential。
|
|
29
11
|
const STATE_DIR = process.env.AGENTLINK_STATE_DIR || join(homedir(), ".agentlink");
|
|
30
12
|
const CREDENTIAL_FILE = join(STATE_DIR, "credential");
|
|
13
|
+
const args = process.argv.slice(2);
|
|
31
14
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
printHelp();
|
|
37
|
-
process.exit(0);
|
|
15
|
+
function value(name) {
|
|
16
|
+
const index = args.findIndex((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`));
|
|
17
|
+
if (index < 0) return undefined;
|
|
18
|
+
return args[index].includes("=") ? args[index].slice(name.length + 3) : args[index + 1];
|
|
38
19
|
}
|
|
39
20
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const a = args[i];
|
|
49
|
-
if (a === `--${name}`) return args[i + 1];
|
|
50
|
-
if (a.startsWith(`--${name}=`)) return a.slice(name.length + 3);
|
|
51
|
-
}
|
|
52
|
-
return undefined;
|
|
21
|
+
function normalizeHub(hub) {
|
|
22
|
+
const url = new URL(hub);
|
|
23
|
+
if (url.protocol === "http:") url.protocol = "ws:";
|
|
24
|
+
if (url.protocol === "https:") url.protocol = "wss:";
|
|
25
|
+
url.hash = "";
|
|
26
|
+
url.search = "";
|
|
27
|
+
url.pathname = url.pathname.replace(/\/+$/, "") || "/";
|
|
28
|
+
return url.toString();
|
|
53
29
|
}
|
|
54
30
|
|
|
55
|
-
function
|
|
31
|
+
function savedCredential(hub = OFFICIAL_HUB) {
|
|
56
32
|
try {
|
|
57
|
-
const
|
|
58
|
-
|
|
33
|
+
const raw = readFileSync(CREDENTIAL_FILE, "utf8").trim();
|
|
34
|
+
if (!raw) return undefined;
|
|
35
|
+
if (!raw.startsWith("{")) {
|
|
36
|
+
return normalizeHub(hub) === normalizeHub(OFFICIAL_HUB) ? raw : undefined;
|
|
37
|
+
}
|
|
38
|
+
const saved = JSON.parse(raw);
|
|
39
|
+
return saved.hub === normalizeHub(hub) && typeof saved.credential === "string"
|
|
40
|
+
? saved.credential
|
|
41
|
+
: undefined;
|
|
59
42
|
} catch {
|
|
60
|
-
return undefined;
|
|
43
|
+
return undefined;
|
|
61
44
|
}
|
|
62
45
|
}
|
|
63
46
|
|
|
64
|
-
function
|
|
47
|
+
function saveCredential(credential, hub = OFFICIAL_HUB) {
|
|
65
48
|
mkdirSync(STATE_DIR, { recursive: true });
|
|
66
|
-
|
|
67
|
-
|
|
49
|
+
writeFileSync(CREDENTIAL_FILE, JSON.stringify({ hub: normalizeHub(hub), credential }), {
|
|
50
|
+
mode: 0o600,
|
|
51
|
+
});
|
|
68
52
|
chmodSync(CREDENTIAL_FILE, 0o600);
|
|
69
53
|
}
|
|
70
54
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
55
|
+
function parseCredential(credential) {
|
|
56
|
+
const parts = credential.split(".");
|
|
57
|
+
if (parts.length === 3 && parts[0] === "als1" && parts.slice(1).every(Boolean)) {
|
|
58
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(parts[2])) throw new Error("机器凭证中的加密密钥无效。");
|
|
59
|
+
return { machineToken: parts[1], enckey: parts[2] };
|
|
60
|
+
}
|
|
61
|
+
if (parts.length !== 4 || parts[0] !== "al1" || !parts.slice(1).every(Boolean)) {
|
|
62
|
+
throw new Error("机器凭证必须是完整的 al1.<payload>.<sig>.<enckey>。");
|
|
63
|
+
}
|
|
64
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(parts[3])) throw new Error("机器凭证中的加密密钥无效。");
|
|
65
|
+
return { machineToken: parts.slice(0, 3).join("."), enckey: parts[3] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function hubMode(hub) {
|
|
69
|
+
const url = new URL("/api/config", hub);
|
|
70
|
+
if (url.protocol === "ws:") url.protocol = "http:";
|
|
71
|
+
if (url.protocol === "wss:") url.protocol = "https:";
|
|
72
|
+
const response = await fetch(url);
|
|
73
|
+
if (!response.ok) throw new Error(`Hub 配置读取失败 (${response.status})。`);
|
|
74
|
+
return (await response.json()).mode;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function createSingleCredential(hub) {
|
|
78
|
+
const token = randomBytes(24).toString("base64url");
|
|
79
|
+
const enckey = randomBytes(32).toString("base64url");
|
|
80
|
+
const credential = `als1.${token}.${enckey}`;
|
|
81
|
+
saveCredential(credential, hub);
|
|
82
|
+
const url = new URL("/pair", hub);
|
|
83
|
+
if (url.protocol === "ws:") url.protocol = "http:";
|
|
84
|
+
if (url.protocol === "wss:") url.protocol = "https:";
|
|
85
|
+
url.searchParams.set("token", token);
|
|
86
|
+
url.hash = new URLSearchParams({ enckey }).toString();
|
|
87
|
+
console.log("\n在 AgentLink 网页扫码,或打开下面的链接:\n");
|
|
88
|
+
await printQr(credential);
|
|
89
|
+
console.log(`\n${url}\n`);
|
|
90
|
+
return credential;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function printQr(text) {
|
|
94
|
+
const { renderQr } = await import(new URL("../dist/qr.js", import.meta.url));
|
|
95
|
+
renderQr(text);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function pair(hub) {
|
|
99
|
+
const enckey = randomBytes(32).toString("base64url");
|
|
100
|
+
const code = randomBytes(16).toString("base64url");
|
|
101
|
+
const pairing = `alp1.${code}.${enckey}`;
|
|
102
|
+
const url = new URL(hub);
|
|
103
|
+
if (url.protocol === "https:") url.protocol = "wss:";
|
|
104
|
+
if (url.protocol === "http:") url.protocol = "ws:";
|
|
105
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}/daemon`;
|
|
106
|
+
url.searchParams.set("pair", code);
|
|
107
|
+
url.searchParams.set("host", hostname());
|
|
108
|
+
url.searchParams.set("v", "1");
|
|
109
|
+
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const socket = new WebSocket(url);
|
|
112
|
+
const timer = setTimeout(() => {
|
|
113
|
+
socket.close();
|
|
114
|
+
reject(new Error("配对超时,请检查 Hub 是否在线后重试。"));
|
|
115
|
+
}, 10 * 60_000);
|
|
116
|
+
socket.on("message", async (raw) => {
|
|
117
|
+
let message;
|
|
118
|
+
try {
|
|
119
|
+
message = JSON.parse(raw.toString());
|
|
120
|
+
} catch {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (message.type === "pair_pending") {
|
|
124
|
+
console.log("\n用 AgentLink 网页或手机扫描二维码:\n");
|
|
125
|
+
await printQr(pairing);
|
|
126
|
+
console.log(`\n无法扫码时粘贴:\n${pairing}\n`);
|
|
127
|
+
}
|
|
128
|
+
if (message.type === "claimed" && typeof message.token === "string") {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
socket.close();
|
|
131
|
+
const credential = `${message.token}.${enckey}`;
|
|
132
|
+
saveCredential(credential, hub);
|
|
133
|
+
console.log(`[agentlink] 配对成功,凭证已保存到 ${CREDENTIAL_FILE}(0600)。`);
|
|
134
|
+
resolve(credential);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
socket.on("error", reject);
|
|
138
|
+
});
|
|
114
139
|
}
|
|
115
140
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (!token) {
|
|
121
|
-
console.error("这台电脑上还没有凭证。先在手机 AgentLink 的「添加机器」里生成命令并运行一次。");
|
|
122
|
-
process.exit(1);
|
|
123
|
-
}
|
|
124
|
-
const parts = token.split(".");
|
|
125
|
-
if (parts.length < 4) {
|
|
126
|
-
console.log("此凭证不含加密密钥(明文接入),其他设备无需录入密钥,直接使用即可。");
|
|
127
|
-
process.exit(0);
|
|
128
|
-
}
|
|
129
|
-
const { renderQr } = await import(join(dirname(fileURLToPath(import.meta.url)), "../dist/qr.js"));
|
|
130
|
-
const { createHash } = await import("node:crypto");
|
|
131
|
-
const enckey = parts[3];
|
|
132
|
-
const fp = createHash("sha256").update(Buffer.from(enckey, "base64url")).digest("hex").slice(0, 8);
|
|
133
|
-
console.log("用手机 AgentLink 的「录入密钥」扫下面的二维码:\n");
|
|
134
|
-
renderQr(token);
|
|
135
|
-
console.log("\n凭证信息(无法扫码时可手动粘贴):\n");
|
|
136
|
-
console.log(token);
|
|
137
|
-
console.log("");
|
|
138
|
-
console.log(`密钥指纹 ${fp} —— 与手机上显示的一致即可放心保存。`);
|
|
141
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
142
|
+
console.log(
|
|
143
|
+
`用法: npx alink-cli [--hub <wss://…>] [--dir <目录>] [--pair]\n\n首次运行会显示配对二维码;之后直接复用 ~/.agentlink/credential。\n--legacy 运行旧 daemon;credential status|path|show|set|clear 管理凭证。`,
|
|
144
|
+
);
|
|
139
145
|
process.exit(0);
|
|
140
146
|
}
|
|
141
147
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const STATUS_JSON = join(STATE_DIR, "status.json");
|
|
152
|
-
|
|
153
|
-
if (["start", "stop", "status", "logs", "restart"].includes(rawArgs[0])) {
|
|
154
|
-
const [cmd, ...rest] = rawArgs;
|
|
155
|
-
if (cmd === "start") await ctlStart(rest);
|
|
156
|
-
else if (cmd === "stop") await ctlStop();
|
|
157
|
-
else if (cmd === "status") ctlStatus();
|
|
158
|
-
else if (cmd === "logs") await ctlLogs(rest);
|
|
159
|
-
else await ctlRestart(rest);
|
|
148
|
+
if (args[0] === "credential") {
|
|
149
|
+
const hub = value("hub") || OFFICIAL_HUB;
|
|
150
|
+
const command = args[1] || "status";
|
|
151
|
+
if (command === "path") console.log(CREDENTIAL_FILE);
|
|
152
|
+
else if (command === "status") console.log(savedCredential(hub) ? "已保存凭证" : "未保存凭证");
|
|
153
|
+
else if (command === "show") console.log(savedCredential(hub) || "");
|
|
154
|
+
else if (command === "set" && args[2] && !/\s/.test(args[2])) saveCredential(args[2], hub);
|
|
155
|
+
else if (command === "clear") rmSync(CREDENTIAL_FILE, { force: true });
|
|
156
|
+
else throw new Error("用法: alink-cli credential status|path|show|set <credential>|clear");
|
|
160
157
|
process.exit(0);
|
|
161
158
|
}
|
|
162
159
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function pidAlive(pid) {
|
|
172
|
-
try {
|
|
173
|
-
process.kill(pid, 0);
|
|
174
|
-
return true;
|
|
175
|
-
} catch {
|
|
176
|
-
return false;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// start 记录的后台进程(daemon.json)——pid 还活着才算数。
|
|
181
|
-
function runningDaemon() {
|
|
182
|
-
const meta = readJsonFile(DAEMON_JSON);
|
|
183
|
-
return meta && typeof meta.pid === "number" && pidAlive(meta.pid) ? meta : null;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function fmtUptime(ms) {
|
|
187
|
-
const s = Math.max(0, Math.floor(ms / 1000));
|
|
188
|
-
const h = Math.floor(s / 3600);
|
|
189
|
-
const m = Math.floor((s % 3600) / 60);
|
|
190
|
-
return h > 0 ? `${h}h${m}m` : m > 0 ? `${m}m${s % 60}s` : `${s}s`;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function sleep(ms) {
|
|
194
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
async function ctlStart(args, { alreadyOk = false } = {}) {
|
|
198
|
-
if (hasFlag("tunnel", args)) {
|
|
199
|
-
console.error("[agentlink] --tunnel 模式带着 cloudflared 子进程,请前台运行(不支持 start)。");
|
|
200
|
-
process.exit(1);
|
|
201
|
-
}
|
|
202
|
-
if (hasFlag("pair", args)) {
|
|
203
|
-
console.error("[agentlink] 配对需要扫终端里的二维码,请先前台跑一次 `npx alink-cli` 完成绑定,再 start。");
|
|
204
|
-
process.exit(1);
|
|
205
|
-
}
|
|
206
|
-
const already = runningDaemon();
|
|
207
|
-
if (already) {
|
|
208
|
-
const msg = `[agentlink] 已在后台运行 (pid ${already.pid})。要重启用 alink-cli restart。`;
|
|
209
|
-
if (alreadyOk) {
|
|
210
|
-
console.log(msg);
|
|
211
|
-
console.log(`[agentlink] 日志: ${DAEMON_LOG}`);
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
console.error(msg);
|
|
215
|
-
process.exit(1);
|
|
216
|
-
}
|
|
217
|
-
// 凭证前置检查:后台进程没法扫码配对(二维码只会写进日志文件),所以
|
|
218
|
-
// 无凭证 + 官方 hub 直接拒绝,指引先前台配对。
|
|
219
|
-
const hubArg = flagValue("hub", args);
|
|
220
|
-
const hasCredential = hasFlag("token", args) || process.env.AGENTLINK_TOKEN || readCredentialFile();
|
|
221
|
-
if (!hasCredential && (!hubArg || hubArg === OFFICIAL_HUB)) {
|
|
222
|
-
console.error("[agentlink] 这台电脑还没绑定。先前台跑一次 `npx alink-cli`,用手机扫码完成配对,再 start。");
|
|
223
|
-
process.exit(1);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// 每次 start 换新日志(旧日志随进程一起翻篇),元数据记下参数供 restart 复用。
|
|
227
|
-
mkdirSync(STATE_DIR, { recursive: true });
|
|
228
|
-
writeFileSync(DAEMON_LOG, "");
|
|
229
|
-
const logFd = openSync(DAEMON_LOG, "a");
|
|
230
|
-
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...args], {
|
|
231
|
-
detached: true,
|
|
232
|
-
stdio: ["ignore", logFd, logFd],
|
|
233
|
-
env: { ...process.env, AGENTLINK_FOREGROUND: "1" },
|
|
234
|
-
});
|
|
235
|
-
child.unref();
|
|
236
|
-
writeFileSync(DAEMON_JSON, JSON.stringify({ pid: child.pid, args, startedAt: Date.now() }));
|
|
237
|
-
|
|
238
|
-
// 等到 daemon 报告连上(status.json state=connected)再回话;起不来就把
|
|
239
|
-
// 日志尾巴打出来。连不上但还在重试(网络/hub 抖动)不算失败。
|
|
240
|
-
const deadline = Date.now() + 12_000;
|
|
241
|
-
for (;;) {
|
|
242
|
-
await sleep(250);
|
|
243
|
-
if (!pidAlive(child.pid)) {
|
|
244
|
-
console.error("[agentlink] 后台 daemon 启动即退出。日志尾部:");
|
|
245
|
-
printLogTail(30);
|
|
246
|
-
rmSync(DAEMON_JSON, { force: true });
|
|
247
|
-
process.exit(1);
|
|
248
|
-
}
|
|
249
|
-
const st = readJsonFile(STATUS_JSON);
|
|
250
|
-
if (st && st.pid === child.pid && st.state === "connected") {
|
|
251
|
-
console.log(`[agentlink] 已在后台启动并连上 hub (pid ${child.pid})。`);
|
|
252
|
-
console.log(`[agentlink] 日志: ${DAEMON_LOG}`);
|
|
253
|
-
console.log(`[agentlink] 常用命令: alink-cli status / logs -f / stop`);
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
if (Date.now() > deadline) {
|
|
257
|
-
console.log(`[agentlink] 已在后台启动 (pid ${child.pid}),但还没连上 hub——会按退避自动重试。`);
|
|
258
|
-
console.log(`[agentlink] 用 alink-cli status 或 alink-cli logs -f 观察进展。`);
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
async function ctlStop() {
|
|
265
|
-
const meta = runningDaemon();
|
|
266
|
-
if (!meta) {
|
|
267
|
-
rmSync(DAEMON_JSON, { force: true }); // 清掉可能残留的陈旧记录
|
|
268
|
-
console.log("[agentlink] 没有在后台运行的 daemon。");
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
process.kill(meta.pid, "SIGTERM");
|
|
272
|
-
for (let i = 0; i < 20 && pidAlive(meta.pid); i++) await sleep(250);
|
|
273
|
-
if (pidAlive(meta.pid)) {
|
|
274
|
-
console.error(`[agentlink] pid ${meta.pid} 不理会 SIGTERM,改发 SIGKILL。`);
|
|
275
|
-
process.kill(meta.pid, "SIGKILL");
|
|
276
|
-
for (let i = 0; i < 8 && pidAlive(meta.pid); i++) await sleep(250);
|
|
277
|
-
}
|
|
278
|
-
rmSync(DAEMON_JSON, { force: true });
|
|
279
|
-
console.log(`[agentlink] 已停止 (pid ${meta.pid})。`);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function ctlStatus() {
|
|
283
|
-
const meta = runningDaemon();
|
|
284
|
-
const st = readJsonFile(STATUS_JSON);
|
|
285
|
-
// 不是 start 管的,但 status.json 里的 pid 还活着 → 前台/别处跑着的 daemon。
|
|
286
|
-
if (!meta && st && typeof st.pid === "number" && pidAlive(st.pid)) {
|
|
287
|
-
console.log(`● 运行中 (pid ${st.pid},非后台托管——大概率是前台在跑)`);
|
|
288
|
-
printStatusDetail(st);
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
if (!meta) {
|
|
292
|
-
console.log("● 未运行");
|
|
293
|
-
if (st && st.state === "stopped") console.log(` 上次退出码 ${st.code ?? "?"}`);
|
|
294
|
-
if (existsSync(DAEMON_LOG)) console.log(` 日志: ${DAEMON_LOG}`);
|
|
295
|
-
process.exit(1);
|
|
296
|
-
}
|
|
297
|
-
const stateLabel =
|
|
298
|
-
st && st.pid === meta.pid
|
|
299
|
-
? { starting: "启动中", pairing: "配对中", connected: "已连接", reconnecting: `重连中(第 ${st.attempt ?? "?"} 次)`, stopped: "已停止" }[st.state] ?? st.state
|
|
300
|
-
: "未知";
|
|
301
|
-
console.log(`● 运行中 (pid ${meta.pid}, 已运行 ${fmtUptime(Date.now() - meta.startedAt)})`);
|
|
302
|
-
console.log(` 状态: ${stateLabel}`);
|
|
303
|
-
printStatusDetail(st && st.pid === meta.pid ? st : null);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function printStatusDetail(st) {
|
|
307
|
-
if (st) {
|
|
308
|
-
console.log(` hub: ${st.hub}`);
|
|
309
|
-
if (st.machineId) console.log(` machine: ${st.machineId}`);
|
|
310
|
-
if (st.daemonVersion) console.log(` 版本: ${st.daemonVersion}`);
|
|
311
|
-
}
|
|
312
|
-
console.log(` 日志: ${DAEMON_LOG}`);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function printLogTail(n) {
|
|
316
|
-
try {
|
|
317
|
-
const lines = readFileSync(DAEMON_LOG, "utf-8").trimEnd().split("\n");
|
|
318
|
-
for (const line of lines.slice(-n)) console.error(` ${line}`);
|
|
319
|
-
} catch {
|
|
320
|
-
console.error(" (没有日志)");
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
async function ctlLogs(args) {
|
|
325
|
-
const follow = args.includes("-f") || args.includes("--follow");
|
|
326
|
-
const nIdx = args.indexOf("-n");
|
|
327
|
-
const n = nIdx >= 0 ? Number(args[nIdx + 1]) || 50 : 50;
|
|
328
|
-
if (!existsSync(DAEMON_LOG)) {
|
|
329
|
-
console.error(`[agentlink] 还没有日志(${DAEMON_LOG})。先 alink-cli start。`);
|
|
330
|
-
process.exit(1);
|
|
331
|
-
}
|
|
332
|
-
const content = readFileSync(DAEMON_LOG, "utf-8");
|
|
333
|
-
const lines = content.split("\n");
|
|
334
|
-
if (lines[lines.length - 1] === "") lines.pop();
|
|
335
|
-
if (lines.length > 0) process.stdout.write(lines.slice(-n).join("\n") + "\n");
|
|
336
|
-
if (!follow) return;
|
|
337
|
-
// 跟随:轮询文件增量(够用且零依赖;Ctrl+C 退出)。
|
|
338
|
-
let offset = Buffer.byteLength(content);
|
|
339
|
-
for (;;) {
|
|
340
|
-
await sleep(500);
|
|
341
|
-
let size;
|
|
342
|
-
try {
|
|
343
|
-
size = statSync(DAEMON_LOG).size;
|
|
344
|
-
} catch {
|
|
345
|
-
continue; // 日志被轮换/删除,等它回来
|
|
346
|
-
}
|
|
347
|
-
if (size < offset) offset = 0; // start 截断了日志——从头跟
|
|
348
|
-
if (size > offset) {
|
|
349
|
-
const fd = openSync(DAEMON_LOG, "r");
|
|
350
|
-
const buf = Buffer.alloc(size - offset);
|
|
351
|
-
readSync(fd, buf, 0, buf.length, offset);
|
|
352
|
-
closeSync(fd);
|
|
353
|
-
process.stdout.write(buf.toString("utf-8"));
|
|
354
|
-
offset = size;
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
async function ctlRestart(args) {
|
|
360
|
-
const prev = readJsonFile(DAEMON_JSON);
|
|
361
|
-
const startArgs = args.length > 0 ? args : (prev?.args ?? []);
|
|
362
|
-
await ctlStop();
|
|
363
|
-
await ctlStart(startArgs);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
if (rawArgs.length === 0 && !process.env.AGENTLINK_FOREGROUND && (process.env.AGENTLINK_TOKEN || readCredentialFile())) {
|
|
367
|
-
await ctlStart([], { alreadyOk: true });
|
|
160
|
+
if (args[0] === "qr") {
|
|
161
|
+
const credential =
|
|
162
|
+
value("token") || process.env.AGENTLINK_TOKEN || savedCredential(value("hub") || OFFICIAL_HUB);
|
|
163
|
+
if (!credential) throw new Error("这台电脑还没有凭证,请先运行 npx alink-cli 完成配对。");
|
|
164
|
+
await printQr(credential);
|
|
368
165
|
process.exit(0);
|
|
369
166
|
}
|
|
370
167
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if (rawArgs[0] === "local") rawArgs[0] = "--local";
|
|
375
|
-
|
|
376
|
-
// --tunnel / --local 模式下 daemon 会自起本地 hub,且与 --hub 互斥,此时绝不注入 --hub。
|
|
377
|
-
const tunnelMode = hasFlag("tunnel");
|
|
378
|
-
const localMode = hasFlag("local");
|
|
379
|
-
|
|
380
|
-
// 1) 补 --hub 默认值:官方 hub。本地模式(tunnel/local)自带 localhost hub,跳过。
|
|
381
|
-
if (!hasFlag("hub") && !tunnelMode && !localMode) {
|
|
382
|
-
rawArgs.push("--hub", OFFICIAL_HUB);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// 2) 解析 token 来源,并决定是否需要“成功连接后落盘”。
|
|
386
|
-
// 优先级:显式 --pair(重新配对)> 命令行 --token > AGENTLINK_TOKEN 环境变量
|
|
387
|
-
// > 凭证文件 > (官方 hub)扫码配对。
|
|
388
|
-
let tokenToPersist; // 仅当用户这次显式给了新 --token 时,连上后写盘
|
|
389
|
-
const explicitToken = hasFlag("token") ? flagValue("token") : undefined;
|
|
390
|
-
if (hasFlag("pair")) {
|
|
391
|
-
// 显式 --pair:忽略已存凭证,重新走扫码配对(换账号/重绑)。凭证由 daemon
|
|
392
|
-
// 配对成功后自行落盘;--pair 与 --token 的互斥由 daemon 的参数校验把关。
|
|
393
|
-
} else if (explicitToken) {
|
|
394
|
-
// 用户显式给了 token——daemon 会直接用 argv 里的 --token。若它和已存凭证不同,
|
|
395
|
-
// 安排在成功连上 hub 后落盘(避免把连不上的坏 token 也存下来)。
|
|
396
|
-
if (explicitToken !== readCredentialFile()) tokenToPersist = explicitToken;
|
|
397
|
-
} else if (process.env.AGENTLINK_TOKEN) {
|
|
398
|
-
// 环境变量已给——daemon 自己会读 AGENTLINK_TOKEN,这里什么都不用做。
|
|
399
|
-
} else if (localMode) {
|
|
400
|
-
// --local:single 模式本地 hub,daemon 自己现生成一个 UUID token 并打印/开浏览器。
|
|
401
|
-
// 不复用落盘凭证(那多半是官方 al1. 多租户串,喂给本地 single hub 是错的),也不配对。
|
|
168
|
+
if (args.includes("--legacy")) {
|
|
169
|
+
process.argv = process.argv.filter((arg) => arg !== "--legacy");
|
|
170
|
+
await import(new URL("../dist/daemon.js", import.meta.url));
|
|
402
171
|
} else {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
if (tokenToPersist) {
|
|
425
|
-
const original = process.stdout.write.bind(process.stdout);
|
|
426
|
-
let filed = false;
|
|
427
|
-
process.stdout.write = (chunk, ...rest) => {
|
|
428
|
-
if (!filed && typeof chunk === "string" && chunk.includes("[daemon] registered.")) {
|
|
429
|
-
filed = true;
|
|
430
|
-
try {
|
|
431
|
-
persistCredential(tokenToPersist);
|
|
432
|
-
original(`[agentlink] 凭证已保存到 ${CREDENTIAL_FILE}(0600)——之后可直接裸跑 \`npx alink-cli\`\n`);
|
|
433
|
-
} catch {
|
|
434
|
-
original(`[agentlink] 警告:凭证写入 ${CREDENTIAL_FILE} 失败——下次仍需 --token\n`);
|
|
435
|
-
}
|
|
436
|
-
process.stdout.write = original; // 还原,之后正常输出
|
|
437
|
-
}
|
|
438
|
-
return original(chunk, ...rest);
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// 5) 启动 daemon。import 求值时它就会解析 argv 并开始连 hub。
|
|
443
|
-
const daemon = new URL("../dist/daemon.js", import.meta.url);
|
|
444
|
-
await import(daemon.href);
|
|
445
|
-
|
|
446
|
-
function printHelp() {
|
|
447
|
-
const self = "alink-cli";
|
|
448
|
-
process.stdout.write(
|
|
449
|
-
[
|
|
450
|
-
`用法: npx ${self} [--hub <wss://…>] [--dir <目录>]…`,
|
|
451
|
-
` npx ${self} local [--dir …] # 同机驾驶舱:本地起 hub 并自动开浏览器,不遥控`,
|
|
452
|
-
` npx ${self} qr # 终端打印本机凭证二维码(给新手机录密钥)`,
|
|
453
|
-
` npx ${self} credential set <凭证> # 手动写入 / 修改本机保存的凭证`,
|
|
454
|
-
` npx ${self} start [--dir …] # 后台运行(先前台配对过一次)`,
|
|
455
|
-
` npx ${self} status|logs [-f]|stop|restart`,
|
|
456
|
-
``,
|
|
457
|
-
`把当前这台工作机接入 AgentLink,随时随地遥控本机的编码 agent。`,
|
|
458
|
-
`首次裸跑会进入扫码配对:加密密钥在这台电脑上生成,终端打出二维码,`,
|
|
459
|
-
`用手机 AgentLink 的「添加机器」扫一下即绑定——密钥只走二维码,不经过服务器。`,
|
|
460
|
-
`绑定后裸跑会默认挂到后台(日志 ~/.agentlink/daemon.log),status 看连接`,
|
|
461
|
-
`状态,logs -f 跟日志,stop/restart 停止或重启。`,
|
|
462
|
-
``,
|
|
463
|
-
`参数:`,
|
|
464
|
-
` --pair 强制重新扫码配对(换账号 / 重绑这台机器)。`,
|
|
465
|
-
` --token <token> 直接给机器凭证(al1.… 整串,如从别处迁移)。首次成功连上后`,
|
|
466
|
-
` 存到 ~/.agentlink/credential(0600),之后裸跑自动复用。`,
|
|
467
|
-
` 也可用环境变量 AGENTLINK_TOKEN 提供(--token 优先)。`,
|
|
468
|
-
` credential 管理本机保存的凭证:status/path/show/set/clear。show 会明文打印`,
|
|
469
|
-
` 凭证;set 直接覆盖 ~/.agentlink/credential。`,
|
|
470
|
-
` --hub <url> hub 地址,缺省官方 hub ${OFFICIAL_HUB}。`,
|
|
471
|
-
` --dir <path> 工作目录根,可重复;缺省当前目录。运行时可用其下任意子目录。`,
|
|
472
|
-
` --tunnel 不连任何 hub:本地起 hub + 免费 Cloudflare 隧道并打印公网链接`,
|
|
473
|
-
` (需已安装 cloudflared)。与 --hub 互斥。`,
|
|
474
|
-
` local 同机场景:本地起 single 模式 hub + daemon,自动用浏览器打开`,
|
|
475
|
-
` 控制台(localhost),把这台电脑上的多个 agent 统一管起来。`,
|
|
476
|
-
` 不遥控、不联网、无需扫码。--port 可改本地 hub 端口(默认 8080)。`,
|
|
477
|
-
` --help, -h 显示本帮助。`,
|
|
478
|
-
``,
|
|
479
|
-
`示例:`,
|
|
480
|
-
` npx ${self} # 首次:终端出二维码,手机扫码绑定;`,
|
|
481
|
-
` # 之后裸跑复用已存凭证,直接上线`,
|
|
482
|
-
``,
|
|
483
|
-
`连接是出站的(走 NAT/防火墙无需端口转发),断线自动重连。加密密钥只留在`,
|
|
484
|
-
`本机和扫码的手机上、从不上网(端到端加密)。`,
|
|
485
|
-
``,
|
|
486
|
-
].join("\n"),
|
|
487
|
-
);
|
|
172
|
+
const hub = value("hub") || OFFICIAL_HUB;
|
|
173
|
+
const explicit = value("token") || process.env.AGENTLINK_TOKEN;
|
|
174
|
+
const credential =
|
|
175
|
+
explicit ||
|
|
176
|
+
(!args.includes("--pair") && savedCredential(hub)) ||
|
|
177
|
+
((await hubMode(hub)) === "multi" ? await pair(hub) : await createSingleCredential(hub));
|
|
178
|
+
if (explicit && explicit !== savedCredential(hub)) saveCredential(explicit, hub);
|
|
179
|
+
const { machineToken, enckey } = parseCredential(credential);
|
|
180
|
+
const daemon = fileURLToPath(new URL("../dist/bin.mjs", import.meta.url));
|
|
181
|
+
const cwd = value("dir") || process.cwd();
|
|
182
|
+
const child = spawn(process.execPath, [daemon, "serve", cwd], {
|
|
183
|
+
stdio: "inherit",
|
|
184
|
+
env: {
|
|
185
|
+
...process.env,
|
|
186
|
+
T3CODE_HUB_URL: hub,
|
|
187
|
+
T3CODE_HUB_MACHINE_TOKEN: machineToken,
|
|
188
|
+
T3CODE_HUB_ENCKEY: enckey,
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => child.kill(signal));
|
|
192
|
+
process.exitCode = await new Promise((resolve) => child.on("exit", (code) => resolve(code ?? 1)));
|
|
488
193
|
}
|