alink-cli 0.7.5 → 0.8.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 CHANGED
@@ -6,7 +6,13 @@
6
6
  npx alink-cli
7
7
  ```
8
8
 
9
- 首次运行输入 AgentLink 账号和密码,这台电脑会自动加入账号并上线。之后直接重复 `npx alink-cli` 即可上线;密码不会保存到本机。
9
+ 直接运行会进入基于 Ink 的交互式 TUI。TUI 采用扁平菜单,最多两级:
10
+
11
+ - 顶部状态栏实时显示 daemon 是否运行、PID、Hub、凭证状态。
12
+ - 主菜单:启动 / 停止 / 重启 daemon、查看状态详情、登录 / 配对 / 登出、查看机器、设置、帮助、退出。
13
+ - 启动 daemon 后它在后台运行,退出 TUI 不会停止它。
14
+
15
+ 首次运行输入 AgentLink 账号和密码,这台电脑会自动加入账号并上线。之后直接重复 `npx alink-cli` 即可管理或启动后台服务;密码不会保存到本机。
10
16
 
11
17
  ```bash
12
18
  npm install -g alink-cli
@@ -28,5 +34,7 @@ npx alink-cli --dir /path/to/project
28
34
  - `--hub`:Hub 地址,默认 `wss://link.harmopath.com`。
29
35
  - `--dir`:默认工作目录,缺省为当前目录。
30
36
  - `--legacy`:临时运行旧 daemon;默认始终是 daemon-t3。
37
+ - `--no-tui` / `AGENTLINK_TUI=0`:跳过交互式 TUI,按旧行为前台启动 daemon(适合脚本或非 TTY 环境)。
38
+ - `CI=true` 或非 TTY 环境会自动跳过 TUI。
31
39
 
32
40
  Node.js 22.16+。连接为出站 WebSocket,无需端口转发;Hub 只转发 E2EE 密文。
@@ -5,7 +5,7 @@ import { createRequire } from "node:module";
5
5
  // `require`; expose this package's resolver so its bundled `ws` dependency is found.
6
6
  globalThis.require ??= createRequire(import.meta.url);
7
7
 
8
- const MACHINE_BUCKET = "agentlink-machines";
8
+ const MACHINE_TABLE = "agentlink_machine_keys";
9
9
  const REQUEST_TIMEOUT_MS = 10_000;
10
10
 
11
11
  function httpUrl(hub, pathname) {
@@ -19,12 +19,20 @@ async function json(response) {
19
19
  return response.json().catch(() => ({}));
20
20
  }
21
21
 
22
- export async function loadAgentLinkAccountConfig(hub, fetchFn = fetch) {
23
- const response = await fetchFn(httpUrl(hub, "/api/config"), {
24
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
25
- });
26
- if (!response.ok) throw new Error(`Hub 配置读取失败 (${response.status})。`);
27
- return json(response);
22
+ function timestamp(value) {
23
+ const parsed = typeof value === "number" ? value : Number(value);
24
+ return Number.isFinite(parsed) ? parsed : undefined;
25
+ }
26
+
27
+ function normalizeMachineRow(row) {
28
+ const createdAt = timestamp(row?.created_at);
29
+ return {
30
+ machineId: row?.machine_id,
31
+ ...(row?.name ? { name: row.name } : {}),
32
+ ...(row?.hostname ? { hostname: row.hostname } : {}),
33
+ ...(row?.enckey ? { enckey: row.enckey } : {}),
34
+ ...(createdAt !== undefined ? { createdAt } : {}),
35
+ };
28
36
  }
29
37
 
30
38
  function machineIdFromToken(authToken) {
@@ -38,18 +46,12 @@ function machineIdFromToken(authToken) {
38
46
  }
39
47
  }
40
48
 
41
- async function readMachines(bucket, path) {
42
- const { data, error } = await bucket.download(path);
43
- if (error) {
44
- if (/not found|no such|404/i.test(error.message || "")) return [];
45
- throw new Error(`读取账号机器列表失败:${error.message}`);
46
- }
47
- try {
48
- const parsed = JSON.parse(await data.text());
49
- return Array.isArray(parsed.machines) ? parsed.machines : [];
50
- } catch {
51
- throw new Error("账号机器列表格式损坏,请在 Web 端移除异常记录后重试。");
52
- }
49
+ export async function loadAgentLinkAccountConfig(hub, fetchFn = fetch) {
50
+ const response = await fetchFn(httpUrl(hub, "/api/config"), {
51
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
52
+ });
53
+ if (!response.ok) throw new Error(`Hub 配置读取失败 (${response.status})。`);
54
+ return json(response);
53
55
  }
54
56
 
55
57
  export async function registerAgentLinkAccountMachine({
@@ -108,29 +110,30 @@ export async function registerAgentLinkAccountMachine({
108
110
  try {
109
111
  const user = await auth.getCurrentUser();
110
112
  if (!user?.uid) throw new Error("CloudBase 账号登录状态无效。");
111
- const path = `${user.uid}/machines.json`;
112
- const bucket = app.storage.from(MACHINE_BUCKET);
113
- const machines = await readMachines(bucket, path);
114
- const current = machines.find((machine) => machine?.machineId === machineId);
113
+
114
+ const database = app.rdb();
115
+ const { data, error: readError } = await database
116
+ .from(MACHINE_TABLE)
117
+ .select("machine_id,connected_at,created_at")
118
+ .order("updated_at", { ascending: false });
119
+ if (readError) throw new Error(`读取账号机器记录失败:${readError.message}`);
120
+ const current = (Array.isArray(data) ? data : []).find((row) => row?.machine_id === machineId);
121
+
115
122
  const enckey = randomBytesFn(32).toString("base64url");
116
- const machine = {
117
- machineId,
118
- name: machineName,
119
- hostname: machineName,
120
- createdAt: current?.createdAt ?? Date.now(),
121
- enckey,
122
- };
123
- // ponytail: read-modify-write is enough for normal first-time setup; add
124
- // versioned retries if concurrent registrations for one account become common.
125
- const { error } = await bucket.update(
126
- path,
127
- JSON.stringify({
128
- version: 2,
129
- machines: [machine, ...machines.filter((entry) => entry?.machineId !== machineId)],
130
- }),
131
- { contentType: "application/json" },
123
+ const now = Date.now();
124
+ const { error } = await database.from(MACHINE_TABLE).upsert(
125
+ {
126
+ machine_id: machineId,
127
+ name: machineName,
128
+ hostname: machineName,
129
+ enckey,
130
+ connected_at: current?.connected_at ?? null,
131
+ created_at: current?.created_at ?? now,
132
+ updated_at: now,
133
+ },
134
+ { onConflict: "owner_id,machine_id" },
132
135
  );
133
- if (error) throw new Error(`保存账号机器列表失败:${error.message}`);
136
+ if (error) throw new Error(`保存账号机器记录失败:${error.message}`);
134
137
  return { credential: `${authToken}.${enckey}`, machineId };
135
138
  } finally {
136
139
  await auth.signOut().catch(() => undefined);
@@ -149,7 +152,13 @@ export async function listAgentLinkAccountMachines({ config, identifier, passwor
149
152
  try {
150
153
  const user = await auth.getCurrentUser();
151
154
  if (!user?.uid) throw new Error("CloudBase 账号登录状态无效。");
152
- return readMachines(app.storage.from(MACHINE_BUCKET), `${user.uid}/machines.json`);
155
+ const database = app.rdb();
156
+ const { data, error } = await database
157
+ .from(MACHINE_TABLE)
158
+ .select("machine_id,name,hostname,enckey,connected_at,created_at")
159
+ .order("updated_at", { ascending: false });
160
+ if (error) throw new Error(`读取账号机器记录失败:${error.message}`);
161
+ return (Array.isArray(data) ? data : []).map(normalizeMachineRow);
153
162
  } finally {
154
163
  await auth.signOut().catch(() => undefined);
155
164
  }
@@ -0,0 +1,55 @@
1
+ import { createInterface } from "node:readline/promises";
2
+
3
+ export async function readPassword(label) {
4
+ if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") {
5
+ throw new Error(
6
+ "当前终端无法安全读取密码,请在交互式终端运行,或设置 AGENTLINK_ACCOUNT 和 AGENTLINK_PASSWORD。",
7
+ );
8
+ }
9
+ process.stdout.write(label);
10
+ return new Promise((resolve, reject) => {
11
+ let password = "";
12
+ const wasRaw = process.stdin.isRaw;
13
+ const finish = () => {
14
+ process.stdin.off("data", onData);
15
+ process.stdin.setRawMode(wasRaw);
16
+ process.stdin.pause();
17
+ process.stdout.write("\n");
18
+ };
19
+ const onData = (chunk) => {
20
+ for (const char of String(chunk)) {
21
+ if (char === "\u0003") {
22
+ finish();
23
+ reject(new Error("已取消登录。"));
24
+ return;
25
+ }
26
+ if (char === "\r" || char === "\n") {
27
+ finish();
28
+ resolve(password);
29
+ return;
30
+ }
31
+ if (char === "\u007f" || char === "\b") password = password.slice(0, -1);
32
+ else if (char >= " ") password += char;
33
+ }
34
+ };
35
+ process.stdin.setRawMode(true);
36
+ process.stdin.setEncoding("utf8");
37
+ process.stdin.resume();
38
+ process.stdin.on("data", onData);
39
+ });
40
+ }
41
+
42
+ export async function accountCredentials() {
43
+ const fromEnv = process.env.AGENTLINK_ACCOUNT?.trim();
44
+ const passwordFromEnv = process.env.AGENTLINK_PASSWORD;
45
+ if (fromEnv && passwordFromEnv) return { identifier: fromEnv, password: passwordFromEnv };
46
+ if (!process.stdin.isTTY) {
47
+ throw new Error("首次运行需要登录账号,请在交互式终端运行。");
48
+ }
49
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
50
+ const identifier = (await readline.question("AgentLink 账号:")).trim();
51
+ readline.close();
52
+ const password = await readPassword("AgentLink 密码:");
53
+ if (!identifier || !password) throw new Error("账号和密码不能为空。");
54
+ return { identifier, password };
55
+ }
package/bin/agentlink.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import { randomBytes } from "node:crypto";
4
+ import { existsSync } from "node:fs";
4
5
  import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
6
  import { hostname, homedir } from "node:os";
6
7
  import { join } from "node:path";
7
- import { createInterface } from "node:readline/promises";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import WebSocket from "ws";
10
10
 
@@ -13,12 +13,20 @@ import {
13
13
  loadAgentLinkAccountConfig,
14
14
  registerAgentLinkAccountMachine,
15
15
  } from "./agentlink-account.js";
16
+ import { accountCredentials } from "./agentlink-prompts.js";
16
17
 
17
18
  const OFFICIAL_HUB = process.env.AGENTLINK_DEFAULT_HUB || "wss://link.harmopath.com";
18
19
  const STATE_DIR = process.env.AGENTLINK_STATE_DIR || join(homedir(), ".agentlink");
19
20
  const CREDENTIAL_FILE = join(STATE_DIR, "credential");
20
21
  const args = process.argv.slice(2);
21
22
  const legacyCredentialCommands = process.env.AGENTLINK_ENABLE_LEGACY_CREDENTIALS === "1";
23
+ const forceTui = process.env.AGENTLINK_TUI === "1";
24
+ const noTui =
25
+ !forceTui &&
26
+ (args.includes("--no-tui") ||
27
+ process.env.AGENTLINK_TUI === "0" ||
28
+ process.env.CI === "true" ||
29
+ !process.stdin.isTTY);
22
30
 
23
31
  function value(name) {
24
32
  const index = args.findIndex((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`));
@@ -89,60 +97,6 @@ async function createSingleCredential(hub) {
89
97
  return credential;
90
98
  }
91
99
 
92
- async function readPassword(label) {
93
- if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") {
94
- throw new Error(
95
- "当前终端无法安全读取密码,请在交互式终端运行,或设置 AGENTLINK_ACCOUNT 和 AGENTLINK_PASSWORD。",
96
- );
97
- }
98
- process.stdout.write(label);
99
- return new Promise((resolve, reject) => {
100
- let password = "";
101
- const wasRaw = process.stdin.isRaw;
102
- const finish = () => {
103
- process.stdin.off("data", onData);
104
- process.stdin.setRawMode(wasRaw);
105
- process.stdin.pause();
106
- process.stdout.write("\n");
107
- };
108
- const onData = (chunk) => {
109
- for (const char of String(chunk)) {
110
- if (char === "\u0003") {
111
- finish();
112
- reject(new Error("已取消登录。"));
113
- return;
114
- }
115
- if (char === "\r" || char === "\n") {
116
- finish();
117
- resolve(password);
118
- return;
119
- }
120
- if (char === "\u007f" || char === "\b") password = password.slice(0, -1);
121
- else if (char >= " ") password += char;
122
- }
123
- };
124
- process.stdin.setRawMode(true);
125
- process.stdin.setEncoding("utf8");
126
- process.stdin.resume();
127
- process.stdin.on("data", onData);
128
- });
129
- }
130
-
131
- async function accountCredentials() {
132
- const fromEnv = process.env.AGENTLINK_ACCOUNT?.trim();
133
- const passwordFromEnv = process.env.AGENTLINK_PASSWORD;
134
- if (fromEnv && passwordFromEnv) return { identifier: fromEnv, password: passwordFromEnv };
135
- if (!process.stdin.isTTY) {
136
- throw new Error("首次运行需要登录账号,请在交互式终端运行。");
137
- }
138
- const readline = createInterface({ input: process.stdin, output: process.stdout });
139
- const identifier = (await readline.question("AgentLink 账号:")).trim();
140
- readline.close();
141
- const password = await readPassword("AgentLink 密码:");
142
- if (!identifier || !password) throw new Error("账号和密码不能为空。");
143
- return { identifier, password };
144
- }
145
-
146
100
  async function loginAndCreateCredential(hub, config) {
147
101
  console.log("[agentlink] 首次使用,请登录 AgentLink 账号。");
148
102
  const { identifier, password } = await accountCredentials();
@@ -208,42 +162,42 @@ async function pair(hub) {
208
162
  });
209
163
  }
210
164
 
211
- if (args[0] === "help" || args.includes("--help") || args.includes("-h")) {
165
+ // ---------------------------------------------------------------------------
166
+ // Command handlers
167
+ // ---------------------------------------------------------------------------
168
+
169
+ async function showHelp() {
212
170
  console.log(
213
- `用法:\n alink-cli 登录(首次)并让这台电脑上线\n alink-cli login 登录账号,不启动服务\n alink-cli status 查看登录状态\n alink-cli machines 查看账号下的所有机器\n alink-cli logout 退出账号\n alink-cli help 查看帮助\n\n选项:\n --hub <wss://…> Hub 地址\n --dir <目录> 默认工作目录\n --legacy 临时运行旧 daemon`,
171
+ `用法:\n alink-cli 进入交互式 TUI(默认)\n alink-cli --no-tui 跳过 TUI,按旧行为启动 daemon\n alink-cli login 登录账号\n alink-cli status 查看登录状态\n alink-cli machines 查看账号下的所有机器\n alink-cli logout 退出账号\n alink-cli --legacy 临时运行旧 daemon\n alink-cli help 查看帮助\n\n选项:\n --hub <wss://…> Hub 地址\n --dir <目录> 默认工作目录\n --no-tui 禁用交互式 TUI`,
214
172
  );
215
- process.exit(0);
216
173
  }
217
174
 
218
- if (args[0] === "status") {
175
+ async function showStatus() {
219
176
  const hub = value("hub") || OFFICIAL_HUB;
220
177
  console.log(`[agentlink] 登录状态:${savedCredential(hub) ? "已登录" : "未登录"}`);
221
178
  console.log(`[agentlink] Hub:${normalizeHub(hub)}`);
222
- process.exit(0);
223
179
  }
224
180
 
225
- if (args[0] === "logout") {
181
+ async function doLogout() {
226
182
  const loggedIn = savedCredential(value("hub") || OFFICIAL_HUB);
227
183
  if (loggedIn) rmSync(CREDENTIAL_FILE, { force: true });
228
184
  console.log(
229
185
  loggedIn ? "[agentlink] 已退出账号;下次启动时需要重新登录。" : "[agentlink] 当前未登录。",
230
186
  );
231
- process.exit(0);
232
187
  }
233
188
 
234
- if (args[0] === "login") {
189
+ async function doLogin() {
235
190
  const hub = value("hub") || OFFICIAL_HUB;
236
191
  if (savedCredential(hub)) {
237
192
  console.log("[agentlink] 已登录。需要切换或修复机器时,请先运行 alink-cli logout。");
238
- process.exit(0);
193
+ return;
239
194
  }
240
195
  const config = await loadAgentLinkAccountConfig(hub);
241
196
  if (config?.mode !== "multi") throw new Error("当前 Hub 不使用账号登录。");
242
197
  await loginAndCreateCredential(hub, config);
243
- process.exit(0);
244
198
  }
245
199
 
246
- if (args[0] === "machines") {
200
+ async function doMachines() {
247
201
  const hub = value("hub") || OFFICIAL_HUB;
248
202
  const config = await loadAgentLinkAccountConfig(hub);
249
203
  if (config?.mode !== "multi") throw new Error("当前 Hub 不使用账号机器目录。");
@@ -259,38 +213,14 @@ if (args[0] === "machines") {
259
213
  );
260
214
  }
261
215
  }
262
- process.exit(0);
263
- }
264
-
265
- if (args[0] === "credential" && legacyCredentialCommands) {
266
- const hub = value("hub") || OFFICIAL_HUB;
267
- const command = args[1] || "status";
268
- if (command === "path") console.log(CREDENTIAL_FILE);
269
- else if (command === "status") console.log(savedCredential(hub) ? "已保存凭证" : "未保存凭证");
270
- else if (command === "show") console.log(savedCredential(hub) || "");
271
- else if (command === "set" && args[2] && !/\s/.test(args[2])) saveCredential(args[2], hub);
272
- else if (command === "clear") rmSync(CREDENTIAL_FILE, { force: true });
273
- else throw new Error("用法: alink-cli credential status|path|show|set <credential>|clear");
274
- process.exit(0);
275
216
  }
276
217
 
277
- if (args[0] === "qr" && legacyCredentialCommands) {
278
- const credential =
279
- value("token") || process.env.AGENTLINK_TOKEN || savedCredential(value("hub") || OFFICIAL_HUB);
280
- if (!credential) throw new Error("这台电脑还没有凭证,请先运行 npx alink-cli 完成配对。");
281
- await printQr(credential);
282
- process.exit(0);
283
- }
284
-
285
- if (args[0] && !args[0].startsWith("-")) {
286
- console.error(`[agentlink] 未知命令:${args[0]}。运行 alink-cli help 查看帮助。`);
287
- process.exit(1);
288
- }
289
-
290
- if (args.includes("--legacy")) {
218
+ async function doLegacy() {
291
219
  process.argv = process.argv.filter((arg) => arg !== "--legacy");
292
220
  await import(new URL("../dist/daemon.js", import.meta.url));
293
- } else {
221
+ }
222
+
223
+ async function doNoTuiStart() {
294
224
  const hub = value("hub") || OFFICIAL_HUB;
295
225
  const explicit = value("token") || process.env.AGENTLINK_TOKEN;
296
226
  const config =
@@ -322,3 +252,91 @@ if (args.includes("--legacy")) {
322
252
  for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => child.kill(signal));
323
253
  process.exitCode = await new Promise((resolve) => child.on("exit", (code) => resolve(code ?? 1)));
324
254
  }
255
+
256
+ async function doTui() {
257
+ const uiPath = fileURLToPath(new URL("../dist/ui.js", import.meta.url));
258
+ if (!existsSync(uiPath)) {
259
+ console.error(
260
+ `[agentlink] 交互式 UI 包不存在: ${uiPath}\n请先运行 pnpm --filter alink-cli run build。`,
261
+ );
262
+ process.exit(1);
263
+ }
264
+ await import(new URL("../dist/ui.js", import.meta.url));
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Router
269
+ // ---------------------------------------------------------------------------
270
+
271
+ async function main() {
272
+ // Strip internal-only flags before dispatching so downstream handlers see clean args.
273
+ const publicArgs = args.filter((a) => a !== "--no-tui");
274
+
275
+ if (publicArgs[0] === "help" || publicArgs.includes("--help") || publicArgs.includes("-h")) {
276
+ await showHelp();
277
+ return;
278
+ }
279
+
280
+ if (publicArgs[0] === "status") {
281
+ await showStatus();
282
+ return;
283
+ }
284
+
285
+ if (publicArgs[0] === "logout") {
286
+ await doLogout();
287
+ return;
288
+ }
289
+
290
+ if (publicArgs[0] === "login") {
291
+ await doLogin();
292
+ return;
293
+ }
294
+
295
+ if (publicArgs[0] === "machines") {
296
+ await doMachines();
297
+ return;
298
+ }
299
+
300
+ if (publicArgs[0] === "credential" && legacyCredentialCommands) {
301
+ const hub = value("hub") || OFFICIAL_HUB;
302
+ const command = publicArgs[1] || "status";
303
+ if (command === "path") console.log(CREDENTIAL_FILE);
304
+ else if (command === "status") console.log(savedCredential(hub) ? "已保存凭证" : "未保存凭证");
305
+ else if (command === "show") console.log(savedCredential(hub) || "");
306
+ else if (command === "set" && publicArgs[2] && !/\s/.test(publicArgs[2])) saveCredential(publicArgs[2], hub);
307
+ else if (command === "clear") rmSync(CREDENTIAL_FILE, { force: true });
308
+ else throw new Error("用法: alink-cli credential status|path|show|set <credential>|clear");
309
+ return;
310
+ }
311
+
312
+ if (publicArgs[0] === "qr" && legacyCredentialCommands) {
313
+ const credential =
314
+ value("token") || process.env.AGENTLINK_TOKEN || savedCredential(value("hub") || OFFICIAL_HUB);
315
+ if (!credential) throw new Error("这台电脑还没有凭证,请先运行 npx alink-cli 完成配对。");
316
+ await printQr(credential);
317
+ return;
318
+ }
319
+
320
+ if (publicArgs[0] && !publicArgs[0].startsWith("-")) {
321
+ console.error(`[agentlink] 未知命令:${publicArgs[0]}。运行 alink-cli help 查看帮助。`);
322
+ process.exit(1);
323
+ }
324
+
325
+ if (publicArgs.includes("--legacy")) {
326
+ await doLegacy();
327
+ return;
328
+ }
329
+
330
+ // Non-TTY, CI, or explicit --no-tui: preserve the original foreground daemon behavior.
331
+ if (noTui) {
332
+ await doNoTuiStart();
333
+ return;
334
+ }
335
+
336
+ await doTui();
337
+ }
338
+
339
+ main().catch((err) => {
340
+ console.error(err instanceof Error ? err.message : String(err));
341
+ process.exit(1);
342
+ });