cofluxd 2.15.0 → 2.16.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
@@ -50,6 +50,31 @@ cofluxd up --key <join key>
50
50
 
51
51
  Get the command, join key included, from **Add device → Headless** in the Coflux desktop app. The key is single use and valid for one hour; the host joins your account as soon as the command runs. Without `--key`, `cofluxd up` prints an authorization link instead: open it in a browser signed in to your account. Use `cofluxd status`, `cofluxd doctor`, or `cofluxd logs -f` to inspect it.
52
52
 
53
+ `cofluxd up` picks how the service runs:
54
+
55
+ - **macOS**: a launchd agent.
56
+ - **Linux with systemd**: systemd user units. If `systemctl --user` does not work in your session, `up` stops with an error instead of reporting success.
57
+ - **Linux without systemd** (a container, WSL without systemd, Alpine/OpenRC): cofluxd runs the service itself. `cofluxd status` shows it as `running (self-managed, no autostart)`. If the service process crashes it is restarted with terminals kept. `status`, `logs`, `restart`, `down` and `uninstall` all work, but nothing starts it again after a reboot: run `cofluxd up` again, or use `cofluxd run` under your own supervisor.
58
+
59
+ ## Containers and other supervisors: `cofluxd run`
60
+
61
+ `cofluxd run` takes the same flags as `up`, downloads the binaries if needed, and runs the service in the foreground with its log on stdout and stderr. An unregistered device prints its authorization link in that output. `run` exits non-zero when its terminals' host stops, so the outer supervisor (a Docker restart policy, s6, supervisord, tmux) can start it again; SIGTERM or Ctrl-C stops everything and exits 0. A rejected join key is reported and `run` keeps running, so a restart policy never loops on a spent key. `run` refuses to start while another Coflux service runs for the same `~/.coflux`; stop that one with `cofluxd down` first.
62
+
63
+ ```dockerfile
64
+ FROM node:22
65
+ RUN npm install -g cofluxd
66
+ VOLUME /root/.coflux
67
+ ENTRYPOINT ["cofluxd", "run"]
68
+ ```
69
+
70
+ ```sh
71
+ docker build -t coflux-device .
72
+ docker run -d --init --restart unless-stopped --name coflux-device \
73
+ -v coflux-home:/root/.coflux coflux-device --key <join key>
74
+ ```
75
+
76
+ Use `--init` (or `init: true` in Compose): Node does not reap orphaned processes when it is PID 1. Keep `~/.coflux` on a volume so the device keeps its identity and binaries across restarts; once the device has joined, the key is ignored. `docker stop` ends the terminals and exits cleanly.
77
+
53
78
  ## Operate your workspaces
54
79
 
55
80
  ```sh
@@ -82,9 +107,9 @@ The CLI bundled with the desktop app can reuse the app's login through a local c
82
107
 
83
108
  ## Upgrades and terminal lifetime
84
109
 
85
- Updating this npm package does not end running terminals. `cofluxd update` downloads runtime artifacts without restarting the Supervisor that owns the PTYs. Apply that update with `cofluxd restart` after your tasks finish.
110
+ Updating this npm package does not end running terminals. `cofluxd update` downloads the new version without restarting anything; it tells you when `cofluxd restart` is needed to apply it.
86
111
 
87
- `cofluxd restart` and `cofluxd down` end local terminal processes. The desktop app stays online in the background; fully quitting or signing out ends its local terminals after confirmation.
112
+ `cofluxd restart` keeps terminals open. `cofluxd restart --ptyd` and `cofluxd down` end local terminal processes. The desktop app stays online in the background; fully quitting or signing out ends its local terminals after confirmation.
88
113
 
89
114
  Starting with 1.0, operation commands such as `cofluxd terminal` and `cofluxd login` have been removed. Use `coflux terminal` and `coflux login`. The MCP interface has also been removed in favor of the CLI.
90
115
 
@@ -7,13 +7,23 @@ import net from "node:net";
7
7
  import { hostname } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import readline from "node:readline";
10
+ import { error as printError, success } from "./output.mjs";
11
+
12
+ /** An error with the next step to show under it. */
13
+ function cliError(message, next) {
14
+ const error = new Error(message);
15
+ if (next) error.next = next;
16
+ return error;
17
+ }
18
+ const USAGE_NEXT = "Run coflux --help for usage.";
19
+ const LOGIN_NEXT = "Sign in to the Coflux app, or run coflux login.";
10
20
 
11
21
  /* --------------------------------- 实体标识 -------------------------------- */
12
22
  // `coflux:<kind>:<hex>`:设备 / 项目 / 工作区 / 终端 ID 的可粘贴短形式,hex 是 ID 的前几位
13
23
  // (生成时固定取前 8 位)。解析大小写不敏感并归一成小写;凡是收 ID 的地方都收标识。
14
24
  //
15
25
  // 规则是纯拼接,故各端各自本地生成,不上协议:Rust 侧同一份规则在 crates/cli/src/handle.rs 与
16
- // crates/worker/src/handle.rs——两版 CLI 的输出是逐字对齐的契约,改一边必须改另一边。
26
+ // crates/runtime/src/handle.rs——两版 CLI 的输出是逐字对齐的契约,改一边必须改另一边。
17
27
  const HANDLE_KINDS = ["device", "project", "workspace", "terminal"];
18
28
 
19
29
  /** `id` 的标识。空进空出:缺坐标时不能造出 `coflux:x:` 这样的半截标识。 */
@@ -44,22 +54,26 @@ export function matchesTarget(target, id, kind) {
44
54
  }
45
55
 
46
56
  /** 筛选参数拿到了别的类型的标识:说清楚,不要打印空列表。不是标识的一律放行(那就是个 ID)。 */
47
- const HANDLE_LABELS = { device: "设备", project: "项目", workspace: "工作区", terminal: "终端" };
48
57
  export function checkFilterHandle(flag, expected, target) {
49
58
  const handle = parseHandle(target);
50
59
  if (handle && handle.kind !== expected) {
51
- throw new Error(`--${flag} 需要${HANDLE_LABELS[expected]}标识或${HANDLE_LABELS[expected]} ID,给的是${HANDLE_LABELS[handle.kind]}标识 ${target}`);
60
+ throw cliError(
61
+ `--${flag} needs a ${expected} id or handle, but ${target} is a ${handle.kind} handle.`,
62
+ `Run coflux ${expected === "workspace" ? "workspace" : "device"} list to find the ${expected} id.`,
63
+ );
52
64
  }
53
65
  }
54
66
 
55
67
  function origin(raw) {
56
68
  const url = new URL(raw.replace(/^wss:/, "https:").replace(/^ws:/, "http:"));
57
- if (url.username || url.password) throw new Error("服务器地址不能包含凭据");
58
- if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) throw new Error("服务器必须使用 HTTPS(本机开发可用 HTTP)");
69
+ if (url.username || url.password) throw cliError("The server URL must not contain credentials.", "Pass it as --server https://<host>.");
70
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) {
71
+ throw cliError("The server URL must use HTTPS.", "Use https://, or http://localhost for local development.");
72
+ }
59
73
  return url.origin;
60
74
  }
61
75
  function unwrap(body) {
62
- if (body?.ok !== true) throw new Error(body?.error || "账号请求失败");
76
+ if (body?.ok !== true) throw new Error(body?.error || "The account request failed.");
63
77
  return body.value;
64
78
  }
65
79
  async function request(server, path, token, body, timeout) {
@@ -71,10 +85,10 @@ function broker(home, body, timeout) {
71
85
  const socket = net.createConnection(join(home, "client.sock"));
72
86
  let text = "";
73
87
  socket.setEncoding("utf8");
74
- socket.setTimeout(timeout, () => socket.destroy(new Error("账号请求超时,请查询操作结果")));
75
- socket.on("error", (error) => reject(error.code === "ENOENT" || error.code === "ECONNREFUSED" ? new Error("请先登录 Coflux 应用或运行 coflux login") : error));
88
+ socket.setTimeout(timeout, () => socket.destroy(cliError("The account request timed out.", "Check whether it took effect before you try again.")));
89
+ socket.on("error", (error) => reject(error.code === "ENOENT" || error.code === "ECONNREFUSED" ? cliError("You are not signed in.", LOGIN_NEXT) : error));
76
90
  socket.on("connect", () => socket.write(JSON.stringify(body) + "\n"));
77
- socket.on("data", (chunk) => { text += chunk; if (Buffer.byteLength(text) > 8 * 1024 * 1024) socket.destroy(new Error("响应过大")); });
91
+ socket.on("data", (chunk) => { text += chunk; if (Buffer.byteLength(text) > 8 * 1024 * 1024) socket.destroy(new Error("The response was too large.")); });
78
92
  socket.on("end", () => { try { resolve(unwrap(JSON.parse(text))); } catch (error) { reject(error); } });
79
93
  });
80
94
  }
@@ -83,7 +97,11 @@ function broker(home, body, timeout) {
83
97
  // redirect with PKCE (S256), or a paste code when the browser cannot reach back (SSH, or forced with
84
98
  // COFLUX_LOGIN_PASTE=1). Same requests, same copy as the Rust CLI (crates/cli/src/browser_login.rs).
85
99
 
86
- const LOGIN_FAILURES = { not_allowed: "该邮箱未开通 Coflux", not_verified: "该账号的邮箱未经验证,无法登录", cancelled: "已取消登录" };
100
+ const LOGIN_FAILURES = {
101
+ not_allowed: "This email address has no Coflux access.",
102
+ not_verified: "This account's email address is not verified.",
103
+ cancelled: "Sign-in was cancelled.",
104
+ };
87
105
 
88
106
  function prefersPaste() {
89
107
  return ["COFLUX_LOGIN_PASTE", "SSH_CONNECTION", "SSH_TTY", "SSH_CLIENT"].some((name) => !!process.env[name]);
@@ -104,7 +122,7 @@ function escapeHtml(value) {
104
122
  }
105
123
 
106
124
  function callbackPage(title, body) {
107
- return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escapeHtml(title)} · Coflux</title><style>:root{color-scheme:light dark}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font:15px/1.5 -apple-system,BlinkMacSystemFont,sans-serif}main{text-align:center;padding:24px}h1{font-size:18px}</style></head><body><main><h1>${escapeHtml(title)}</h1><p>${escapeHtml(body)}</p></main></body></html>`;
125
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(title)} - Coflux</title><style>:root{color-scheme:light dark}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font:15px/1.5 -apple-system,BlinkMacSystemFont,sans-serif}main{text-align:center;padding:24px}h1{font-size:18px}</style></head><body><main><h1>${escapeHtml(title)}</h1><p>${escapeHtml(body)}</p></main></body></html>`;
108
126
  }
109
127
 
110
128
  function listenLoopback() {
@@ -141,19 +159,19 @@ async function browserLogin(server) {
141
159
  }
142
160
  const page = new URL(registered.url);
143
161
  // Only ever open a page on the server we are logging into.
144
- if (page.origin !== server) { listener?.close(); throw new Error("服务器返回的登录地址不在该服务器上"); }
162
+ if (page.origin !== server) { listener?.close(); throw cliError("The server returned a sign-in page on another host.", "Check the --server URL, then run coflux login again."); }
145
163
  const exchange = (code) => request(server, "/api/client/login/exchange", null, { protocolVersion: 1, code, codeVerifier: verifier }, 30000);
146
- process.stderr.write(`在浏览器中打开以下地址完成登录(Ctrl-C 取消):\n ${page.href}\n`);
164
+ process.stderr.write(`To sign in, open this page in your browser:\n\n ${page.href}\n\nPress Ctrl-C to cancel.\n`);
147
165
  if (!listener) {
148
- process.stderr.write("登录后页面会显示一次性登录码。\n");
149
- const code = await askLine("粘贴登录码:");
150
- if (!code) throw new Error("没有输入登录码");
166
+ process.stderr.write("After you sign in, the page shows a one-time code.\n");
167
+ const code = await askLine("Paste the code: ");
168
+ if (!code) throw cliError("No code entered.", "Run coflux login again.");
151
169
  return exchange(code);
152
170
  }
153
171
  openBrowser(page.href);
154
172
  const waitMs = typeof registered.expiresAt === "number" && registered.expiresAt > Date.now() ? registered.expiresAt - Date.now() : 600000;
155
173
  return new Promise((resolve, reject) => {
156
- const timer = setTimeout(() => finish(new Error("登录超时,请重新运行 coflux login")), waitMs);
174
+ const timer = setTimeout(() => finish(cliError("Sign-in timed out.", "Run coflux login again.")), waitMs);
157
175
  let done = false;
158
176
  function finish(error, value) {
159
177
  if (done) return;
@@ -166,17 +184,17 @@ async function browserLogin(server) {
166
184
  listener.on("request", (req, res) => {
167
185
  const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
168
186
  const answer = (status, title, body) => { res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); res.end(callbackPage(title, body)); };
169
- if (req.method !== "GET" || url.pathname !== "/callback" || url.searchParams.get("state") !== state) { answer(404, "页面不存在", "这个地址只用于 Coflux 登录回调。"); return; }
187
+ if (req.method !== "GET" || url.pathname !== "/callback" || url.searchParams.get("state") !== state) { answer(404, "Not found", "This address only serves the Coflux sign-in callback."); return; }
170
188
  const failed = url.searchParams.get("error");
171
189
  if (failed) {
172
- const message = LOGIN_FAILURES[failed] ?? "登录未完成,请重试";
173
- answer(200, "登录未完成", `${message}。可以关闭此页面,回到终端。`);
174
- finish(new Error(message));
190
+ const message = LOGIN_FAILURES[failed] ?? "Sign-in did not complete.";
191
+ answer(200, "Sign-in not completed", `${message} You can close this page and return to the terminal.`);
192
+ finish(cliError(message, "Run coflux login again."));
175
193
  return;
176
194
  }
177
195
  exchange(url.searchParams.get("code") ?? "").then(
178
- (value) => { answer(200, "已登录", "已登录,可以回到终端。"); finish(null, value); },
179
- (error) => { answer(200, "登录未完成", "登录未完成,请回到终端查看原因。"); finish(error); },
196
+ (value) => { answer(200, "Signed in", "You are signed in. You can return to the terminal."); finish(null, value); },
197
+ (error) => { answer(200, "Sign-in not completed", "Sign-in did not complete. See the terminal for the reason."); finish(error); },
180
198
  );
181
199
  });
182
200
  });
@@ -204,22 +222,24 @@ export function handlesAccountCommand(positionals, flags, home) {
204
222
  export async function runAccountCommand(positionals, flags, home) {
205
223
  const [command, sub = "list", id] = positionals;
206
224
  const sessionPath = join(home, "cli-session.json");
207
- const required = (key) => { if (!flags[key]) throw new Error(`缺少 --${key}`); return flags[key]; };
208
- const target = () => { if (!id) throw new Error("缺少目标 ID"); return id; };
225
+ const required = (key) => { if (!flags[key]) throw cliError(`Missing --${key}.`, USAGE_NEXT); return flags[key]; };
226
+ const target = () => { if (!id) throw cliError("Missing id.", USAGE_NEXT); return id; };
209
227
  // `project import <path>`: the path is resolved on the **target device** (`~` expansion and
210
228
  // `git rev-parse --show-toplevel` both happen there), so the CLI only checks its shape — the same
211
229
  // rule as `device exec --cwd`. Expanding it here would resolve the caller's home on the wrong machine.
212
230
  const importPath = () => {
213
231
  const value = (id ?? "").trim();
214
- if (!value) throw new Error('缺少要导入的路径(导入当前目录写 coflux project import "$PWD")');
215
- if (!(value.startsWith("/") || value === "~" || value.startsWith("~/"))) throw new Error('路径要绝对路径或 ~ 开头(它在目标设备上解析);导入当前目录写 coflux project import "$PWD"');
232
+ if (!value) throw cliError("Missing path.", 'To import the current directory, run coflux project import "$PWD".');
233
+ if (!(value.startsWith("/") || value === "~" || value.startsWith("~/"))) {
234
+ throw cliError("The path must be absolute or start with ~, because it is resolved on the device.", 'To import the current directory, run coflux project import "$PWD".');
235
+ }
216
236
  return value;
217
237
  };
218
238
  // `--device` falls back to the daemon-issued COFLUX_DEVICE_ID; empty on both sides is an error,
219
239
  // never a guess — silently importing onto the wrong machine is worse than failing.
220
240
  const deviceTarget = () => {
221
241
  const value = (flags.device || process.env.COFLUX_DEVICE_ID || "").trim();
222
- if (!value) throw new Error("缺少设备:请加 --device <id>(coflux device list 可以看到)");
242
+ if (!value) throw cliError("Missing device.", "Pass --device <id>. Run coflux device list to see your devices.");
223
243
  return value;
224
244
  };
225
245
  const print = (value) => console.log(JSON.stringify(value));
@@ -228,15 +248,15 @@ export async function runAccountCommand(positionals, flags, home) {
228
248
  // No credential flags: sign in through the browser (loopback + PKCE, or a paste code over SSH).
229
249
  if (!flags.username && !flags["password-stdin"]) {
230
250
  const value = await browserLogin(server);
231
- if (!value?.token) throw new Error("服务器没有返回会话");
251
+ if (!value?.token) throw cliError("The server did not return a session.", "Run coflux login again.");
232
252
  saveSession(home, sessionPath, { server, token: value.token, accountId: value.accountId });
233
- console.log(`已登录为 ${value.login || "当前账号"}`);
253
+ success(value.login ? `Signed in as ${value.login}` : "Signed in");
234
254
  return;
235
255
  }
236
256
  const username = required("username");
237
- if (!flags["password-stdin"]) throw new Error("用 --password-stdin 从标准输入读取密码;密码不进入命令参数或配置文件");
257
+ if (!flags["password-stdin"]) throw cliError("Missing --password-stdin.", "Pipe the password on stdin and pass --password-stdin.");
238
258
  let password = "";
239
- for await (const chunk of process.stdin) { password += chunk; if (Buffer.byteLength(password) > 4096) throw new Error("密码过长"); if (password.includes("\n")) break; }
259
+ for await (const chunk of process.stdin) { password += chunk; if (Buffer.byteLength(password) > 4096) throw cliError("The password is too long.", "Check what is piped to stdin."); if (password.includes("\n")) break; }
240
260
  const value = await request(server, "/api/client/login", null, { protocolVersion: 1, username, password: password.split("\n", 1)[0].replace(/\r$/, "") }, 30000);
241
261
  saveSession(home, sessionPath, { server, token: value.token, accountId: value.accountId });
242
262
  print({ accountId: value.accountId, server });
@@ -248,16 +268,16 @@ export async function runAccountCommand(positionals, flags, home) {
248
268
  // `terminal.wait` 与 `device.exec` 都可能在中心侧阻塞到 600 秒;其余账号操作 40 秒足够。
249
269
  const timeout = operation.op === "terminal.wait" || operation.op === "device.exec" ? 610000 : 40000;
250
270
  if (!session) {
251
- if (flags.server) throw new Error("请先登录指定服务器");
271
+ if (flags.server) throw cliError("You are not signed in to that server.", "Run coflux login --server <url> first.");
252
272
  return broker(home, body, timeout);
253
273
  }
254
274
  const server = origin(session.server);
255
- if (flags.server && origin(flags.server) !== server) throw new Error("目标服务器与登录记录不一致,请先登录目标服务器");
256
- if (!session.token) throw new Error("请先登录");
275
+ if (flags.server && origin(flags.server) !== server) throw cliError("You are signed in to a different server.", "Run coflux login --server <url> first.");
276
+ if (!session.token) throw cliError("You are not signed in.", "Run coflux login.");
257
277
  return request(server, "/api/client/command", session.token, body, timeout);
258
278
  };
259
279
  if (command === "logout") {
260
- if (!session) throw new Error("此 CLI 未单独登录;应用账号请在 Coflux 中退出");
280
+ if (!session) throw cliError("This CLI is not signed in on its own.", "To sign out of the app account, sign out in Coflux.");
261
281
  await call({ op: "logout" });
262
282
  fs.rmSync(sessionPath);
263
283
  print({ loggedOut: true });
@@ -269,21 +289,23 @@ export async function runAccountCommand(positionals, flags, home) {
269
289
  // (device offline, capability missing, bad cwd, timeout, bad arguments) all exit 255, so a caller's
270
290
  // shell test can tell "the remote command returned 1" from "it never ran".
271
291
  if (command === "device" && sub === "exec") {
272
- const fail = async (message) => { await writeAll(process.stderr, `✗ ${message}\n`); process.exit(255); };
292
+ const fail = (message, next = USAGE_NEXT) => { printError(message, next); process.exit(255); };
273
293
  let value;
274
294
  try {
275
- if (!id) throw new Error("缺少设备 ID(coflux device list 可以看到)");
295
+ if (!id) throw cliError("Missing device id.", "Run coflux device list to see your devices.");
276
296
  const cmd = required("cmd");
277
297
  const timeout = Number(flags.timeout ?? 60);
278
- // 上限在这里就说清楚,别让中心的入参校验回一句「请求失败」。
279
- if (!Number.isInteger(timeout)) throw new Error("--timeout 必须是整数秒");
280
- if (timeout < 1 || timeout > 600) throw new Error("--timeout 取 1-600 秒;更久、或需要用户看见的长任务请改用 coflux terminal new");
298
+ // State the limits here rather than let the server's validation answer with a bare failure.
299
+ if (!Number.isInteger(timeout)) throw cliError("--timeout must be a whole number of seconds.", "Use a value from 1 to 600.");
300
+ if (timeout < 1 || timeout > 600) {
301
+ throw cliError("--timeout must be between 1 and 600 seconds.", "For longer work the user should see, open a terminal with coflux terminal new.");
302
+ }
281
303
  value = await call({ op: "device.exec", deviceId: id, command: cmd, cwd: flags.cwd ?? "", timeout });
282
304
  } catch (error) {
283
- await fail(error.message);
305
+ fail(error.message, error.next);
284
306
  }
285
307
  const exitCode = Number(value?.exitCode);
286
- if (!Number.isInteger(exitCode)) await fail("设备回执缺少退出码(中心版本过旧?)");
308
+ if (!Number.isInteger(exitCode)) fail("The device did not report an exit code.", "The server may need an update; try again later.");
287
309
  await writeAll(process.stdout, String(value.stdout ?? ""));
288
310
  await writeAll(process.stderr, String(value.stderr ?? ""));
289
311
  await writeAll(process.stdout, `# exit=${exitCode}\n`);
@@ -307,9 +329,11 @@ export async function runAccountCommand(positionals, flags, home) {
307
329
  if (sub === "wait") operation = { op: "terminal.wait", terminalId: target(), timeout: Number(flags.timeout ?? 30) };
308
330
  if (["stop", "remove"].includes(sub)) operation = { op: `terminal.${sub}`, terminalId: target() };
309
331
  }
310
- if (!operation && command !== "whoami" && command !== "ports" && sub !== "list") throw new Error("未知账号命令");
332
+ if (!operation && command !== "whoami" && command !== "ports" && sub !== "list") throw cliError(`Unknown command: ${positionals.join(" ")}`, "Run coflux --help to see the commands.");
311
333
  // `project.import` is addressed by device like `snapshot`, so `--device` is an input to it rather than a filter.
312
- if (operation && ((flags.device && operation.op !== "project.import") || (flags.workspace && operation.op !== "terminal.new"))) throw new Error("目标 ID 已确定作用范围,请不要附加设备或工作区筛选参数");
334
+ if (operation && ((flags.device && operation.op !== "project.import") || (flags.workspace && operation.op !== "terminal.new"))) {
335
+ throw cliError("--device and --workspace cannot be combined with an id.", "Drop the filter; the id already names the target.");
336
+ }
313
337
  let value = await call(operation || { op: "snapshot" });
314
338
  if (command === "whoami") value = { accountId: value.accountId };
315
339
  else if (sub === "list" || command === "ports") {