alink-cli 0.7.5 → 0.8.1

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,14 @@
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
+ - 未登录时才显示扫码配对入口。
14
+ - 启动 daemon 后它在后台运行,退出 TUI 不会停止它。
15
+
16
+ 首次运行输入 AgentLink 账号和密码,这台电脑会自动加入账号并上线。之后直接重复 `npx alink-cli` 即可管理或启动后台服务;账号密码不会保存到本机,登录会话 JWT 缓存于 `~/.agentlink/session`(0600)。
10
17
 
11
18
  ```bash
12
19
  npm install -g alink-cli
@@ -23,10 +30,12 @@ npx alink-cli --dir /path/to/project
23
30
  - 全局安装后可直接使用 `alink-cli`(兼容旧命令 `agentlink`)。
24
31
  - `login`:登录账号并注册这台电脑,但不启动服务。
25
32
  - `status`:查看当前登录状态和 Hub。
26
- - `machines`:验证账号密码后查看账号下的所有机器,不展示内部凭证。
33
+ - `machines`:查看账号下的所有机器(在线/离线),不展示内部凭证;首次会验证一次账号密码,之后使用缓存的会话 JWT,无需重复输入。
27
34
  - `logout`:退出当前账号并清除本机登录记录,下次运行重新输入账号密码。
28
35
  - `--hub`:Hub 地址,默认 `wss://link.harmopath.com`。
29
36
  - `--dir`:默认工作目录,缺省为当前目录。
30
37
  - `--legacy`:临时运行旧 daemon;默认始终是 daemon-t3。
38
+ - `--no-tui` / `AGENTLINK_TUI=0`:跳过交互式 TUI,按旧行为前台启动 daemon(适合脚本或非 TTY 环境)。
39
+ - `CI=true` 或非 TTY 环境会自动跳过 TUI。
31
40
 
32
41
  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,35 +110,82 @@ 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}`);
134
- return { credential: `${authToken}.${enckey}`, machineId };
136
+ if (error) throw new Error(`保存账号机器记录失败:${error.message}`);
137
+ return { credential: `${authToken}.${enckey}`, machineId, jwt: login.jwt };
135
138
  } finally {
136
139
  await auth.signOut().catch(() => undefined);
137
140
  }
138
141
  }
139
142
 
143
+ export async function listAgentLinkAccountMachinesFromHub({ hub, jwt, fetchFn = fetch }) {
144
+ if (!jwt) throw new Error("没有可用的登录会话,请先运行 alink-cli login。");
145
+ const response = await fetchFn(httpUrl(hub, "/api/machines"), {
146
+ method: "GET",
147
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
148
+ headers: {
149
+ Authorization: `Bearer ${jwt}`,
150
+ "X-Requested-With": "agentlink",
151
+ },
152
+ });
153
+ const body = await json(response);
154
+ if (!response.ok) {
155
+ throw new Error(body.error || `读取机器列表失败 (${response.status})。`);
156
+ }
157
+ const machines = Array.isArray(body.machines) ? body.machines : [];
158
+ return machines.map((row) => ({
159
+ machineId: typeof row.machineId === "string" ? row.machineId : "",
160
+ online: row.online === true,
161
+ name: typeof row.name === "string" ? row.name : undefined,
162
+ hostname: typeof row.hostname === "string" ? row.hostname : undefined,
163
+ connectedAt: timestamp(row.connectedAt),
164
+ createdAt: timestamp(row.createdAt),
165
+ updatedAt: timestamp(row.updatedAt),
166
+ })).filter((m) => m.machineId);
167
+ }
168
+
169
+ export async function exchangeCredentialForSessionJwt({ hub, credential, fetchFn = fetch }) {
170
+ if (!credential) throw new Error("没有可用的机器凭证。");
171
+ // The hub only accepts the three-segment auth half; strip the E2EE enckey.
172
+ const authHalf = credential.split(".").slice(0, 3).join(".");
173
+ const response = await fetchFn(httpUrl(hub, "/auth/credential"), {
174
+ method: "POST",
175
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
176
+ headers: {
177
+ "Content-Type": "application/json",
178
+ "X-Requested-With": "agentlink",
179
+ },
180
+ body: JSON.stringify({ credential: authHalf }),
181
+ });
182
+ const body = await json(response);
183
+ if (!response.ok || typeof body.jwt !== "string" || !body.jwt) {
184
+ throw new Error(body.error || "凭证换取会话失败。");
185
+ }
186
+ return body.jwt;
187
+ }
188
+
140
189
  export async function listAgentLinkAccountMachines({ config, identifier, password, cloudbaseSdk }) {
141
190
  if (!config?.cloudbaseEnvId) throw new Error("当前 Hub 尚未配置账号机器目录。");
142
191
  const sdk = cloudbaseSdk ?? (await import("@cloudbase/js-sdk")).default;
@@ -149,7 +198,13 @@ export async function listAgentLinkAccountMachines({ config, identifier, passwor
149
198
  try {
150
199
  const user = await auth.getCurrentUser();
151
200
  if (!user?.uid) throw new Error("CloudBase 账号登录状态无效。");
152
- return readMachines(app.storage.from(MACHINE_BUCKET), `${user.uid}/machines.json`);
201
+ const database = app.rdb();
202
+ const { data, error } = await database
203
+ .from(MACHINE_TABLE)
204
+ .select("machine_id,name,hostname,enckey,connected_at,created_at")
205
+ .order("updated_at", { ascending: false });
206
+ if (error) throw new Error(`读取账号机器记录失败:${error.message}`);
207
+ return (Array.isArray(data) ? data : []).map(normalizeMachineRow);
153
208
  } finally {
154
209
  await auth.signOut().catch(() => undefined);
155
210
  }
@@ -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,24 +1,73 @@
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
 
11
11
  import {
12
+ exchangeCredentialForSessionJwt,
12
13
  listAgentLinkAccountMachines,
14
+ listAgentLinkAccountMachinesFromHub,
13
15
  loadAgentLinkAccountConfig,
14
16
  registerAgentLinkAccountMachine,
15
17
  } from "./agentlink-account.js";
18
+ import { accountCredentials } from "./agentlink-prompts.js";
19
+
20
+ function savedSessionJwt(hub = OFFICIAL_HUB) {
21
+ try {
22
+ const raw = readFileSync(SESSION_FILE, "utf8").trim();
23
+ if (!raw) return undefined;
24
+ if (!raw.startsWith("{")) {
25
+ return normalizeHub(hub) === normalizeHub(OFFICIAL_HUB) ? raw : undefined;
26
+ }
27
+ const saved = JSON.parse(raw);
28
+ return saved.hub === normalizeHub(hub) && typeof saved.jwt === "string" ? saved.jwt : undefined;
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ function saveSessionJwt(jwt, hub = OFFICIAL_HUB) {
35
+ mkdirSync(STATE_DIR, { recursive: true });
36
+ writeFileSync(SESSION_FILE, JSON.stringify({ hub: normalizeHub(hub), jwt }), { mode: 0o600 });
37
+ chmodSync(SESSION_FILE, 0o600);
38
+ }
39
+
40
+ function clearSessionJwt(hub = OFFICIAL_HUB) {
41
+ try {
42
+ const raw = readFileSync(SESSION_FILE, "utf8").trim();
43
+ if (!raw) {
44
+ rmSync(SESSION_FILE, { force: true });
45
+ return;
46
+ }
47
+ if (!raw.startsWith("{")) {
48
+ if (normalizeHub(hub) === normalizeHub(OFFICIAL_HUB)) rmSync(SESSION_FILE, { force: true });
49
+ return;
50
+ }
51
+ const saved = JSON.parse(raw);
52
+ if (saved.hub === normalizeHub(hub)) rmSync(SESSION_FILE, { force: true });
53
+ } catch {
54
+ rmSync(SESSION_FILE, { force: true });
55
+ }
56
+ }
16
57
 
17
58
  const OFFICIAL_HUB = process.env.AGENTLINK_DEFAULT_HUB || "wss://link.harmopath.com";
18
59
  const STATE_DIR = process.env.AGENTLINK_STATE_DIR || join(homedir(), ".agentlink");
19
60
  const CREDENTIAL_FILE = join(STATE_DIR, "credential");
61
+ const SESSION_FILE = join(STATE_DIR, "session");
20
62
  const args = process.argv.slice(2);
21
63
  const legacyCredentialCommands = process.env.AGENTLINK_ENABLE_LEGACY_CREDENTIALS === "1";
64
+ const forceTui = process.env.AGENTLINK_TUI === "1";
65
+ const noTui =
66
+ !forceTui &&
67
+ (args.includes("--no-tui") ||
68
+ process.env.AGENTLINK_TUI === "0" ||
69
+ process.env.CI === "true" ||
70
+ !process.stdin.isTTY);
22
71
 
23
72
  function value(name) {
24
73
  const index = args.findIndex((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`));
@@ -36,6 +85,13 @@ function normalizeHub(hub) {
36
85
  return url.toString();
37
86
  }
38
87
 
88
+ function httpUrl(hub, pathname) {
89
+ const url = new URL(pathname, hub);
90
+ if (url.protocol === "ws:") url.protocol = "http:";
91
+ if (url.protocol === "wss:") url.protocol = "https:";
92
+ return url;
93
+ }
94
+
39
95
  function savedCredential(hub = OFFICIAL_HUB) {
40
96
  try {
41
97
  const raw = readFileSync(CREDENTIAL_FILE, "utf8").trim();
@@ -89,60 +145,6 @@ async function createSingleCredential(hub) {
89
145
  return credential;
90
146
  }
91
147
 
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
148
  async function loginAndCreateCredential(hub, config) {
147
149
  console.log("[agentlink] 首次使用,请登录 AgentLink 账号。");
148
150
  const { identifier, password } = await accountCredentials();
@@ -154,6 +156,7 @@ async function loginAndCreateCredential(hub, config) {
154
156
  machineName: hostname(),
155
157
  });
156
158
  saveCredential(result.credential, hub);
159
+ if (result.jwt) saveSessionJwt(result.jwt, hub);
157
160
  console.log("[agentlink] 登录成功,这台电脑已加入账号。");
158
161
  return result.credential;
159
162
  }
@@ -208,89 +211,106 @@ async function pair(hub) {
208
211
  });
209
212
  }
210
213
 
211
- if (args[0] === "help" || args.includes("--help") || args.includes("-h")) {
214
+ // ---------------------------------------------------------------------------
215
+ // Command handlers
216
+ // ---------------------------------------------------------------------------
217
+
218
+ async function showHelp() {
212
219
  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`,
220
+ `用法:\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
221
  );
215
- process.exit(0);
216
222
  }
217
223
 
218
- if (args[0] === "status") {
224
+ async function showVersion() {
225
+ try {
226
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
227
+ console.log(pkg.version);
228
+ } catch {
229
+ console.log("0.0.0");
230
+ }
231
+ }
232
+
233
+ async function showStatus() {
219
234
  const hub = value("hub") || OFFICIAL_HUB;
220
235
  console.log(`[agentlink] 登录状态:${savedCredential(hub) ? "已登录" : "未登录"}`);
236
+ console.log(`[agentlink] 会话状态:${savedSessionJwt(hub) ? "已缓存" : "未缓存"}`);
221
237
  console.log(`[agentlink] Hub:${normalizeHub(hub)}`);
222
- process.exit(0);
223
238
  }
224
239
 
225
- if (args[0] === "logout") {
226
- const loggedIn = savedCredential(value("hub") || OFFICIAL_HUB);
240
+ async function doLogout() {
241
+ const hub = value("hub") || OFFICIAL_HUB;
242
+ const loggedIn = savedCredential(hub);
227
243
  if (loggedIn) rmSync(CREDENTIAL_FILE, { force: true });
244
+ clearSessionJwt(hub);
228
245
  console.log(
229
246
  loggedIn ? "[agentlink] 已退出账号;下次启动时需要重新登录。" : "[agentlink] 当前未登录。",
230
247
  );
231
- process.exit(0);
232
248
  }
233
249
 
234
- if (args[0] === "login") {
250
+ async function doLogin() {
235
251
  const hub = value("hub") || OFFICIAL_HUB;
236
252
  if (savedCredential(hub)) {
237
253
  console.log("[agentlink] 已登录。需要切换或修复机器时,请先运行 alink-cli logout。");
238
- process.exit(0);
254
+ return;
239
255
  }
240
256
  const config = await loadAgentLinkAccountConfig(hub);
241
257
  if (config?.mode !== "multi") throw new Error("当前 Hub 不使用账号登录。");
242
258
  await loginAndCreateCredential(hub, config);
243
- process.exit(0);
244
259
  }
245
260
 
246
- if (args[0] === "machines") {
261
+ async function ensureSessionJwt(hub, config) {
262
+ const jwt = savedSessionJwt(hub);
263
+ if (jwt) return jwt;
264
+ console.log("[agentlink] 需要验证 AgentLink 账号密码以获取机器列表。");
265
+ const { identifier, password } = await accountCredentials();
266
+ const loginResponse = await fetch(httpUrl(hub, "/auth/password/login"), {
267
+ method: "POST",
268
+ signal: AbortSignal.timeout(10_000),
269
+ headers: { "Content-Type": "application/json", "X-Requested-With": "agentlink" },
270
+ body: JSON.stringify({ identifier, password, client: "cli" }),
271
+ });
272
+ const login = await loginResponse.json().catch(() => ({}));
273
+ if (!loginResponse.ok || typeof login.jwt !== "string" || !login.jwt) {
274
+ throw new Error(
275
+ login.error === "invalid_account_credentials" ? "账号或密码错误。" : login.error || "账号登录失败。",
276
+ );
277
+ }
278
+ saveSessionJwt(login.jwt, hub);
279
+ return login.jwt;
280
+ }
281
+
282
+ async function doMachines() {
247
283
  const hub = value("hub") || OFFICIAL_HUB;
248
284
  const config = await loadAgentLinkAccountConfig(hub);
249
285
  if (config?.mode !== "multi") throw new Error("当前 Hub 不使用账号机器目录。");
250
- console.log("[agentlink] 查看账号机器,请验证 AgentLink 账号。");
251
- const { identifier, password } = await accountCredentials();
252
- const machines = await listAgentLinkAccountMachines({ config, identifier, password });
286
+ let jwt = savedSessionJwt(hub);
287
+ if (!jwt) {
288
+ const credential = savedCredential(hub);
289
+ if (credential) {
290
+ jwt = await exchangeCredentialForSessionJwt({ hub, credential });
291
+ } else {
292
+ jwt = await ensureSessionJwt(hub, config);
293
+ }
294
+ saveSessionJwt(jwt, hub);
295
+ }
296
+ const machines = await listAgentLinkAccountMachinesFromHub({ hub, jwt });
253
297
  if (machines.length === 0) console.log("[agentlink] 账号下还没有机器。");
254
298
  else {
255
299
  console.log(`[agentlink] 账号下共 ${machines.length} 台机器:`);
256
300
  for (const machine of machines) {
257
- console.log(
258
- `- ${machine.name || machine.hostname || machine.machineId} (${machine.machineId})`,
259
- );
301
+ const onlineMarker = machine.online ? "●" : "○";
302
+ const label = machine.name || machine.hostname || machine.machineId;
303
+ console.log(` ${onlineMarker} ${label} (${machine.machineId})`);
260
304
  }
261
305
  }
262
- process.exit(0);
263
306
  }
264
307
 
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
- }
276
-
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")) {
308
+ async function doLegacy() {
291
309
  process.argv = process.argv.filter((arg) => arg !== "--legacy");
292
310
  await import(new URL("../dist/daemon.js", import.meta.url));
293
- } else {
311
+ }
312
+
313
+ async function doNoTuiStart() {
294
314
  const hub = value("hub") || OFFICIAL_HUB;
295
315
  const explicit = value("token") || process.env.AGENTLINK_TOKEN;
296
316
  const config =
@@ -322,3 +342,96 @@ if (args.includes("--legacy")) {
322
342
  for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => child.kill(signal));
323
343
  process.exitCode = await new Promise((resolve) => child.on("exit", (code) => resolve(code ?? 1)));
324
344
  }
345
+
346
+ async function doTui() {
347
+ const uiPath = fileURLToPath(new URL("../dist/ui.js", import.meta.url));
348
+ if (!existsSync(uiPath)) {
349
+ console.error(
350
+ `[agentlink] 交互式 UI 包不存在: ${uiPath}\n请先运行 pnpm --filter alink-cli run build。`,
351
+ );
352
+ process.exit(1);
353
+ }
354
+ await import(new URL("../dist/ui.js", import.meta.url));
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Router
359
+ // ---------------------------------------------------------------------------
360
+
361
+ async function main() {
362
+ // Strip internal-only flags before dispatching so downstream handlers see clean args.
363
+ const publicArgs = args.filter((a) => a !== "--no-tui");
364
+
365
+ if (publicArgs.includes("--version") || publicArgs.includes("-v")) {
366
+ await showVersion();
367
+ return;
368
+ }
369
+
370
+ if (publicArgs[0] === "help" || publicArgs.includes("--help") || publicArgs.includes("-h")) {
371
+ await showHelp();
372
+ return;
373
+ }
374
+
375
+ if (publicArgs[0] === "status") {
376
+ await showStatus();
377
+ return;
378
+ }
379
+
380
+ if (publicArgs[0] === "logout") {
381
+ await doLogout();
382
+ return;
383
+ }
384
+
385
+ if (publicArgs[0] === "login") {
386
+ await doLogin();
387
+ return;
388
+ }
389
+
390
+ if (publicArgs[0] === "machines") {
391
+ await doMachines();
392
+ return;
393
+ }
394
+
395
+ if (publicArgs[0] === "credential" && legacyCredentialCommands) {
396
+ const hub = value("hub") || OFFICIAL_HUB;
397
+ const command = publicArgs[1] || "status";
398
+ if (command === "path") console.log(CREDENTIAL_FILE);
399
+ else if (command === "status") console.log(savedCredential(hub) ? "已保存凭证" : "未保存凭证");
400
+ else if (command === "show") console.log(savedCredential(hub) || "");
401
+ else if (command === "set" && publicArgs[2] && !/\s/.test(publicArgs[2])) saveCredential(publicArgs[2], hub);
402
+ else if (command === "clear") rmSync(CREDENTIAL_FILE, { force: true });
403
+ else throw new Error("用法: alink-cli credential status|path|show|set <credential>|clear");
404
+ return;
405
+ }
406
+
407
+ if (publicArgs[0] === "qr" && legacyCredentialCommands) {
408
+ const credential =
409
+ value("token") || process.env.AGENTLINK_TOKEN || savedCredential(value("hub") || OFFICIAL_HUB);
410
+ if (!credential) throw new Error("这台电脑还没有凭证,请先运行 npx alink-cli 完成配对。");
411
+ await printQr(credential);
412
+ return;
413
+ }
414
+
415
+ if (publicArgs[0] && !publicArgs[0].startsWith("-")) {
416
+ console.error(`[agentlink] 未知命令:${publicArgs[0]}。运行 alink-cli help 查看帮助。`);
417
+ process.exit(1);
418
+ }
419
+
420
+ if (publicArgs.includes("--legacy")) {
421
+ await doLegacy();
422
+ return;
423
+ }
424
+
425
+ // Non-TTY, CI, or explicit --no-tui: preserve the original foreground daemon behavior.
426
+ if (noTui) {
427
+ await doNoTuiStart();
428
+ return;
429
+ }
430
+
431
+ await doTui();
432
+ }
433
+
434
+ main().catch((err) => {
435
+ console.error(err instanceof Error ? err.message : String(err));
436
+ process.exit(1);
437
+ });