alink-cli 0.7.1 → 0.7.3

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,20 +6,17 @@
6
6
  npx alink-cli
7
7
  ```
8
8
 
9
- 首次运行会在本机生成 E2EE 密钥并打印二维码;在 `https://link.harmopath.com` 登录 GitHub 后扫码或粘贴即可。凭证保存到 `~/.agentlink/credential`(`0600`),之后重复同一命令即可上线。
9
+ 首次运行输入 AgentLink 账号和密码,这台电脑会自动加入账号并上线。之后直接重复 `npx alink-cli` 即可上线;密码不会保存到本机。
10
10
 
11
11
  ```bash
12
12
  npx alink-cli --hub wss://your-hub.example.com
13
13
  npx alink-cli --dir /path/to/project
14
- npx alink-cli --pair
15
- npx alink-cli qr
16
- npx alink-cli credential status
14
+ npx alink-cli logout
17
15
  ```
18
16
 
19
17
  - `--hub`:Hub 地址,默认 `wss://link.harmopath.com`。
20
18
  - `--dir`:默认工作目录,缺省为当前目录。
21
- - `--pair`:忽略已保存凭证并重新配对。
22
- - `--token`:导入完整机器凭证。
19
+ - `logout`:退出当前账号并清除本机登录记录,下次运行重新输入账号密码。
23
20
  - `--legacy`:临时运行旧 daemon;默认始终是 daemon-t3。
24
21
 
25
22
  Node.js 22.16+。连接为出站 WebSocket,无需端口转发;Hub 只转发 E2EE 密文。
@@ -0,0 +1,133 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ const MACHINE_BUCKET = "agentlink-machines";
4
+ const REQUEST_TIMEOUT_MS = 10_000;
5
+
6
+ function httpUrl(hub, pathname) {
7
+ const url = new URL(pathname, hub);
8
+ if (url.protocol === "ws:") url.protocol = "http:";
9
+ if (url.protocol === "wss:") url.protocol = "https:";
10
+ return url;
11
+ }
12
+
13
+ async function json(response) {
14
+ return response.json().catch(() => ({}));
15
+ }
16
+
17
+ export async function loadAgentLinkAccountConfig(hub, fetchFn = fetch) {
18
+ const response = await fetchFn(httpUrl(hub, "/api/config"), {
19
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
20
+ });
21
+ if (!response.ok) throw new Error(`Hub 配置读取失败 (${response.status})。`);
22
+ return json(response);
23
+ }
24
+
25
+ function machineIdFromToken(authToken) {
26
+ try {
27
+ const payload = JSON.parse(
28
+ Buffer.from(authToken.split(".")[1] || "", "base64url").toString("utf8"),
29
+ );
30
+ return typeof payload.mid === "string" ? payload.mid : "";
31
+ } catch {
32
+ return "";
33
+ }
34
+ }
35
+
36
+ async function readMachines(bucket, path) {
37
+ const { data, error } = await bucket.download(path);
38
+ if (error) {
39
+ if (/not found|no such|404/i.test(error.message || "")) return [];
40
+ throw new Error(`读取账号机器列表失败:${error.message}`);
41
+ }
42
+ try {
43
+ const parsed = JSON.parse(await data.text());
44
+ return Array.isArray(parsed.machines) ? parsed.machines : [];
45
+ } catch {
46
+ throw new Error("账号机器列表格式损坏,请在 Web 端移除异常记录后重试。");
47
+ }
48
+ }
49
+
50
+ export async function registerAgentLinkAccountMachine({
51
+ hub,
52
+ config,
53
+ identifier,
54
+ password,
55
+ machineName,
56
+ fetchFn = fetch,
57
+ cloudbaseSdk,
58
+ randomBytesFn = randomBytes,
59
+ }) {
60
+ if (!config?.cloudbaseEnvId) throw new Error("当前 Hub 尚未配置账号机器目录。");
61
+
62
+ const loginResponse = await fetchFn(httpUrl(hub, "/auth/password/login"), {
63
+ method: "POST",
64
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
65
+ headers: { "Content-Type": "application/json", "X-Requested-With": "agentlink" },
66
+ body: JSON.stringify({ identifier, password, client: "cli" }),
67
+ });
68
+ const login = await json(loginResponse);
69
+ if (!loginResponse.ok || typeof login.jwt !== "string" || !login.jwt) {
70
+ throw new Error(
71
+ login.error === "invalid_account_credentials"
72
+ ? "账号或密码错误。"
73
+ : login.error || "账号登录失败。",
74
+ );
75
+ }
76
+
77
+ const mintResponse = await fetchFn(httpUrl(hub, "/api/machines/mint"), {
78
+ method: "POST",
79
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
80
+ headers: {
81
+ Authorization: `Bearer ${login.jwt}`,
82
+ "Content-Type": "application/json",
83
+ "X-Requested-With": "agentlink",
84
+ },
85
+ body: JSON.stringify({ name: machineName }),
86
+ });
87
+ const minted = await json(mintResponse);
88
+ const authToken = typeof minted.authToken === "string" ? minted.authToken : "";
89
+ const machineId =
90
+ typeof minted.machineId === "string" ? minted.machineId : machineIdFromToken(authToken);
91
+ if (!mintResponse.ok || !authToken.startsWith("al1.") || !machineId) {
92
+ throw new Error(minted.error || "机器注册失败。");
93
+ }
94
+
95
+ const sdk = cloudbaseSdk ?? (await import("@cloudbase/js-sdk")).default;
96
+ const app = sdk.init({ env: config.cloudbaseEnvId, region: "ap-shanghai" });
97
+ const auth = app.auth();
98
+ const signedIn = await auth.signInWithPassword(
99
+ identifier.includes("@") ? { email: identifier, password } : { username: identifier, password },
100
+ );
101
+ if (signedIn?.error) throw new Error(signedIn.error.message || "CloudBase 账号登录失败。");
102
+
103
+ try {
104
+ const user = await auth.getCurrentUser();
105
+ if (!user?.uid) throw new Error("CloudBase 账号登录状态无效。");
106
+ const path = `${user.uid}/machines.json`;
107
+ const bucket = app.storage.from(MACHINE_BUCKET);
108
+ const machines = await readMachines(bucket, path);
109
+ const current = machines.find((machine) => machine?.machineId === machineId);
110
+ const enckey = randomBytesFn(32).toString("base64url");
111
+ const machine = {
112
+ machineId,
113
+ name: machineName,
114
+ hostname: machineName,
115
+ createdAt: current?.createdAt ?? Date.now(),
116
+ enckey,
117
+ };
118
+ // ponytail: read-modify-write is enough for normal first-time setup; add
119
+ // versioned retries if concurrent registrations for one account become common.
120
+ const { error } = await bucket.update(
121
+ path,
122
+ JSON.stringify({
123
+ version: 2,
124
+ machines: [machine, ...machines.filter((entry) => entry?.machineId !== machineId)],
125
+ }),
126
+ { contentType: "application/json" },
127
+ );
128
+ if (error) throw new Error(`保存账号机器列表失败:${error.message}`);
129
+ return { credential: `${authToken}.${enckey}`, machineId };
130
+ } finally {
131
+ await auth.signOut().catch(() => undefined);
132
+ }
133
+ }
package/bin/agentlink.js CHANGED
@@ -4,9 +4,15 @@ import { randomBytes } from "node:crypto";
4
4
  import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { hostname, homedir } from "node:os";
6
6
  import { join } from "node:path";
7
+ import { createInterface } from "node:readline/promises";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import WebSocket from "ws";
9
10
 
11
+ import {
12
+ loadAgentLinkAccountConfig,
13
+ registerAgentLinkAccountMachine,
14
+ } from "./agentlink-account.js";
15
+
10
16
  const OFFICIAL_HUB = process.env.AGENTLINK_DEFAULT_HUB || "wss://link.harmopath.com";
11
17
  const STATE_DIR = process.env.AGENTLINK_STATE_DIR || join(homedir(), ".agentlink");
12
18
  const CREDENTIAL_FILE = join(STATE_DIR, "credential");
@@ -65,15 +71,6 @@ function parseCredential(credential) {
65
71
  return { machineToken: parts.slice(0, 3).join("."), enckey: parts[3] };
66
72
  }
67
73
 
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
74
  async function createSingleCredential(hub) {
78
75
  const token = randomBytes(24).toString("base64url");
79
76
  const enckey = randomBytes(32).toString("base64url");
@@ -90,6 +87,75 @@ async function createSingleCredential(hub) {
90
87
  return credential;
91
88
  }
92
89
 
90
+ async function readPassword(label) {
91
+ if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") {
92
+ throw new Error(
93
+ "当前终端无法安全读取密码,请在交互式终端运行,或设置 AGENTLINK_ACCOUNT 和 AGENTLINK_PASSWORD。",
94
+ );
95
+ }
96
+ process.stdout.write(label);
97
+ return new Promise((resolve, reject) => {
98
+ let password = "";
99
+ const wasRaw = process.stdin.isRaw;
100
+ const finish = () => {
101
+ process.stdin.off("data", onData);
102
+ process.stdin.setRawMode(wasRaw);
103
+ process.stdin.pause();
104
+ process.stdout.write("\n");
105
+ };
106
+ const onData = (chunk) => {
107
+ for (const char of String(chunk)) {
108
+ if (char === "\u0003") {
109
+ finish();
110
+ reject(new Error("已取消登录。"));
111
+ return;
112
+ }
113
+ if (char === "\r" || char === "\n") {
114
+ finish();
115
+ resolve(password);
116
+ return;
117
+ }
118
+ if (char === "\u007f" || char === "\b") password = password.slice(0, -1);
119
+ else if (char >= " ") password += char;
120
+ }
121
+ };
122
+ process.stdin.setRawMode(true);
123
+ process.stdin.setEncoding("utf8");
124
+ process.stdin.resume();
125
+ process.stdin.on("data", onData);
126
+ });
127
+ }
128
+
129
+ async function accountCredentials() {
130
+ const fromEnv = process.env.AGENTLINK_ACCOUNT?.trim();
131
+ const passwordFromEnv = process.env.AGENTLINK_PASSWORD;
132
+ if (fromEnv && passwordFromEnv) return { identifier: fromEnv, password: passwordFromEnv };
133
+ if (!process.stdin.isTTY) {
134
+ throw new Error("首次运行需要登录账号,请在交互式终端运行。");
135
+ }
136
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
137
+ const identifier = (await readline.question("AgentLink 账号:")).trim();
138
+ readline.close();
139
+ const password = await readPassword("AgentLink 密码:");
140
+ if (!identifier || !password) throw new Error("账号和密码不能为空。");
141
+ return { identifier, password };
142
+ }
143
+
144
+ async function loginAndCreateCredential(hub, config) {
145
+ console.log("[agentlink] 首次使用,请登录 AgentLink 账号。");
146
+ const { identifier, password } = await accountCredentials();
147
+ const result = await registerAgentLinkAccountMachine({
148
+ hub,
149
+ config,
150
+ identifier,
151
+ password,
152
+ machineName: hostname(),
153
+ });
154
+ saveCredential(result.credential, hub);
155
+ console.log("[agentlink] 登录成功,这台电脑已加入账号。");
156
+ return result.credential;
157
+ }
158
+
93
159
  async function printQr(text) {
94
160
  const { renderQr } = await import(new URL("../dist/qr.js", import.meta.url));
95
161
  renderQr(text);
@@ -130,7 +196,9 @@ async function pair(hub) {
130
196
  socket.close();
131
197
  const credential = `${message.token}.${enckey}`;
132
198
  saveCredential(credential, hub);
133
- console.log(`[agentlink] 配对成功,凭证已保存到 ${CREDENTIAL_FILE}(0600)。`);
199
+ console.log(
200
+ `[agentlink] 配对成功。alp1 配对串只能使用这一次;已转换为本机凭证并保存到 ${CREDENTIAL_FILE}(0600)。`,
201
+ );
134
202
  resolve(credential);
135
203
  }
136
204
  });
@@ -140,11 +208,17 @@ async function pair(hub) {
140
208
 
141
209
  if (args.includes("--help") || args.includes("-h")) {
142
210
  console.log(
143
- `用法: npx alink-cli [--hub <wss://…>] [--dir <目录>] [--pair]\n\n首次运行会显示配对二维码;之后直接复用 ~/.agentlink/credential。\n--legacy 运行旧 daemon;credential status|path|show|set|clear 管理凭证。`,
211
+ `用法: npx alink-cli [--hub <wss://…>] [--dir <目录>]\n npx alink-cli logout\n\n首次运行输入 AgentLink 账号密码,这台电脑会自动加入账号;以后直接运行即可上线。\nlogout 退出当前账号;--legacy 临时运行旧 daemon。`,
144
212
  );
145
213
  process.exit(0);
146
214
  }
147
215
 
216
+ if (args[0] === "logout") {
217
+ rmSync(CREDENTIAL_FILE, { force: true });
218
+ console.log("[agentlink] 已退出账号;下次启动时需要重新登录。");
219
+ process.exit(0);
220
+ }
221
+
148
222
  if (args[0] === "credential") {
149
223
  const hub = value("hub") || OFFICIAL_HUB;
150
224
  const command = args[1] || "status";
@@ -171,11 +245,20 @@ if (args.includes("--legacy")) {
171
245
  } else {
172
246
  const hub = value("hub") || OFFICIAL_HUB;
173
247
  const explicit = value("token") || process.env.AGENTLINK_TOKEN;
248
+ const config =
249
+ explicit || savedCredential(hub) || args.includes("--pair")
250
+ ? null
251
+ : await loadAgentLinkAccountConfig(hub);
174
252
  const credential =
175
253
  explicit ||
176
254
  (!args.includes("--pair") && savedCredential(hub)) ||
177
- ((await hubMode(hub)) === "multi" ? await pair(hub) : await createSingleCredential(hub));
255
+ (config?.mode === "multi"
256
+ ? await loginAndCreateCredential(hub, config)
257
+ : await createSingleCredential(hub));
178
258
  if (explicit && explicit !== savedCredential(hub)) saveCredential(explicit, hub);
259
+ if (!explicit && savedCredential(hub) && !args.includes("--pair")) {
260
+ console.log("[agentlink] 已登录,正在让这台电脑上线。");
261
+ }
179
262
  const { machineToken, enckey } = parseCredential(credential);
180
263
  const daemon = fileURLToPath(new URL("../dist/bin.mjs", import.meta.url));
181
264
  const cwd = value("dir") || process.cwd();
package/dist/bin.mjs CHANGED
@@ -2871,7 +2871,7 @@ const RelayServerGroup = HttpApiGroup.make("server").add(HttpApiEndpoint.post("p
2871
2871
  success: RelayPublishResponse,
2872
2872
  error: RelayAgentActivityPublishErrors
2873
2873
  }).annotate(OpenApi.Summary, "Publish agent activity")).annotate(OpenApi.Description, "Environment-authenticated activity publication.").middleware(RelayEnvironmentAuth);
2874
- HttpApi.make("RelayApi").add(RelayHealthGroup, RelayMetadataGroup, RelayMobileGroup, RelayClientGroup, RelayTokenGroup, RelayDpopClientGroup, RelayServerGroup).annotate(OpenApi.Title, "T3 Code Relay API").annotate(OpenApi.Version, "1.0.0").annotate(OpenApi.Description, "Control-plane API for linking T3 environments, connecting authorized clients, and publishing agent activity.");
2874
+ HttpApi.make("RelayApi").add(RelayHealthGroup, RelayMetadataGroup, RelayMobileGroup, RelayClientGroup, RelayTokenGroup, RelayDpopClientGroup, RelayServerGroup).annotate(OpenApi.Title, "AgentLink Relay API").annotate(OpenApi.Version, "1.0.0").annotate(OpenApi.Description, "Control-plane API for linking T3 environments, connecting authorized clients, and publishing agent activity.");
2875
2875
  //#endregion
2876
2876
  //#region ../t3-contracts/src/environmentHttp.ts
2877
2877
  const OptionalBearerHeaders = Schema$1.Struct({
@@ -7445,20 +7445,20 @@ const trimmedNonEmpty = (annotations, maxLength) => {
7445
7445
  return encoded.pipe(Schema$1.decodeTo(encoded, SchemaTransformation.trim()));
7446
7446
  };
7447
7447
  const T3ProjectFileScript = Schema$1.Struct({
7448
- name: trimmedNonEmpty({ description: "Display name for the script, shown in the T3 Code scripts menu." }),
7449
- command: trimmedNonEmpty({ description: "Shell command executed in a T3 Code terminal at the project root." }),
7448
+ name: trimmedNonEmpty({ description: "Display name for the script, shown in the AgentLink scripts menu." }),
7449
+ command: trimmedNonEmpty({ description: "Shell command executed in an AgentLink terminal at the project root." }),
7450
7450
  icon: Schema$1.optionalKey(ProjectScriptIcon.annotate({ description: "Icon shown next to the script in the scripts menu. Defaults to \"play\"." })),
7451
7451
  runOnWorktreeCreate: Schema$1.optionalKey(Schema$1.Boolean.annotate({ description: "When true, the script runs automatically after a worktree is created for a new thread." })),
7452
7452
  previewUrl: Schema$1.optionalKey(trimmedNonEmpty({ description: "URL opened in the in-app browser preview when this script runs. Only honored on the desktop build." })),
7453
7453
  autoOpenPreview: Schema$1.optionalKey(Schema$1.Boolean.annotate({ description: "When true, automatically open the preview panel at `previewUrl` the moment the script starts." }))
7454
- }).annotate({ description: "A project script that team members can import into T3 Code." });
7454
+ }).annotate({ description: "A project script that team members can import into AgentLink." });
7455
7455
  const T3ProjectFile = Schema$1.Struct({
7456
7456
  $schema: Schema$1.optionalKey(Schema$1.String.annotate({ description: `URL of the JSON Schema for this file, typically "${T3_PROJECT_FILE_SCHEMA_URL}".` })),
7457
- iconPath: Schema$1.optionalKey(trimmedNonEmpty({ description: "Workspace-relative path to the project icon (e.g. \"assets/logo.svg\"). Checked before T3 Code's built-in icon locations." }, T3_PROJECT_FILE_PATH_MAX_LENGTH)),
7458
- scripts: Schema$1.optionalKey(Schema$1.Array(T3ProjectFileScript).annotate({ description: "Project scripts shared with everyone who opens this repository in T3 Code." }).check(Schema$1.isMaxLength(T3_PROJECT_FILE_MAX_SCRIPTS)))
7457
+ iconPath: Schema$1.optionalKey(trimmedNonEmpty({ description: "Workspace-relative path to the project icon (e.g. \"assets/logo.svg\"). Checked before AgentLink's built-in icon locations." }, T3_PROJECT_FILE_PATH_MAX_LENGTH)),
7458
+ scripts: Schema$1.optionalKey(Schema$1.Array(T3ProjectFileScript).annotate({ description: "Project scripts shared with everyone who opens this repository in AgentLink." }).check(Schema$1.isMaxLength(T3_PROJECT_FILE_MAX_SCRIPTS)))
7459
7459
  }).annotate({
7460
7460
  title: "T3 project file",
7461
- description: "Checked-in project configuration for T3 Code (t3.json at the repository root). See https://t3.codes for documentation."
7461
+ description: "Checked-in project configuration for AgentLink (t3.json at the repository root)."
7462
7462
  });
7463
7463
  //#endregion
7464
7464
  //#region ../t3-contracts/src/project.ts
@@ -17921,15 +17921,7 @@ const renderTerminalQrCode = (value, margin = 2) => {
17921
17921
  }
17922
17922
  return rows.join("\n");
17923
17923
  };
17924
- const formatHeadlessServeOutput = (accessInfo) => [
17925
- "T3 Code server is ready.",
17926
- `Connection string: ${accessInfo.connectionString}`,
17927
- `Token: ${accessInfo.token}`,
17928
- `Pairing URL: ${accessInfo.pairingUrl}`,
17929
- "",
17930
- renderTerminalQrCode(accessInfo.pairingUrl),
17931
- ""
17932
- ].join("\n");
17924
+ const formatHeadlessServeOutput = (_accessInfo) => "AgentLink server is ready.\n";
17933
17925
  const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessInfo")(function* () {
17934
17926
  const serverConfig = yield* ServerConfig;
17935
17927
  const httpServer = yield* HttpServer.HttpServer;
@@ -68642,7 +68634,7 @@ const runServerCommand = (flags, options) => Effect.gen(function* () {
68642
68634
  return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
68643
68635
  });
68644
68636
  const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe(Command.withDescription("Run the T3 Code server."), Command.withHandler((flags) => runServerCommand(flags)));
68645
- const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe(Command.withDescription("Run the T3 Code server without opening a browser and print headless pairing details."), Command.withHandler((flags) => runServerCommand(flags, {
68637
+ const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe(Command.withDescription("Run the T3 Code server without opening a browser."), Command.withHandler((flags) => runServerCommand(flags, {
68646
68638
  startupPresentation: "headless",
68647
68639
  forceAutoBootstrapProjectFromCwd: false
68648
68640
  })));