cofluxd 2.14.0 → 2.16.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/cofluxd.mjs CHANGED
@@ -1,14 +1,20 @@
1
1
  #!/usr/bin/env node
2
- // cofluxd —— coflux daemon 管理 CLI。
3
- // daemon 是三个 Rust 进程(ptyd 持 PTY、长生;supervisor 可随时替换;worker 频繁热升级,零 node 运行时);
4
- // 本 CLI 只负责装/起/停/升级(用一下,不常驻)。systemd(Linux user) / launchd(macOS LaunchAgent),
5
- // ptyd 与 supervisor 各自一份服务文件:重启 supervisor 不带走 ptyd,终端留在里面。
2
+ // cofluxd: installs, starts, stops and updates the Coflux service on a device. It runs for one
3
+ // command and never stays resident.
4
+ //
5
+ // The service is three Rust processes: ptyd owns every PTY and lives long; the launcher owns the
6
+ // runtime version pointer and rollback and rarely changes; the runtime is updated by the server.
7
+ // Three service backends start them:
8
+ // - launchd (macOS) and systemd user units (Linux with systemd booted): ptyd and the launcher each
9
+ // get their own unit, so restarting the launcher leaves the terminals in ptyd.
10
+ // - self-managed (Linux without systemd: containers, WSL, OpenRC): `coflux-launcher watch` starts
11
+ // and supervises both. `cofluxd up` runs it in the background; `cofluxd run` in the foreground.
6
12
  import { parseArgs } from "node:util";
7
13
  import { homedir, hostname, platform, arch } from "node:os";
8
- import { join, dirname } from "node:path";
14
+ import { join, dirname, basename } from "node:path";
9
15
  import { createInterface } from "node:readline/promises";
10
16
  import fs from "node:fs";
11
- import { spawnSync } from "node:child_process";
17
+ import { spawn, spawnSync } from "node:child_process";
12
18
  import dns from "node:dns/promises";
13
19
  import net from "node:net";
14
20
  import tls from "node:tls";
@@ -22,9 +28,10 @@ import {
22
28
  parseReleaseManifestEntry,
23
29
  verifyReleaseArtifact,
24
30
  } from "./release-trust.mjs";
25
- import { executorRuntime, plistXml, ptydPlistXml, ptydSystemdUnit, systemdUnit } from "./service-unit.mjs";
31
+ import { executorRuntime, plistXml, ptydPlistXml, ptydSystemdUnit, systemdUnit, watcherEnv } from "./service-unit.mjs";
32
+ import { bold, duration, error as printError, fail, info, step, success, table, warn } from "./output.mjs";
26
33
 
27
- // 默认中心服务(公共 SaaS);自托管用 --server 覆盖。
34
+ // The public server; self-hosted servers pass --server.
28
35
  const DEFAULT_SERVER = "wss://api.coflux.dev/daemon";
29
36
 
30
37
  const REPO = "myWsq/coflux";
@@ -41,44 +48,77 @@ const RELEASE_ARCHIVE_BASE = (
41
48
  const MAX_RELEASE_METADATA_BYTES = 1024 * 1024;
42
49
  const HOME = process.env.COFLUX_HOME || join(homedir(), ".coflux");
43
50
  const BIN_DIR = join(HOME, "bin");
44
- const SETTINGS = join(HOME, "settings.json"); // 用户配置(serverUrl/deviceName/shell)→ daemon 直接读;600 权限防同机其他用户窥探
51
+ const SETTINGS = join(HOME, "settings.json"); // serverUrl / deviceName / shell, read by the service; 0600
45
52
  const LOG_FILE = join(HOME, "daemon.log");
46
53
  const CRED = join(HOME, "credentials.json");
47
- const PENDING_AUTH = join(HOME, "pending-auth.json"); // worker 落盘的待授权链接(daemon.authorizePending)
48
- // One-time join key (plan 20260924-device-join-keys): `up --key` writes it, the worker presents it once
54
+ const PENDING_AUTH = join(HOME, "pending-auth.json"); // the authorization link the runtime writes
55
+ // One-time join key (plan 20260924-device-join-keys): `up --key` writes it, the runtime presents it once
49
56
  // and deletes it on the server's answer, recording what happened in the outcome file (0600 each).
50
57
  const JOIN_KEY = join(HOME, "join-key.json");
51
58
  const JOIN_OUTCOME = join(HOME, "join-outcome.json");
52
- const CONN_STATE = join(HOME, "conn-state.json"); // worker 落盘的连接态快照(plan 033,见 crates/worker/src/conn_state.rs)
53
- const LOCAL_GATEWAY_STORE = join(HOME, "local-gateway.json"); // gateway key/origin/grant;doctor 只读结构与数量,绝不打印秘密
54
- const FDA_STATUS = join(HOME, "fda-status"); // supervisor 启动时探测落盘(仅 macOS,见 crates/supervisor/src/fda.rs)
55
- const SUP_BIN = join(BIN_DIR, "coflux-supervisor");
56
- const WRK_BIN = join(BIN_DIR, "coflux-worker");
57
- // PTY 托管进程(plan 20260918-ptyd-terminal-custody):与 supervisor 平级的独立服务,supervisor 找不到它就拒绝启动。
59
+ const CONN_STATE = join(HOME, "conn-state.json"); // connection snapshot (crates/runtime/src/conn_state.rs)
60
+ const LOCAL_GATEWAY_STORE = join(HOME, "local-gateway.json"); // doctor reads its shape and counts only, never secrets
61
+ const FDA_STATUS = join(HOME, "fda-status"); // macOS only, written by the runtime (crates/runtime/src/fda.rs)
62
+ const LAUNCHER_BIN = join(BIN_DIR, "coflux-launcher");
63
+ const RUNTIME_BIN = join(BIN_DIR, "coflux-runtime");
58
64
  const PTYD_BIN = join(BIN_DIR, "coflux-ptyd");
65
+ const CLI_BIN = join(BIN_DIR, "coflux");
59
66
  const CLI_RELEASE_FLOOR = join(HOME, "cofluxd.release-floor");
60
- const WORKER_RELEASE_FLOOR = join(HOME, "worker.release-floor");
67
+ const RUNTIME_RELEASE_FLOOR = join(HOME, "runtime.release-floor");
61
68
  const IS_MAC = platform() === "darwin";
62
69
  const IS_LINUX = platform() === "linux";
63
70
  const PLIST = join(homedir(), "Library", "LaunchAgents", "com.coflux.daemon.plist");
64
71
  const UNIT = join(homedir(), ".config", "systemd", "user", "coflux-daemon.service");
65
- // ptyd 是独立的服务(plan 20260918-ptyd-terminal-custody):launchd 一个 plist 只能跑一个程序,systemd 默认
66
- // KillMode=control-group 会在 `systemctl restart` 时把整个 cgroup 一起杀——ptyd 必须在 supervisor 的进程组 /
67
- // cgroup 之外,重启 supervisor 才不会带走它。停止 daemon(down / uninstall)仍然一并停 ptyd。
72
+ // ptyd runs as its own service (plan 20260918-ptyd-terminal-custody): one launchd plist runs one program,
73
+ // and systemd's default KillMode=control-group would take the whole cgroup down on `systemctl restart`.
74
+ // ptyd must sit outside the launcher's process group / cgroup for a restart to leave the terminals.
68
75
  const PTYD_PLIST = join(homedir(), "Library", "LaunchAgents", "com.coflux.ptyd.plist");
69
76
  const PTYD_UNIT = join(homedir(), ".config", "systemd", "user", "coflux-ptyd.service");
70
- const DEFAULT_LOCAL_GATEWAY_PORT = 8788; // 与 packages/crates protocol 的 LOCAL_GATEWAY_PORT 保持一致
77
+ const DEFAULT_LOCAL_GATEWAY_PORT = 8788; // same as LOCAL_GATEWAY_PORT in the protocol packages
78
+
79
+ // Self-managed mode (`coflux-launcher watch`, crates/launcher/src/watch.rs).
80
+ const SERVICE_MODE_FILE = join(HOME, "service-mode"); // "self-managed" once `up` chose it on this device
81
+ const WATCH_SOCK = join(HOME, "watch.sock");
82
+ const WATCH_STATE = join(HOME, "watch.json");
83
+ /** Found inside a launcher binary that has `watch`; an older launcher would ignore the argument. */
84
+ const WATCH_PROTOCOL = "coflux-watch/1";
85
+ /** The watcher's exit code when another service already owns this home. */
86
+ const WATCH_EXIT_REFUSED = 3;
71
87
 
72
- const die = (m) => { console.error("✗ " + m); process.exit(1); };
88
+ const SYSTEMD_NEXT = "Check that systemd user services work in this session (systemctl --user status), then try again.";
89
+ const LAUNCHD_NEXT = "See cofluxd logs, then try again.";
90
+
91
+ const die = (what, next = "Run cofluxd --help for usage.") => fail(what, next);
73
92
  const run = (cmd, args, opts = {}) => spawnSync(cmd, args, { encoding: "utf8", ...opts });
93
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
94
+ const messageOf = (error) => (error instanceof Error ? error.message : String(error));
95
+
96
+ /**
97
+ * A launchctl / systemctl call whose failure must stop the command: `up` never reports success over a
98
+ * service manager that refused. Calls that only clear whatever may be loaded use `run` instead.
99
+ */
100
+ function runChecked(cmd, args, next) {
101
+ const result = run(cmd, args);
102
+ if (result.status === 0) return result;
103
+ const detail = (result.stderr || result.stdout || "").trim().split("\n")[0] || (result.error ? result.error.message : `exit status ${result.status}`);
104
+ die(`${cmd} ${args.join(" ")} failed: ${detail}`, next);
105
+ }
74
106
 
75
107
  function rustTarget() {
76
108
  const p = platform(), a = arch();
77
109
  if (p === "darwin") return a === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin";
78
110
  if (p === "linux") return a === "arm64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl";
79
- die(`不支持的平台: ${p}/${a}(仅 macOS / Linux)`);
111
+ die(`This platform is not supported: ${p}/${a}.`, "Coflux runs on macOS and Linux.");
80
112
  }
81
113
 
114
+ /** `linux-x64`, `macos-arm64`: the platform as people name it. */
115
+ function platformLabel() {
116
+ return `${IS_MAC ? "macos" : platform()}-${arch()}`;
117
+ }
118
+
119
+ /** `2.15.0` for the tag `v2.15.0`. */
120
+ const displayVersion = (tag) => String(tag).replace(/^v/, "");
121
+
82
122
  function readSettings() {
83
123
  try { return JSON.parse(fs.readFileSync(SETTINGS, "utf8")); } catch { return {}; }
84
124
  }
@@ -102,6 +142,11 @@ function readConnState() {
102
142
  try { return JSON.parse(fs.readFileSync(CONN_STATE, "utf8")); } catch { return null; }
103
143
  }
104
144
 
145
+ /** The host people recognise in a server URL: `api.coflux.dev`. */
146
+ function serverHost(serverUrl) {
147
+ try { return new URL(serverUrl).host || serverUrl; } catch { return serverUrl || "unknown server"; }
148
+ }
149
+
105
150
  function readLocalGatewaySummary() {
106
151
  let store;
107
152
  try { store = JSON.parse(fs.readFileSync(LOCAL_GATEWAY_STORE, "utf8")); }
@@ -109,26 +154,24 @@ function readLocalGatewaySummary() {
109
154
  return {
110
155
  ok: false,
111
156
  ready: false,
112
- // JSON.parse 的错误在部分 Node 版本会带原文片段;store 含私钥,诊断输出绝不能回显。
113
- error: fs.existsSync(LOCAL_GATEWAY_STORE) ? "grant store 无法解析" : "grant store 尚未创建",
157
+ // JSON.parse errors can quote the file on some Node versions; the store holds private keys and
158
+ // diagnostics must never echo it.
159
+ error: fs.existsSync(LOCAL_GATEWAY_STORE) ? "pairing data cannot be read" : "no pairing data yet",
114
160
  };
115
161
  }
116
162
  if (store?.version !== 1 || !Array.isArray(store.origins) || !Array.isArray(store.grants)) {
117
- return { ok: false, ready: false, error: "grant store 结构/版本无效" };
163
+ return { ok: false, ready: false, error: "pairing data is not valid" };
118
164
  }
119
165
  const origins = store.origins.filter((origin) => typeof origin === "string" && origin.length > 0);
120
166
  const grants = store.grants.filter((grant) => grant && typeof grant.origin === "string" && grant.origin.length > 0);
121
167
  const origin = grants.find((grant) => origins.includes(grant.origin))?.origin || origins[0];
122
168
  const ready = grants.length > 0 && !!origin;
123
- const summary = ready
124
- ? `${grants.length} 个持久 grant / ${origins.length} 个 Origin`
125
- : `${grants.length} 个 grant / ${origins.length} 个 Origin(尚无可用浏览器配对)`;
126
169
  return {
127
170
  ok: ready,
128
171
  ready,
129
172
  origin,
130
- detail: ready ? summary : undefined,
131
- error: ready ? undefined : summary,
173
+ detail: ready ? `${grants.length} paired browser${grants.length === 1 ? "" : "s"}` : undefined,
174
+ error: ready ? undefined : "no paired browser yet",
132
175
  };
133
176
  }
134
177
 
@@ -137,44 +180,30 @@ function localGatewayPort() {
137
180
  if (raw === undefined || raw === "") return { ok: true, port: DEFAULT_LOCAL_GATEWAY_PORT };
138
181
  const port = Number(raw);
139
182
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
140
- return { ok: false, error: `COFLUX_LOCAL_GATEWAY_PORT=${raw} 无法定位固定监听端口` };
183
+ return { ok: false, error: `COFLUX_LOCAL_GATEWAY_PORT=${raw} is not a valid port` };
141
184
  }
142
185
  return { ok: true, port };
143
186
  }
144
- const CONN_STATE_LABEL = { connecting: "连接中", connected: "已连接", reconnecting: "重连中" };
145
- function formatDuration(ms) {
146
- const s = Math.max(0, Math.round(ms / 1000));
147
- if (s < 60) return `${s}秒`;
148
- const m = Math.floor(s / 60);
149
- if (m < 60) return `${m}分钟`;
150
- const h = Math.floor(m / 60);
151
- return `${h}小时${m % 60}分钟`;
152
- }
153
187
 
154
- // "granted" | "denied" | "unknown" | null(文件不存在——supervisor 还没起过/还没写)
188
+ /** "granted" | "denied" | "unknown" | null (no file: the service has not written it yet). */
155
189
  function readFdaStatus() {
156
190
  try { return fs.readFileSync(FDA_STATUS, "utf8").trim(); } catch { return null; }
157
191
  }
158
- function fdaLabel(status) {
159
- return status === "granted" ? "已授予" : status === "denied" ? "未授予" : "未知";
160
- }
161
-
162
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
163
192
 
164
193
  function fileSha256(path) {
165
194
  try { return crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex"); } catch { return null; }
166
195
  }
167
196
 
168
- // macOS:新落盘的二进制带 com.apple.provenance,launchd 顶层 spawn 会被 AMFI 以
169
- // OS_REASON_CODESIGNING 静默杀死——即使产物是 Developer ID 签名 + 已公证(2026-07-25
170
- // v0.13.0 实测仍被连杀)。本地 ad-hoc 重签使其成为"本机产物"绕开该检查。
171
- // 远端产物必须先完成 release statement 验签,再允许走到这里;绝不能替任意下载内容重签。
197
+ // macOS: a freshly written binary carries com.apple.provenance, and launchd's top-level spawn gets it
198
+ // killed by AMFI with OS_REASON_CODESIGNING, even when it is Developer ID signed and notarized
199
+ // (observed with v0.13.0 on 2026-07-25). An ad-hoc local re-sign makes it a local product. Remote
200
+ // artifacts must pass release statement verification before they get here; never re-sign anything else.
172
201
  function resignMacBinaries(paths) {
173
202
  if (!IS_MAC) return;
174
203
  for (const p of paths) {
175
204
  const r = run("codesign", ["--force", "-s", "-", p]);
176
205
  if (r.status !== 0) {
177
- throw new Error(`macOS 本地重签失败: ${p};保留当前 daemon 二进制`);
206
+ throw new Error(`could not re-sign ${basename(p)} for macOS`);
178
207
  }
179
208
  }
180
209
  }
@@ -184,12 +213,12 @@ async function fetchBounded(url, maxBytes, label) {
184
213
  redirect: "follow",
185
214
  signal: AbortSignal.timeout(30_000),
186
215
  });
187
- if (!res.ok) throw new Error(`${label} 下载失败(HTTP ${res.status})`);
216
+ if (!res.ok) throw new Error(`${label}: HTTP ${res.status}`);
188
217
  const declaredLength = Number(res.headers.get("content-length"));
189
218
  if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
190
- throw new Error(`${label} 超过大小上限`);
219
+ throw new Error(`${label} is larger than allowed`);
191
220
  }
192
- if (!res.body) throw new Error(`${label} 响应没有 body`);
221
+ if (!res.body) throw new Error(`${label} came back empty`);
193
222
  const chunks = [];
194
223
  let total = 0;
195
224
  for await (const chunk of res.body) {
@@ -197,7 +226,7 @@ async function fetchBounded(url, maxBytes, label) {
197
226
  total += bytes.byteLength;
198
227
  if (total > maxBytes) {
199
228
  await res.body.cancel().catch(() => {});
200
- throw new Error(`${label} 超过大小上限`);
229
+ throw new Error(`${label} is larger than allowed`);
201
230
  }
202
231
  chunks.push(bytes);
203
232
  }
@@ -210,34 +239,36 @@ function loadReleasePublicKey() {
210
239
  return createReleasePublicKey(publicKeyHex);
211
240
  }
212
241
 
242
+ const FLOOR_NEXT = "Reinstall Coflux on this device: cofluxd uninstall --purge, then cofluxd up.";
243
+
213
244
  function readReleaseFloor(path, label) {
214
245
  let metadata;
215
246
  try { metadata = fs.lstatSync(path); }
216
247
  catch (error) {
217
248
  if (error?.code === "ENOENT") return undefined;
218
- throw new Error(`${label} 元数据无法读取,拒绝远端升级`);
249
+ throw new Error(`The local version record ${label} cannot be read.`);
219
250
  }
220
251
  if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size <= 0 || metadata.size > 128) {
221
- throw new Error(`${label} 不是安全且有界的普通文件,拒绝远端升级`);
252
+ throw new Error(`The local version record ${label} is not a small regular file.`);
222
253
  }
223
254
  let value;
224
255
  try { value = fs.readFileSync(path, "utf8").trim(); }
225
- catch { throw new Error(`${label} 无法读取,拒绝远端升级`); }
256
+ catch { throw new Error(`The local version record ${label} cannot be read.`); }
226
257
  try { assertReleaseVersion(value); }
227
- catch { throw new Error(`${label} 已损坏,拒绝远端升级`); }
258
+ catch { throw new Error(`The local version record ${label} is corrupted.`); }
228
259
  return value;
229
260
  }
230
261
 
231
262
  function currentReleaseFloor() {
232
263
  const floors = [
233
264
  readReleaseFloor(CLI_RELEASE_FLOOR, "cofluxd.release-floor"),
234
- readReleaseFloor(WORKER_RELEASE_FLOOR, "worker.release-floor"),
265
+ readReleaseFloor(RUNTIME_RELEASE_FLOOR, "runtime.release-floor"),
235
266
  ].filter(Boolean);
236
267
  return floors.reduce((highest, candidate) => {
237
268
  if (!highest) return candidate;
238
269
  const order = compareReleaseVersions(candidate, highest);
239
270
  if (order === 0 && candidate !== highest) {
240
- throw new Error(`本机 release floor precedence 相同但身份冲突:${highest} / ${candidate}`);
271
+ throw new Error(`The local version records disagree: ${highest} and ${candidate}.`);
241
272
  }
242
273
  return order > 0 ? candidate : highest;
243
274
  }, undefined);
@@ -270,7 +301,7 @@ async function readMirrorLatestTag() {
270
301
  const body = await fetchBounded(
271
302
  `${RELEASE_MIRROR_BASE}/latest.json`,
272
303
  MAX_RELEASE_METADATA_BYTES,
273
- "release 下载镜像 latest.json",
304
+ "latest.json",
274
305
  );
275
306
  const tag = JSON.parse(body.toString("utf8"))?.version;
276
307
  if (typeof tag !== "string") return null;
@@ -281,18 +312,23 @@ async function readMirrorLatestTag() {
281
312
  }
282
313
  }
283
314
 
284
- // skipIfPresent:up 的幂等语义——二进制已存在且调用方未显式传 version 时跳过下载,
285
- // 避免重跑 up 变成隐式升级(用户拍板,见 plan 035 Decisions)。update/--bin-dir/显式 --version 不受影响。
315
+ /**
316
+ * Put the binaries in place. Returns the version installed: a release tag, or "local build".
317
+ *
318
+ * skipIfPresent: `up` and `run` are idempotent. When the binaries exist and no --version was given they
319
+ * are not downloaded again, so running `up` twice never upgrades by surprise (plan 035). `update`,
320
+ * --bin-dir and an explicit --version are not affected.
321
+ */
286
322
  async function ensureBinaries({ version, binDir, skipIfPresent }) {
287
323
  fs.mkdirSync(BIN_DIR, { recursive: true });
288
324
  if (binDir) {
289
- const localArtifacts = ["coflux-supervisor", "coflux-worker", "coflux-ptyd", ...(fs.existsSync(join(binDir, "coflux")) ? ["coflux"] : []), ...(fs.existsSync(join(binDir, "coflux-transport")) ? ["coflux-transport"] : [])].map((name) => ({
325
+ const localArtifacts = ["coflux-launcher", "coflux-runtime", "coflux-ptyd", ...(fs.existsSync(join(binDir, "coflux")) ? ["coflux"] : []), ...(fs.existsSync(join(binDir, "coflux-transport")) ? ["coflux-transport"] : [])].map((name) => ({
290
326
  name,
291
327
  path: join(binDir, name),
292
328
  }));
293
329
  for (const artifact of localArtifacts) {
294
330
  if (!fs.existsSync(artifact.path)) {
295
- die(`本地产物缺失: ${artifact.path}(先 cargo build --release?)`);
331
+ die(`Missing local build: ${artifact.path}`, "Build it first, for example with cargo build --release.");
296
332
  }
297
333
  }
298
334
  const stageDir = fs.mkdtempSync(join(BIN_DIR, ".coflux-local-install-"));
@@ -319,14 +355,14 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
319
355
  fs.rmSync(stageDir, { recursive: true, force: true });
320
356
  }
321
357
  if (localFailure) {
322
- die(`本地 daemon 安装失败:${localFailure instanceof Error ? localFailure.message : String(localFailure)}`);
358
+ die(`Could not install the local build: ${messageOf(localFailure)}`, "Check the files in --bin-dir, then try again.");
323
359
  }
324
- console.log(`✓ 用本地二进制(${binDir})`);
325
- return;
360
+ success(`Using the local build in ${binDir}`);
361
+ return "local build";
326
362
  }
327
- if (skipIfPresent && !version && fs.existsSync(SUP_BIN) && fs.existsSync(WRK_BIN) && fs.existsSync(PTYD_BIN)) {
328
- console.log(`✓ 二进制已存在(${BIN_DIR}),跳过下载(用 cofluxd update 升级)`);
329
- return;
363
+ if (skipIfPresent && !version && fs.existsSync(LAUNCHER_BIN) && fs.existsSync(RUNTIME_BIN) && fs.existsSync(PTYD_BIN)) {
364
+ success("Using the installed Coflux. To upgrade, run cofluxd update.");
365
+ return null;
330
366
  }
331
367
  const target = rustTarget();
332
368
  let releaseVersion;
@@ -335,13 +371,12 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
335
371
  const tag = await readMirrorLatestTag();
336
372
  if (!tag) {
337
373
  die(
338
- `无法从下载镜像读取最新版本(${RELEASE_MIRROR_BASE}/latest.json),拒绝无版本身份的下载。\n` +
339
- " 用 --version vX.Y.Z 指定版本即可从 GitHub Releases 安装。",
374
+ `Cannot read the latest version from ${RELEASE_MIRROR_BASE}/latest.json.`,
375
+ "Pass a version to install it from GitHub Releases: --version vX.Y.Z",
340
376
  );
341
377
  }
342
378
  releaseVersion = tag;
343
379
  releaseBase = RELEASE_MIRROR_BASE;
344
- console.log(`最新版本: ${releaseVersion}`);
345
380
  } else {
346
381
  releaseVersion = version;
347
382
  // Only the tag the mirror's pointer names lives on the mirror; any other version (older, newer or a
@@ -351,7 +386,7 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
351
386
  try {
352
387
  assertReleaseVersion(releaseVersion);
353
388
  } catch (error) {
354
- die(error instanceof Error ? error.message : String(error));
389
+ die(messageOf(error), "Pass a release tag such as --version v2.15.0, or --version latest.");
355
390
  }
356
391
  let releaseFloor;
357
392
  try {
@@ -360,15 +395,18 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
360
395
  ? compareReleaseVersions(releaseVersion, releaseFloor)
361
396
  : 1;
362
397
  if (floorOrder < 0 || (floorOrder === 0 && releaseVersion !== releaseFloor)) {
363
- throw new Error(`远端 release ${releaseVersion} 不高于本机可信身份 ${releaseFloor},拒绝降级/重放`);
398
+ throw Object.assign(
399
+ new Error(`Release ${releaseVersion} is not newer than the version this device already trusts (${releaseFloor}); refusing a downgrade or replay.`),
400
+ { next: "Install a newer release, or run cofluxd update for the latest one." },
401
+ );
364
402
  }
365
403
  } catch (error) {
366
- die(error instanceof Error ? error.message : String(error));
404
+ die(messageOf(error), error?.next || FLOOR_NEXT);
367
405
  }
368
406
 
369
407
  const base = `${releaseBase}/${releaseVersion}`;
370
- console.log(`下载源: ${base}`);
371
408
  const stageDir = fs.mkdtempSync(join(BIN_DIR, ".coflux-release-install-"));
409
+ const download = step(`Downloading Coflux ${displayVersion(releaseVersion)} (${platformLabel()})`);
372
410
  let failure;
373
411
  try {
374
412
  const manifestBytes = await fetchBounded(
@@ -380,14 +418,13 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
380
418
  try {
381
419
  manifest = JSON.parse(manifestBytes.toString("utf8"));
382
420
  } catch {
383
- throw new Error("release manifest 不是有效 JSON");
421
+ throw new Error("the release manifest is not valid JSON");
384
422
  }
385
423
  const publicKey = loadReleasePublicKey();
386
424
  const staged = [];
387
- for (const component of ["supervisor", "worker", ...(manifest.cli ? ["cli"] : []), ...(manifest.transport ? ["transport"] : []), ...(manifest.ptyd ? ["ptyd"] : [])]) {
425
+ for (const component of ["launcher", "runtime", ...(manifest.cli ? ["cli"] : []), ...(manifest.transport ? ["transport"] : []), ...(manifest.ptyd ? ["ptyd"] : [])]) {
388
426
  const entry = parseReleaseManifestEntry(manifest, component, releaseVersion, target);
389
427
  const artifactName = `coflux-${component}-${target}`;
390
- process.stdout.write(`下载并验签 ${artifactName} … `);
391
428
  const data = await fetchBounded(
392
429
  `${base}/${artifactName}`,
393
430
  Math.min(entry.size, MAX_RELEASE_ARTIFACT_BYTES),
@@ -399,11 +436,10 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
399
436
  fs.chmodSync(source, 0o755);
400
437
  staged.push({
401
438
  source,
402
- destination: component === "cli" ? join(BIN_DIR, "coflux") : component === "supervisor" ? SUP_BIN : component === "transport" ? join(BIN_DIR, "coflux-transport") : component === "ptyd" ? PTYD_BIN : WRK_BIN,
439
+ destination: component === "cli" ? CLI_BIN : component === "launcher" ? LAUNCHER_BIN : component === "transport" ? join(BIN_DIR, "coflux-transport") : component === "ptyd" ? PTYD_BIN : RUNTIME_BIN,
403
440
  });
404
- console.log("✓");
405
441
  }
406
- // 两个远端产物均通过同一发布根的验签后,才允许做本地平台变换与替换。
442
+ // Every remote artifact verified against the same release root before any local transform / replacement.
407
443
  resignMacBinaries(staged.map(({ source }) => source));
408
444
  if (manifest.transport) {
409
445
  const source = join(stageDir, "TRANSPORT-NOTICES.txt");
@@ -412,8 +448,8 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
412
448
  fs.writeFileSync(source, notices, { mode: 0o644 });
413
449
  staged.push({ source, destination: join(BIN_DIR, "TRANSPORT-NOTICES.txt") });
414
450
  }
415
- // floor 先于 pair 提交:若其后进程崩溃,最多是旧二进制配更高 floor;重跑同版仍允许,
416
- // 但任何旧的合法 release 都不能趁窗口降级。
451
+ // The floor is committed before the binaries: a crash in between leaves at worst old binaries with
452
+ // a higher floor. Re-running the same version is still allowed; no older valid release can slip in.
417
453
  if (!releaseFloor || compareReleaseVersions(releaseVersion, releaseFloor) > 0) {
418
454
  persistCliReleaseFloor(releaseVersion);
419
455
  }
@@ -424,11 +460,18 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
424
460
  fs.rmSync(stageDir, { recursive: true, force: true });
425
461
  }
426
462
  if (failure) {
427
- die(`release 安装失败:${failure instanceof Error ? failure.message : String(failure)}`);
463
+ download.failed();
464
+ die(
465
+ `Could not install Coflux ${displayVersion(releaseVersion)} from ${base}: ${messageOf(failure)}`,
466
+ "Nothing on this device was changed. Check your network, then try again.",
467
+ );
428
468
  }
469
+ download.done();
470
+ success("Signature verified");
471
+ return releaseVersion;
429
472
  }
430
473
 
431
- // 写 settings.json(daemon 直接读)。
474
+ // settings.json, read by the service directly.
432
475
  function applyConfig({ serverUrl, deviceName, shell }) {
433
476
  fs.mkdirSync(HOME, { recursive: true });
434
477
  fs.chmodSync(HOME, 0o700);
@@ -439,320 +482,704 @@ function applyConfig({ serverUrl, deviceName, shell }) {
439
482
  }
440
483
 
441
484
  /**
442
- * The executor runtime recorded in the unit, worked out once per `cofluxd up`. See
485
+ * The executor runtime recorded for the service, worked out once per command. See
443
486
  * `service-unit.mjs`: null simply means this installation does not host an executor.
444
487
  */
445
488
  function executorUnitRuntime() {
446
489
  const runtime = executorRuntime();
447
- if (!runtime) console.log("· 本次安装不托管 executor(找不到 @coflux/executor);由 Coflux.app 托管或先重装 cofluxd");
490
+ if (!runtime) warn("This installation cannot run the executor.", "Reinstall cofluxd to add it, or use Coflux.app on this machine.");
448
491
  return runtime;
449
492
  }
450
493
 
451
- /** 四份服务文件的路径与文本(`cofluxd service-files` 原样打印,黑盒据此断言 ptyd 独立受管)。 */
494
+ /** The four service files: paths and text (`cofluxd service-files` prints them as they are). */
452
495
  function serviceFiles(executor = executorRuntime()) {
453
496
  return {
454
497
  launchd: {
455
- supervisor: { path: PLIST, text: plistXml({ supervisorBin: SUP_BIN, home: HOME, logFile: LOG_FILE, executor }) },
498
+ launcher: { path: PLIST, text: plistXml({ launcherBin: LAUNCHER_BIN, home: HOME, logFile: LOG_FILE, executor }) },
456
499
  ptyd: { path: PTYD_PLIST, text: ptydPlistXml({ ptydBin: PTYD_BIN, home: HOME, logFile: LOG_FILE }) },
457
500
  },
458
501
  systemd: {
459
- supervisor: { path: UNIT, text: systemdUnit({ supervisorBin: SUP_BIN, home: HOME, executor }) },
502
+ launcher: { path: UNIT, text: systemdUnit({ launcherBin: LAUNCHER_BIN, home: HOME, executor }) },
460
503
  ptyd: { path: PTYD_UNIT, text: ptydSystemdUnit({ ptydBin: PTYD_BIN, home: HOME }) },
461
504
  },
462
505
  };
463
506
  }
464
- function installService(start) {
465
- const files = serviceFiles(executorUnitRuntime());
466
- if (IS_MAC) {
507
+
508
+ /* ------------------------------ service modes ------------------------------ */
509
+
510
+ /** sd_booted(): systemd is PID 1. A `systemctl` binary alone proves nothing in a container. */
511
+ function systemdBooted() {
512
+ return fs.existsSync("/run/systemd/system");
513
+ }
514
+
515
+ function readServiceMode() {
516
+ try { return fs.readFileSync(SERVICE_MODE_FILE, "utf8").trim(); } catch { return ""; }
517
+ }
518
+
519
+ function writeServiceMode() {
520
+ fs.writeFileSync(SERVICE_MODE_FILE, "self-managed\n", { mode: 0o600 });
521
+ }
522
+
523
+ function clearServiceMode() {
524
+ try { fs.rmSync(SERVICE_MODE_FILE, { force: true }); } catch { /* */ }
525
+ }
526
+
527
+ /** The backend this device runs under: recorded once `up` chose self-managed, never re-detected. */
528
+ function serviceMode() {
529
+ if (IS_MAC) return "launchd";
530
+ if (!IS_LINUX) die("Coflux runs only on macOS and Linux.", "Use a macOS or Linux machine.");
531
+ if (readServiceMode() === "self-managed") return "self-managed";
532
+ return systemdBooted() ? "systemd" : "self-managed";
533
+ }
534
+
535
+ /** One request to the running watcher; null when no watcher answers. */
536
+ function watchRequest(op, timeoutMs = 5000) {
537
+ return new Promise((resolve) => {
538
+ const socket = net.createConnection(WATCH_SOCK);
539
+ let text = "";
540
+ let settled = false;
541
+ const finish = (value) => {
542
+ if (settled) return;
543
+ settled = true;
544
+ socket.destroy();
545
+ resolve(value);
546
+ };
547
+ socket.setEncoding("utf8");
548
+ socket.setTimeout(timeoutMs, () => finish(null));
549
+ socket.on("error", () => finish(null));
550
+ socket.on("connect", () => socket.write(JSON.stringify({ op }) + "\n"));
551
+ socket.on("data", (chunk) => {
552
+ text += chunk;
553
+ const end = text.indexOf("\n");
554
+ if (end < 0) return;
555
+ try { finish(JSON.parse(text.slice(0, end))); } catch { finish(null); }
556
+ });
557
+ socket.on("end", () => finish(null));
558
+ });
559
+ }
560
+
561
+ /** A launcher from before `watch` ignores its arguments and would start next to nothing. */
562
+ function launcherSupportsWatch() {
563
+ try { return fs.readFileSync(LAUNCHER_BIN).includes(WATCH_PROTOCOL); } catch { return false; }
564
+ }
565
+
566
+ function requireWatchSupport() {
567
+ if (!launcherSupportsWatch()) {
568
+ die("The installed Coflux is too old to run without systemd.", "Run cofluxd update, then try again.");
569
+ }
570
+ }
571
+
572
+ function pidAlive(pid) {
573
+ if (!Number.isInteger(pid) || pid <= 1) return false;
574
+ try { process.kill(pid, 0); return true; } catch (error) { return error?.code === "EPERM"; }
575
+ }
576
+
577
+ /** The executable name of a process, to make sure a recorded pid still is that process. */
578
+ function processName(pid) {
579
+ try { return basename(fs.readlinkSync(`/proc/${pid}/exe`)).replace(/ \(deleted\)$/, ""); } catch { /* not Linux */ }
580
+ const result = run("ps", ["-p", String(pid), "-o", "comm="]);
581
+ return result.status === 0 ? basename(result.stdout.trim()) : "";
582
+ }
583
+
584
+ async function waitGone(pid, ms) {
585
+ const deadline = Date.now() + ms;
586
+ while (pidAlive(pid)) {
587
+ if (Date.now() >= deadline) return false;
588
+ await sleep(100);
589
+ }
590
+ return true;
591
+ }
592
+
593
+ /**
594
+ * No watcher answers, but a killed one may have left its children running (they live in their own
595
+ * sessions). Stop them in the watcher's order: the launcher first, then ptyd alone.
596
+ */
597
+ async function stopLeftovers() {
598
+ let state;
599
+ try { state = JSON.parse(fs.readFileSync(WATCH_STATE, "utf8")); } catch { return; }
600
+ const launcher = Number(state?.launcherPid);
601
+ if (pidAlive(launcher) && processName(launcher).startsWith("coflux-launcher")) {
602
+ try { process.kill(launcher, "SIGTERM"); } catch { /* already gone */ }
603
+ if (!(await waitGone(launcher, 10_000))) {
604
+ try { process.kill(-launcher, "SIGKILL"); } catch { /* */ }
605
+ }
606
+ }
607
+ const ptyd = Number(state?.ptydPid);
608
+ if (pidAlive(ptyd) && processName(ptyd).startsWith("coflux-ptyd")) {
609
+ try { process.kill(ptyd, "SIGTERM"); } catch { /* already gone */ }
610
+ if (!(await waitGone(ptyd, 15_000))) {
611
+ try { process.kill(ptyd, "SIGKILL"); } catch { /* */ }
612
+ }
613
+ }
614
+ try { fs.rmSync(WATCH_STATE, { force: true }); } catch { /* */ }
615
+ }
616
+
617
+ /** Start the self-managed service in the background and wait until it runs. */
618
+ async function startSelfManaged(executor) {
619
+ requireWatchSupport();
620
+ fs.mkdirSync(HOME, { recursive: true });
621
+ const logFd = fs.openSync(LOG_FILE, "a", 0o600);
622
+ let exited = null;
623
+ try {
624
+ // A new session, no stdin, output to daemon.log: logging out of SSH must not take it down.
625
+ const child = spawn(LAUNCHER_BIN, ["watch"], {
626
+ detached: true,
627
+ stdio: ["ignore", logFd, logFd],
628
+ env: watcherEnv(process.env, { home: HOME, executor }),
629
+ });
630
+ child.once("error", (error) => { exited = { error }; });
631
+ child.once("exit", (code, signal) => { exited = { code, signal }; });
632
+ child.unref();
633
+ } finally {
634
+ fs.closeSync(logFd);
635
+ }
636
+ const deadline = Date.now() + 20_000;
637
+ while (Date.now() < deadline) {
638
+ if (exited) break;
639
+ const status = await watchRequest("status", 1000);
640
+ if (status?.ok && status.launcher?.running) return;
641
+ await sleep(200);
642
+ }
643
+ if (exited?.code === WATCH_EXIT_REFUSED) {
644
+ die("Coflux is already running on this device under another service.", "Stop it first with cofluxd down. If the Coflux app runs on this machine, quit it.");
645
+ }
646
+ die(exited ? "The service exited while starting." : "The service did not start in time.", "See what went wrong with cofluxd logs.");
647
+ }
648
+
649
+ /** Stop the self-managed service: the watcher if one answers, else whatever a killed one left. */
650
+ async function stopSelfManaged() {
651
+ const reply = await watchRequest("stop", 60_000);
652
+ if (reply) {
653
+ const deadline = Date.now() + 10_000;
654
+ while (Date.now() < deadline && (await watchRequest("status", 500))) await sleep(200);
655
+ return;
656
+ }
657
+ await stopLeftovers();
658
+ }
659
+
660
+ function installService(mode, start, executor) {
661
+ const files = serviceFiles(executor);
662
+ if (mode === "launchd") {
467
663
  fs.mkdirSync(dirname(PLIST), { recursive: true });
468
664
  fs.writeFileSync(PTYD_PLIST, files.launchd.ptyd.text);
469
- fs.writeFileSync(PLIST, files.launchd.supervisor.text);
665
+ fs.writeFileSync(PLIST, files.launchd.launcher.text);
470
666
  if (start) {
471
- // ptyd 先起:supervisor 连不上它会退出,KeepAlive 再拉一次也只是浪费。
667
+ // ptyd first: a runtime that cannot reach it exits, and a KeepAlive retry is wasted work.
472
668
  run("launchctl", ["unload", PLIST]);
473
669
  run("launchctl", ["unload", PTYD_PLIST]);
474
- run("launchctl", ["load", PTYD_PLIST]);
475
- run("launchctl", ["load", PLIST]);
670
+ runChecked("launchctl", ["load", PTYD_PLIST], LAUNCHD_NEXT);
671
+ runChecked("launchctl", ["load", PLIST], LAUNCHD_NEXT);
476
672
  }
477
- console.log(`✓ launchd: ${PTYD_PLIST}\n✓ launchd: ${PLIST}`);
478
- } else if (IS_LINUX) {
673
+ } else if (mode === "systemd") {
479
674
  fs.mkdirSync(dirname(UNIT), { recursive: true });
480
675
  fs.writeFileSync(PTYD_UNIT, files.systemd.ptyd.text);
481
- fs.writeFileSync(UNIT, files.systemd.supervisor.text);
676
+ fs.writeFileSync(UNIT, files.systemd.launcher.text);
482
677
  if (start) {
483
- run("systemctl", ["--user", "daemon-reload"]);
484
- run("systemctl", ["--user", "enable", "--now", "coflux-ptyd.service"]);
485
- run("systemctl", ["--user", "enable", "--now", "coflux-daemon.service"]);
678
+ runChecked("systemctl", ["--user", "daemon-reload"], SYSTEMD_NEXT);
679
+ runChecked("systemctl", ["--user", "enable", "--now", "coflux-ptyd.service"], SYSTEMD_NEXT);
680
+ runChecked("systemctl", ["--user", "enable", "--now", "coflux-daemon.service"], SYSTEMD_NEXT);
486
681
  }
487
- console.log(`✓ systemd: ${PTYD_UNIT}\n✓ systemd: ${UNIT}`);
488
- } else die("仅支持 macOS / Linux");
682
+ }
489
683
  }
490
- /** 只重启 supervisor:终端留在 ptyd 里。ptyd 若还没起(首次升到带 ptyd 的版本)先把它拉起来,已在跑则无事。 */
491
- function restartService() {
492
- if (IS_MAC) {
493
- run("launchctl", ["load", PTYD_PLIST]);
684
+
685
+ /** Restart the launcher (and its runtime) only: the terminals stay in ptyd. Starts ptyd if it is not running. */
686
+ async function restartService(mode, executor) {
687
+ if (mode === "launchd") {
688
+ run("launchctl", ["load", PTYD_PLIST]); // already loaded is fine
494
689
  run("launchctl", ["unload", PLIST]);
495
- run("launchctl", ["load", PLIST]);
496
- } else if (IS_LINUX) {
497
- run("systemctl", ["--user", "daemon-reload"]);
498
- run("systemctl", ["--user", "enable", "--now", "coflux-ptyd.service"]);
499
- run("systemctl", ["--user", "restart", "coflux-daemon.service"]);
690
+ runChecked("launchctl", ["load", PLIST], LAUNCHD_NEXT);
691
+ } else if (mode === "systemd") {
692
+ runChecked("systemctl", ["--user", "daemon-reload"], SYSTEMD_NEXT);
693
+ runChecked("systemctl", ["--user", "enable", "--now", "coflux-ptyd.service"], SYSTEMD_NEXT);
694
+ runChecked("systemctl", ["--user", "restart", "coflux-daemon.service"], SYSTEMD_NEXT);
695
+ } else {
696
+ const reply = await watchRequest("restart", 60_000);
697
+ if (reply?.ok) return;
698
+ if (reply) die(`The service could not restart: ${reply.error || "unknown reason"}`, "See what went wrong with cofluxd logs.");
699
+ await startSelfManaged(executor);
500
700
  }
501
701
  }
502
- /** 重启两者(ptyd 自身更新):会结束本机全部终端。 */
503
- function restartAllServices() {
504
- stopService();
505
- if (IS_MAC) { run("launchctl", ["load", PTYD_PLIST]); run("launchctl", ["load", PLIST]); }
506
- else if (IS_LINUX) { run("systemctl", ["--user", "start", "coflux-ptyd.service"]); run("systemctl", ["--user", "start", "coflux-daemon.service"]); }
507
- }
508
- /** 停止本机 daemon = supervisor + ptyd 一起停(终端结束)。 */
509
- function stopService() {
510
- if (IS_MAC) { run("launchctl", ["unload", PLIST]); run("launchctl", ["unload", PTYD_PLIST]); }
511
- else if (IS_LINUX) { run("systemctl", ["--user", "stop", "coflux-daemon.service"]); run("systemctl", ["--user", "stop", "coflux-ptyd.service"]); }
512
- }
513
702
 
514
- async function applyAndStart({ serverUrl, deviceName, shell, version, binDir, noStart }) {
515
- await ensureBinaries({ version, binDir, skipIfPresent: true });
516
- applyConfig({ serverUrl, deviceName, shell });
517
- // 非默认服务器醒目提示:保存值/--server 仍生效(不强制覆盖),但防止 staging 之类残留值静默错连。
518
- if (serverUrl !== DEFAULT_SERVER) {
519
- console.log(`⚠ 使用非默认服务器: ${serverUrl}`);
703
+ /** Restart both, ptyd included (a ptyd update): this ends every terminal on the device. */
704
+ async function restartAllServices(mode, executor) {
705
+ if (mode === "self-managed") {
706
+ const status = await watchRequest("status");
707
+ if (status?.mode === "foreground") {
708
+ // `cofluxd run` owns its watcher; it restarts everything in place.
709
+ const reply = await watchRequest("restart-all", 120_000);
710
+ if (!reply?.ok) die(`The service could not restart: ${reply?.error || "no answer"}`, "See what went wrong with cofluxd logs.");
711
+ return;
712
+ }
713
+ await stopSelfManaged();
714
+ await startSelfManaged(executor);
715
+ return;
520
716
  }
521
- installService(!noStart);
522
- console.log(noStart ? "已安装(未启动)。" : `✓ daemon 已启动 → ${serverUrl}`);
523
- if (!noStart && !fs.existsSync(CRED)) {
524
- // 未登记 → 走浏览器授权,轮询 daemon 落盘的链接/凭证文件。
525
- await waitForAuthorization();
526
- } else {
527
- cmdStatus();
717
+ stopService(mode);
718
+ if (mode === "launchd") {
719
+ runChecked("launchctl", ["load", PTYD_PLIST], LAUNCHD_NEXT);
720
+ runChecked("launchctl", ["load", PLIST], LAUNCHD_NEXT);
721
+ } else if (mode === "systemd") {
722
+ runChecked("systemctl", ["--user", "start", "coflux-ptyd.service"], SYSTEMD_NEXT);
723
+ runChecked("systemctl", ["--user", "start", "coflux-daemon.service"], SYSTEMD_NEXT);
528
724
  }
529
- if (IS_MAC && !noStart && readFdaStatus() !== "granted") {
530
- console.log("\n⚠ 完全磁盘访问权限尚未授予:PTY 里访问桌面/文稿/下载等目录时,系统弹窗可能因无人点击而卡住。");
531
- console.log(" 运行 `cofluxd fda` 完成一次性授权(授予后需重启服务生效)。");
725
+ }
726
+
727
+ /**
728
+ * Stop the launcher and ptyd together (terminals end). `strict` (down / uninstall) fails on a systemctl
729
+ * that could not stop the units; a unit that is not loaded (exit 5) is already stopped.
730
+ */
731
+ function stopService(mode, { strict = false } = {}) {
732
+ if (mode === "launchd") {
733
+ run("launchctl", ["unload", PLIST]);
734
+ run("launchctl", ["unload", PTYD_PLIST]);
735
+ } else if (mode === "systemd") {
736
+ for (const unit of ["coflux-daemon.service", "coflux-ptyd.service"]) {
737
+ const result = run("systemctl", ["--user", "stop", unit]);
738
+ if (strict && result.status !== 0 && result.status !== 5) {
739
+ const detail = (result.stderr || "").trim().split("\n")[0] || (result.error ? result.error.message : `exit status ${result.status}`);
740
+ die(`systemctl --user stop ${unit} failed: ${detail}`, SYSTEMD_NEXT);
741
+ }
742
+ }
743
+ }
744
+ }
745
+
746
+ /* --------------------------- authorization and join --------------------------- */
747
+
748
+ const ADD_DEVICE_NEXT = "Copy the full command from Add Device in Coflux.";
749
+
750
+ /** `✓ Device "name" connected to host`. */
751
+ function deviceConnected(deviceName, serverUrl) {
752
+ success(`Device "${deviceName}" connected to ${serverHost(serverUrl)}`);
753
+ }
754
+
755
+ /** Minutes until a pending link expires, or null. */
756
+ function minutesLeft(pending) {
757
+ return Number.isFinite(pending?.expiresAt) ? Math.max(1, Math.round((pending.expiresAt - Date.now()) / 60000)) : null;
758
+ }
759
+
760
+ function printAuthorizationLink(pending) {
761
+ const mins = minutesLeft(pending);
762
+ info("");
763
+ info("To add this device to your account, open:");
764
+ info("");
765
+ info(` ${bold(pending.url)}`);
766
+ info("");
767
+ info(`Waiting for authorization...${mins ? ` (expires in ${mins}m)` : ""}`);
768
+ }
769
+
770
+ // Poll pending-auth.json (the link) and credentials.json (registered). The CLI never speaks the
771
+ // protocol: it only reads files; the service renews an expired link on its own.
772
+ async function waitForAuthorization({ deviceName, serverUrl }) {
773
+ const maxWaitMs = 11 * 60 * 1000; // only how long we wait in the foreground; the service keeps going
774
+ const start = Date.now();
775
+ let printedUrl = null;
776
+ let warned = false;
777
+ while (Date.now() - start < maxWaitMs) {
778
+ if (fs.existsSync(CRED)) {
779
+ deviceConnected(deviceName, serverUrl);
780
+ return;
781
+ }
782
+ const pending = readPendingAuth();
783
+ if (pending?.url && pending.url !== printedUrl) {
784
+ printedUrl = pending.url;
785
+ printAuthorizationLink(pending);
786
+ }
787
+ if (!printedUrl && !warned && Date.now() - start > 20_000) {
788
+ warned = true;
789
+ warn(`The device has not reached ${serverHost(serverUrl)} yet.`, "It keeps trying. Check it with cofluxd status, or see cofluxd logs.");
790
+ }
791
+ await sleep(1000);
532
792
  }
793
+ // Links expire and are renewed: never point at "the link above", it may be dead by now.
794
+ info("");
795
+ info("Still waiting. The service keeps running and renews the link; run cofluxd status to see the current one.");
533
796
  }
534
797
 
535
798
  /**
536
799
  * `cofluxd up --key` (plan 20260924-device-join-keys): join the account with a one-time key minted in
537
- * Coflux's 添加设备 dialog. Never prints a link and never enters the 11-minute link wait.
800
+ * Coflux's Add Device dialog. Never prints a link and never enters the 11-minute link wait.
538
801
  *
539
- * Order matters: stop the service before the key file exists, so a worker still running from the link
540
- * era can never pick it up in a renewal and then be killed mid-answer; starting the stopped unit again
541
- * (`enable --now` on Linux, unload/load on macOS) is the restart that makes the new worker read it.
542
- * The CLI still never speaks the protocol: it learns the result only from `credentials.json` and the
543
- * outcome file the worker writes.
802
+ * Order matters: stop the service before the key file exists, so a runtime still running from the
803
+ * link era can never pick it up in a renewal and then be killed mid-answer; starting it again is the
804
+ * restart that makes the new runtime read it. The CLI learns the result only from
805
+ * `credentials.json` and the outcome file the runtime writes.
544
806
  */
545
- async function joinWithKey({ serverUrl, deviceName, shell, version, binDir, key }) {
546
- await ensureBinaries({ version, binDir, skipIfPresent: true });
547
- applyConfig({ serverUrl, deviceName, shell });
548
- if (serverUrl !== DEFAULT_SERVER) console.log(`⚠ 使用非默认服务器: ${serverUrl}`);
549
- stopService();
550
- // The worker clears pending-auth.json only at connection end, not on SIGTERM: a stale one would
551
- // read as "daemon too old for keys" below.
807
+ async function joinWithKey({ mode, serverUrl, deviceName, key, executor }) {
808
+ if (mode === "self-managed") {
809
+ requireWatchSupport();
810
+ await stopSelfManaged();
811
+ } else {
812
+ stopService(mode);
813
+ }
814
+ // The runtime clears pending-auth.json only at connection end, not on SIGTERM: a stale one would
815
+ // read as "too old for keys" below.
552
816
  for (const file of [JOIN_OUTCOME, PENDING_AUTH]) { try { fs.rmSync(file, { force: true }); } catch { /* */ } }
553
817
  fs.writeFileSync(JOIN_KEY, JSON.stringify({ key }) + "\n", { mode: 0o600 });
554
818
  fs.chmodSync(JOIN_KEY, 0o600);
555
- installService(true);
556
- console.log(`✓ daemon 已启动 → ${serverUrl}`);
557
- console.log("\n 正在用接入密钥接入账号 …\n");
819
+ await startService(mode, executor);
820
+ const joining = step("Joining your account");
558
821
  const maxWaitMs = 2 * 60 * 1000;
559
822
  const start = Date.now();
560
823
  while (Date.now() - start < maxWaitMs) {
561
824
  const outcome = readJoinOutcome();
562
825
  if (fs.existsSync(CRED) || outcome?.status === "joined") {
563
- console.log("✓ 已接入\n");
564
- cmdStatus();
826
+ joining.done();
827
+ deviceConnected(deviceName, serverUrl);
565
828
  return;
566
829
  }
567
830
  if (outcome?.status === "rejected") {
568
- die(`接入失败:${outcome.reason || "密钥无效、已过期或已被使用"}\n 在 Coflux 的添加设备里换一个密钥,再重新运行这条命令。`);
831
+ joining.failed();
832
+ die(`The join key was rejected: ${outcome.reason || "it is invalid, expired or already used"}`, "Copy a new command from Add Device in Coflux and run it.");
569
833
  }
570
834
  if (outcome?.status === "unsupported") {
571
- die("服务器还不支持接入密钥(需要更新服务器)。");
835
+ joining.failed();
836
+ die("This server does not support join keys yet.", "Update the server, or run cofluxd up without --key to authorize in the browser.");
572
837
  }
573
838
  if (fs.existsSync(JOIN_KEY) && fs.existsSync(PENDING_AUTH)) {
574
- // The daemon enrolled without reading the key: its binary predates join keys (up keeps existing
575
- // binaries). Drop the key so a later worker never presents it behind the user's back.
839
+ // The service enrolled without reading the key: its binary predates join keys (up keeps existing
840
+ // binaries). Drop the key so a later runtime never presents it behind the user's back.
576
841
  try { fs.rmSync(JOIN_KEY, { force: true }); } catch { /* */ }
577
- die("已安装的 daemon 版本过旧,不认识接入密钥:先运行 `cofluxd update`,再在 Coflux 的添加设备里换一个密钥重新运行这条命令。");
842
+ joining.failed();
843
+ die("The installed Coflux is too old for join keys.", "Run cofluxd update, then copy a new command from Add Device in Coflux.");
578
844
  }
579
845
  await sleep(500);
580
846
  }
581
- die("等待接入超时:daemon 仍在后台尝试连接服务器。用 `cofluxd status` 查看状态,`cofluxd logs` 查看原因。");
847
+ joining.failed();
848
+ die("Timed out waiting to join your account.", "The service keeps trying. Check it with cofluxd status, and see cofluxd logs for the reason.");
582
849
  }
583
850
 
584
- // 轮询 ~/.coflux/pending-auth.json(授权链接)与 credentials.json(登记成功)给出全程反馈。
585
- // CLI 保持零协议:只读文件,不连 WS;daemon 断线重连会自动重发 enrollRequest、换发新链接。
586
- async function waitForAuthorization() {
587
- console.log("\n 等待设备授权 …\n");
588
- const maxWaitMs = 11 * 60 * 1000; // 只是前台等待的上限;daemon 会持续自动续期授权链接,超时后用 status 看最新链接即可
589
- const start = Date.now();
590
- let printedUrl = null;
591
- while (Date.now() - start < maxWaitMs) {
592
- if (fs.existsSync(CRED)) {
593
- console.log("✓ 设备已登记\n");
594
- cmdStatus();
595
- return;
596
- }
597
- const pending = readPendingAuth();
598
- if (pending?.url && pending.url !== printedUrl) {
599
- printedUrl = pending.url;
600
- const mins = Number.isFinite(pending.expiresAt) ? Math.max(1, Math.round((pending.expiresAt - Date.now()) / 60000)) : null;
601
- console.log(` 在浏览器打开以下链接,用已登录账号授权此设备${mins ? `(约 ${mins} 分钟内有效)` : ""}:\n`);
602
- console.log(` ${pending.url}\n`);
851
+ /* ------------------------------ commands ------------------------------ */
852
+
853
+ /** The backend `up` installs: launchd, systemd when it is booted, otherwise self-managed. */
854
+ async function upMode() {
855
+ if (IS_MAC) {
856
+ return (await watchRequest("status")) ? "self-managed" : "launchd";
857
+ }
858
+ if (!IS_LINUX) die("Coflux runs only on macOS and Linux.", "Use a macOS or Linux machine.");
859
+ if (await watchRequest("status")) return "self-managed";
860
+ if (systemdBooted()) {
861
+ clearServiceMode();
862
+ return "systemd";
863
+ }
864
+ return "self-managed";
865
+ }
866
+
867
+ /** Start (or, for a running self-managed service, restart) the service. */
868
+ async function startService(mode, executor) {
869
+ if (mode === "self-managed") {
870
+ writeServiceMode();
871
+ if (await watchRequest("status")) {
872
+ await restartService(mode, executor);
873
+ } else {
874
+ await startSelfManaged(executor);
603
875
  }
604
- await sleep(1000);
876
+ } else {
877
+ installService(mode, true, executor);
878
+ }
879
+ success("Service started");
880
+ if (mode === "self-managed") {
881
+ warn("This service does not start on boot.", "In a container, use cofluxd run as the entry point instead.");
605
882
  }
606
- // 链接会过期换新(daemon 自动续期),别引导用户用"上面的链接"——过期后那是死链
607
- console.log(" 仍未完成授权;daemon 已在后台运行并会自动更换过期的授权链接,用 `cofluxd status` 查看最新链接。\n");
608
883
  }
609
884
 
610
- /* ------------------------------ 命令 ------------------------------ */
885
+ function validateKey(raw) {
886
+ const key = raw.trim();
887
+ if (!/^cf_join_[A-Za-z0-9_-]+$/.test(key)) die("Invalid join key.", ADD_DEVICE_NEXT);
888
+ return key;
889
+ }
611
890
 
612
- async function cmdUp(v) {
891
+ /** Settings for up / run: flags first, then the saved settings, then defaults. */
892
+ function resolveSettings(v) {
613
893
  const s = readSettings();
894
+ return {
895
+ serverUrl: v.server || s.serverUrl || DEFAULT_SERVER,
896
+ deviceName: v.name || s.deviceName || hostname(),
897
+ shell: v.shell || s.shell,
898
+ };
899
+ }
900
+
901
+ function noticeServer(serverUrl) {
902
+ // A saved non-default server stays in effect; say so, so a leftover staging URL cannot connect silently.
903
+ if (serverUrl !== DEFAULT_SERVER) warn(`Using the server ${serverUrl}`);
904
+ }
905
+
906
+ async function cmdUp(v) {
907
+ const settings = resolveSettings(v);
908
+ let key;
614
909
  if (v.key !== undefined) {
615
- const key = v.key.trim();
616
- if (!/^cf_join_[A-Za-z0-9_-]+$/.test(key)) die("--key 不是接入密钥:请完整复制 Coflux 添加设备里的命令(密钥以 cf_join_ 开头)");
617
- if (v["no-start"]) die("--key 不能与 --no-start 同用:密钥只在启动时使用一次");
910
+ key = validateKey(v.key);
911
+ if (v["no-start"]) die("--key cannot be used with --no-start.", "The key is used once, when the service starts. Drop --no-start.");
618
912
  if (fs.existsSync(CRED)) {
619
913
  // Already registered: a key would never be presented, so change nothing at all.
620
914
  const c = readCredentialsSummary();
621
- console.log(`✓ 此设备已接入 ${c.serverUrl || s.serverUrl || "(未知服务器)"}${c.daemonId ? `(设备 ${c.daemonId})` : ""},忽略 --key,未做任何改动。`);
915
+ success(`This device is already in your account on ${serverHost(c.serverUrl || settings.serverUrl)}. Nothing was changed.`);
622
916
  return;
623
917
  }
624
- await joinWithKey({
625
- serverUrl: v.server || s.serverUrl || DEFAULT_SERVER,
626
- deviceName: v.name || s.deviceName || hostname(),
627
- shell: v.shell || s.shell,
628
- version: v.version, binDir: v["bin-dir"], key,
629
- });
918
+ }
919
+ await ensureBinaries({ version: v.version, binDir: v["bin-dir"], skipIfPresent: true });
920
+ applyConfig(settings);
921
+ noticeServer(settings.serverUrl);
922
+ const mode = await upMode();
923
+ const executor = executorUnitRuntime();
924
+ if (key) {
925
+ await joinWithKey({ mode, ...settings, key, executor });
630
926
  return;
631
927
  }
632
- await applyAndStart({
633
- serverUrl: v.server || s.serverUrl || DEFAULT_SERVER,
634
- deviceName: v.name || s.deviceName || hostname(),
635
- shell: v.shell || s.shell,
636
- version: v.version, binDir: v["bin-dir"], noStart: v["no-start"],
928
+ if (v["no-start"]) {
929
+ if (mode === "self-managed") writeServiceMode();
930
+ else installService(mode, false, executor);
931
+ success("Installed without starting. Start it with cofluxd up.");
932
+ return;
933
+ }
934
+ await startService(mode, executor);
935
+ if (!fs.existsSync(CRED)) {
936
+ await waitForAuthorization(settings);
937
+ } else {
938
+ info("");
939
+ await cmdStatus();
940
+ }
941
+ if (IS_MAC && readFdaStatus() !== "granted") {
942
+ warn("Full Disk Access is not granted. Terminals can hang on macOS permission prompts.", "Run cofluxd fda to grant it.");
943
+ }
944
+ }
945
+
946
+ /**
947
+ * `cofluxd run`: the self-managed service in the foreground, for containers and other supervisors.
948
+ * Node only forwards signals and mirrors the exit code; `coflux-launcher watch --foreground` does
949
+ * the supervising. Logs go to stdout/stderr. A rejected join key is reported and the service keeps
950
+ * running, so a restart policy cannot loop on a spent key.
951
+ */
952
+ async function cmdRun(v) {
953
+ if (!IS_LINUX && !IS_MAC) die("Coflux runs only on macOS and Linux.", "Use a macOS or Linux machine.");
954
+ if (v["no-start"]) die("--no-start does not apply to cofluxd run.", "Use cofluxd up --no-start to install without starting.");
955
+ const settings = resolveSettings(v);
956
+ const key = v.key !== undefined ? validateKey(v.key) : undefined;
957
+ // Two launchers for one home would fight over launcher.sock: refuse while anything else owns it.
958
+ const otherOwner = (await watchRequest("status"))
959
+ || (IS_MAC && serviceRunningInfo("launchd").running)
960
+ || (IS_LINUX && systemdBooted() && serviceRunningInfo("systemd").running);
961
+ if (otherOwner) die("Coflux is already running on this device.", "Stop it first with cofluxd down, then run cofluxd run again.");
962
+ await ensureBinaries({ version: v.version, binDir: v["bin-dir"], skipIfPresent: true });
963
+ requireWatchSupport();
964
+ applyConfig(settings);
965
+ noticeServer(settings.serverUrl);
966
+ if (key) {
967
+ if (fs.existsSync(CRED)) {
968
+ success("This device is already in your account. The join key was not used.");
969
+ } else {
970
+ for (const file of [JOIN_OUTCOME, PENDING_AUTH]) { try { fs.rmSync(file, { force: true }); } catch { /* */ } }
971
+ fs.writeFileSync(JOIN_KEY, JSON.stringify({ key }) + "\n", { mode: 0o600 });
972
+ fs.chmodSync(JOIN_KEY, 0o600);
973
+ }
974
+ }
975
+ const executor = executorRuntime();
976
+ const child = spawn(LAUNCHER_BIN, ["watch", "--foreground"], {
977
+ stdio: ["ignore", "inherit", "inherit"],
978
+ env: watcherEnv(process.env, { home: HOME, executor }),
979
+ });
980
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
981
+ process.on(signal, () => { try { child.kill(signal); } catch { /* */ } });
982
+ }
983
+
984
+ // Report the authorization link, the join outcome and the first connection in the same output.
985
+ let printedUrl = null;
986
+ let reportedJoin = false;
987
+ let connected = fs.existsSync(CRED);
988
+ const monitor = setInterval(() => {
989
+ if (!connected && fs.existsSync(CRED)) {
990
+ connected = true;
991
+ deviceConnected(settings.deviceName, settings.serverUrl);
992
+ return;
993
+ }
994
+ if (connected) return;
995
+ const outcome = readJoinOutcome();
996
+ if (!reportedJoin && outcome?.status === "rejected") {
997
+ reportedJoin = true;
998
+ printError(`The join key was rejected: ${outcome.reason || "it is invalid, expired or already used"}`, "Copy a new command from Add Device in Coflux. The service keeps running.");
999
+ } else if (!reportedJoin && outcome?.status === "unsupported") {
1000
+ reportedJoin = true;
1001
+ printError("This server does not support join keys yet.", "Update the server, or authorize this device with the link printed below.");
1002
+ }
1003
+ const pending = readPendingAuth();
1004
+ if (pending?.url && pending.url !== printedUrl) {
1005
+ printedUrl = pending.url;
1006
+ printAuthorizationLink(pending);
1007
+ }
1008
+ }, 1000);
1009
+
1010
+ child.once("error", (error) => {
1011
+ clearInterval(monitor);
1012
+ die(`Could not start the service: ${error.message}`, "Run cofluxd update, then try again.");
1013
+ });
1014
+ child.once("exit", (code, signal) => {
1015
+ clearInterval(monitor);
1016
+ if (code === WATCH_EXIT_REFUSED) {
1017
+ printError("Coflux is already running on this device under another service.", "Stop it first with cofluxd down. If the Coflux app runs on this machine, quit it.");
1018
+ } else if (code !== 0) {
1019
+ printError("The service stopped.", "See the output above for the reason. Terminals that were open are closed.");
1020
+ }
1021
+ process.exit(code ?? (signal ? 1 : 0));
637
1022
  });
638
1023
  }
639
1024
 
640
- function cmdDown() { stopService(); console.log("✓ 已停止"); }
1025
+ async function cmdDown() {
1026
+ const mode = (await watchRequest("status")) ? "self-managed" : serviceMode();
1027
+ const stopping = step("Stopping the service");
1028
+ if (mode === "self-managed") await stopSelfManaged();
1029
+ else stopService(mode, { strict: true });
1030
+ stopping.done();
1031
+ }
641
1032
 
642
- // 只有 supervisor 真正靠这条命令更新——它不自动升级(持 PTY,重启会杀会话,见 plan 017)。
643
- // worker 运行中会被 server 自动热升级到最新 stable(无需停服);这里顺带刷新的是 worker 的
644
- // "内置兜底二进制"(supervisor 冷启动/热升级缓存皆无时的 fallback),日常可不管。
1033
+ // Only the launcher, ptyd and the coflux command need this (plan 20261002-runtime-launcher-merge): the
1034
+ // server updates the runtime itself. The runtime refreshed here is the fallback the launcher runs
1035
+ // when its store holds nothing better.
645
1036
  //
646
- // update 与 restart 拆开(2026-07-25 用户拍板):update 只落盘二进制、绝不重启——替换磁盘
647
- // 文件对运行中的 supervisor/PTY 零影响;supervisor 内容没变就明说不用重启(大多数发版
648
- // 都不动 supervisor,旧行为等于白丢一次全部会话)。真正的中断收敛到显式的 `cofluxd restart`。
1037
+ // update never restarts (decided 2026-07-25): replacing files on disk does not affect running
1038
+ // processes. Interruptions happen only on an explicit `cofluxd restart` (terminals stay) or
1039
+ // `cofluxd restart --ptyd` (terminals end).
649
1040
  async function cmdUpdate(v) {
650
- if (!fs.existsSync(SETTINGS)) die("尚未安装,先 cofluxd up");
1041
+ if (!fs.existsSync(SETTINGS)) die("Coflux is not installed on this device.", "Run cofluxd up first.");
651
1042
  const version = v.version || "latest";
652
- const before = fileSha256(SUP_BIN);
653
- const ptydBefore = fileSha256(PTYD_BIN);
654
- await ensureBinaries({ version, binDir: v["bin-dir"] });
655
- const supervisorChanged = before !== fileSha256(SUP_BIN);
656
- const ptydChanged = ptydBefore !== fileSha256(PTYD_BIN);
657
- if (!supervisorChanged && !ptydChanged) {
658
- console.log("✓ 已更新:supervisor / ptyd 无变化——不需要重启,活会话不受影响");
659
- console.log(" worker 由 server 自动热升级;本次刷新的只是兜底 worker 二进制。");
1043
+ const before = {
1044
+ launcher: fileSha256(LAUNCHER_BIN),
1045
+ ptyd: fileSha256(PTYD_BIN),
1046
+ runtime: fileSha256(RUNTIME_BIN),
1047
+ cli: fileSha256(CLI_BIN),
1048
+ };
1049
+ const installed = await ensureBinaries({ version, binDir: v["bin-dir"] });
1050
+ const label = installed === "local build" ? "the local build" : displayVersion(installed);
1051
+ const launcherChanged = before.launcher !== fileSha256(LAUNCHER_BIN);
1052
+ const ptydChanged = before.ptyd !== fileSha256(PTYD_BIN);
1053
+ const runtimeChanged = before.runtime !== fileSha256(RUNTIME_BIN);
1054
+ const cliChanged = before.cli !== fileSha256(CLI_BIN);
1055
+ if (!launcherChanged && !ptydChanged && !runtimeChanged && !cliChanged) {
1056
+ success(`Already up to date (${label})`);
660
1057
  return;
661
1058
  }
662
- if (supervisorChanged) {
663
- console.log(`✓ 新 supervisor 已就位(${v["bin-dir"] ? "本地产物" : version}),但尚未生效`);
664
- console.log(" 运行 `cofluxd restart` 应用——终端留在 ptyd 里,屏幕与回滚原样保留,只有几秒停顿。");
665
- }
1059
+ success(`Updated to ${label}`);
1060
+ if (launcherChanged) info("Run cofluxd restart to apply it. Terminals stay open.");
666
1061
  if (ptydChanged) {
667
- console.log(`✓ 新 ptyd(终端托管进程)已就位${ptydBefore ? "" : "(首次安装)"},但尚未生效`);
668
- console.log(" ⚠ 运行 `cofluxd restart --ptyd` 应用——ptyd 自身更新无法保留终端,会结束本机所有活会话。");
1062
+ warn("Part of this update takes effect only after a full restart, which closes all terminals.", "Run cofluxd restart --ptyd when that is convenient.");
669
1063
  }
1064
+ if (!launcherChanged && !ptydChanged) info("No restart needed.");
670
1065
  }
671
1066
 
672
- // 应用已就位的新 supervisor(或单纯重启 supervisor):终端留在 ptyd 里不动。
673
- // `--ptyd`:连 ptyd 一起重启(ptyd 自身更新时才需要)——这才是会结束本机会话的那条命令。
674
- function cmdRestart(v) {
675
- if (!fs.existsSync(SETTINGS)) die("尚未安装,先 cofluxd up");
676
- // 服务文件与模板同步(首次升到带 ptyd 的版本时 ptyd 的 plist / unit 还不存在);只写文件,不在这里启动。
677
- installService(false);
1067
+ // Apply a new launcher (or just restart the service): the terminals stay in ptyd. `--ptyd` restarts
1068
+ // ptyd as well (only a ptyd update needs it), and that ends every terminal on the device.
1069
+ async function cmdRestart(v) {
1070
+ if (!fs.existsSync(SETTINGS)) die("Coflux is not installed on this device.", "Run cofluxd up first.");
1071
+ const mode = (await watchRequest("status")) ? "self-managed" : serviceMode();
1072
+ const executor = executorRuntime();
1073
+ // Keep the unit files in step with the templates (the first restart after an upgrade may still find
1074
+ // units from an older layout); written only, started below.
1075
+ if (mode !== "self-managed") installService(mode, false, executor);
678
1076
  if (v.ptyd) {
679
- console.log("⚠ 正在重启 ptyd 与 supervisor:本机所有活会话将结束…");
680
- restartAllServices();
1077
+ warn("This closes all terminals on this device.");
1078
+ const restarting = step("Restarting the service and its terminals");
1079
+ await restartAllServices(mode, executor);
1080
+ restarting.done();
681
1081
  } else {
682
- console.log("正在重启 supervisor:本机终端留在 ptyd 里,屏幕与回滚原样保留…");
683
- restartService();
1082
+ const restarting = step("Restarting the service");
1083
+ await restartService(mode, executor);
1084
+ restarting.done();
1085
+ info("Terminals stayed open.");
684
1086
  }
685
- console.log("✓ 已重启(cofluxd status 查看状态)");
686
1087
  }
687
1088
 
688
- /** 隐藏命令:把四份服务文件(launchd × 2、systemd × 2)以 JSON 打到 stdout,供黑盒断言与运维核对。 */
1089
+ /** Hidden: the four service files (launchd x 2, systemd x 2) as JSON on stdout. */
689
1090
  function cmdServiceFiles() {
690
1091
  console.log(JSON.stringify(serviceFiles(), null, 2));
691
1092
  }
692
1093
 
693
- // launchd(macOS) / systemd --user(Linux) 服务活跃态查询,status 与 doctor 共用。
694
- // macOS 必须以 launchctl list 输出里的 PID 为准:label 登记 ≠ 进程存活(supervisor 被
695
- // OS_REASON_CODESIGNING 杀掉时 label 仍在,旧实现会报假"运行中",2026-07-25 实测踩过)。
696
- function serviceRunningInfo() {
697
- if (IS_MAC) {
1094
+ // launchd / systemd service state, shared by status and doctor. On macOS only the PID in
1095
+ // `launchctl list` counts: a registered label is not a live process (a launcher killed with
1096
+ // OS_REASON_CODESIGNING keeps its label; observed 2026-07-25).
1097
+ function serviceRunningInfo(mode) {
1098
+ if (mode === "launchd") {
698
1099
  const out = run("launchctl", ["list", "com.coflux.daemon"]);
699
1100
  const registered = out.status === 0;
700
1101
  const running = registered && /"PID"\s*=\s*\d+/.test(out.stdout || "");
701
- return { running, active: running ? "运行中" : registered ? "已登记但进程不在(看 cofluxd logs;常见于系统拒签杀进程)" : "未运行" };
1102
+ return { running, label: running ? "running" : registered ? "not running (see cofluxd logs)" : "stopped" };
702
1103
  }
703
- if (IS_LINUX) {
704
- const active = (run("systemctl", ["--user", "is-active", "coflux-daemon.service"]).stdout || "").trim() || "未运行";
705
- return { running: active === "active", active };
1104
+ if (mode === "systemd") {
1105
+ const active = (run("systemctl", ["--user", "is-active", "coflux-daemon.service"]).stdout || "").trim();
1106
+ const label = active === "active" ? "running"
1107
+ : active === "activating" || active === "reloading" ? "starting"
1108
+ : active === "failed" ? "failed (see cofluxd logs)"
1109
+ : "stopped";
1110
+ return { running: active === "active", label };
1111
+ }
1112
+ return { running: false, label: "stopped" };
1113
+ }
1114
+
1115
+ /** The service state under whichever backend runs it, plus the terminal count when known. */
1116
+ async function serviceState() {
1117
+ const watcher = await watchRequest("status");
1118
+ if (watcher?.ok) {
1119
+ const launcherUp = !!watcher.launcher?.running;
1120
+ const label = !launcherUp ? "starting"
1121
+ : watcher.mode === "foreground" ? "running (cofluxd run, no autostart)" : "running (self-managed, no autostart)";
1122
+ return { running: launcherUp, label, terminals: Number.isInteger(watcher.terminals) ? watcher.terminals : undefined };
706
1123
  }
707
- return { running: false, active: "未运行" };
1124
+ const mode = serviceMode();
1125
+ if (mode === "self-managed") return { running: false, label: "stopped" };
1126
+ return serviceRunningInfo(mode);
708
1127
  }
709
1128
 
710
- function cmdStatus() {
1129
+ const CONNECTION_LABEL = { connecting: "connecting", connected: "connected", reconnecting: "reconnecting" };
1130
+
1131
+ async function cmdStatus() {
711
1132
  const s = readSettings();
712
- console.log(`服务器: ${s.serverUrl || "(未配置)"}`);
713
- console.log(`设备名: ${s.deviceName || "(默认)"}`);
1133
+ const { running, label, terminals } = await serviceState();
714
1134
  const registered = fs.existsSync(CRED);
715
- const pending = !registered ? readPendingAuth() : null;
1135
+ let connection;
716
1136
  if (registered) {
717
- console.log("凭证: 已登记");
718
- } else if (pending?.url) {
719
- const mins = Number.isFinite(pending.expiresAt) ? Math.max(0, Math.round((pending.expiresAt - Date.now()) / 60000)) : null;
720
- console.log(`凭证: 等待授权${mins !== null ? `(约 ${mins} 分钟内有效)` : ""}`);
721
- console.log(` ${pending.url}`);
1137
+ // conn-state.json is a snapshot the runtime wrote while alive: with the service down it is stale and
1138
+ // must not read as online.
1139
+ if (running) {
1140
+ const conn = readConnState();
1141
+ const state = CONNECTION_LABEL[conn?.state];
1142
+ if (state) {
1143
+ connection = state === "connected" && Number.isFinite(conn.since) ? `connected for ${duration(Date.now() - conn.since)}` : state;
1144
+ } else {
1145
+ connection = "unknown";
1146
+ }
1147
+ } else {
1148
+ connection = "offline";
1149
+ }
722
1150
  } else {
723
- console.log("凭证: 未登记");
724
- }
725
- const { running, active } = serviceRunningInfo();
726
- let pid = "";
727
- if (running) {
728
- try { pid = ` (worker pid ${fs.readFileSync(join(HOME, "worker.pid"), "utf8").trim()})`; } catch { /* */ }
729
- }
730
- console.log(`服务: ${active}${pid}`);
731
- // conn-state.json 是 worker 生前写的快照:进程不在时是 stale 数据,忽略——不能让"进程活着"
732
- // 误当成"在线",也不能让 stale 文件在进程死后继续显示假的连接态。
733
- if (running) {
734
- const conn = readConnState();
735
- if (conn?.state && CONN_STATE_LABEL[conn.state]) {
736
- const dur = Number.isFinite(conn.since) ? formatDuration(Date.now() - conn.since) : null;
737
- console.log(`连接: ${CONN_STATE_LABEL[conn.state]}${dur ? `(已 ${dur})` : ""}`);
1151
+ const pending = readPendingAuth();
1152
+ if (pending?.url) {
1153
+ const mins = Number.isFinite(pending.expiresAt) ? Math.max(0, Math.round((pending.expiresAt - Date.now()) / 60000)) : null;
1154
+ connection = `waiting for authorization${mins !== null ? ` (expires in ${mins}m)` : ""}\n${pending.url}`;
1155
+ } else {
1156
+ connection = "not added to an account yet";
738
1157
  }
739
1158
  }
1159
+ const rows = [
1160
+ ["Device", s.deviceName || hostname()],
1161
+ ["Server", s.serverUrl ? serverHost(s.serverUrl) : "not configured"],
1162
+ ["Service", label],
1163
+ ["Connection", connection],
1164
+ ["Terminals", terminals === undefined ? undefined : `${terminals} open`],
1165
+ ];
740
1166
  if (IS_MAC) {
741
1167
  const fda = readFdaStatus();
742
- const hint = fda === "granted" ? "" : "(`cofluxd fda` 引导授权,避免弹窗卡住 PTY 会话)";
743
- console.log(`FDA: ${fdaLabel(fda)}${hint}`);
1168
+ rows.push(["Disk access", fda === "granted" ? "granted" : fda === "denied" ? "not granted; run cofluxd fda" : "unknown; run cofluxd fda"]);
744
1169
  }
1170
+ table(rows);
745
1171
  }
746
1172
 
747
- /* ------------------------------ doctor:中心 + 本地直连分层自检 ------------------------------ */
748
- // 中心与 loopback 都只做传输层探测,不解析 coflux 协议消息——CLI 保持零协议。中心 WS
749
- // 成功升级后立即断开;loopback 只验证已持久 Origin 能拿到 101,不发送 browser 私钥或 grant。
1173
+ /* ------------------------------ doctor ------------------------------ */
1174
+ // Transport-level probes only: no coflux protocol message is parsed, so the CLI stays protocol-free.
1175
+ // The server WebSocket is closed right after the upgrade; the loopback probe only checks that a stored
1176
+ // Origin gets a 101 and never sends a browser key or grant.
750
1177
  const DOCTOR_TIMEOUT_MS = 5000;
751
1178
 
752
1179
  function parseServerUrl(serverUrl) {
753
1180
  const u = new URL(serverUrl);
754
1181
  const useTls = u.protocol === "wss:";
755
- if (!useTls && u.protocol !== "ws:") throw new Error(`不支持的协议: ${u.protocol}(需 ws:// 或 wss://)`);
1182
+ if (!useTls && u.protocol !== "ws:") throw new Error(`unsupported scheme ${u.protocol}; use ws:// or wss://`);
756
1183
  const port = u.port ? Number(u.port) : (useTls ? 443 : 80);
757
1184
  const path = u.pathname || "/";
758
1185
  return { host: u.hostname, port, path, useTls };
@@ -777,7 +1204,7 @@ function probeTcp(host, port) {
777
1204
  const socket = net.connect({ host, port, timeout: DOCTOR_TIMEOUT_MS });
778
1205
  const done = (ok, extra) => { socket.destroy(); resolve({ ok, ms: Date.now() - t0, ...extra }); };
779
1206
  socket.once("connect", () => done(true, {}));
780
- socket.once("timeout", () => done(false, { error: `连接超时(>${DOCTOR_TIMEOUT_MS}ms)` }));
1207
+ socket.once("timeout", () => done(false, { error: `timed out after ${DOCTOR_TIMEOUT_MS}ms` }));
781
1208
  socket.once("error", (e) => done(false, { error: e.message }));
782
1209
  });
783
1210
  }
@@ -786,16 +1213,16 @@ function probeTls(host, port) {
786
1213
  return new Promise((resolve) => {
787
1214
  const t0 = Date.now();
788
1215
  const socket = tls.connect({ host, port, servername: host, timeout: DOCTOR_TIMEOUT_MS }, () => {
789
- const detail = socket.authorized ? socket.getProtocol() : `${socket.getProtocol()}(证书未验证: ${socket.authorizationError})`;
1216
+ const detail = socket.authorized ? socket.getProtocol() : `${socket.getProtocol()}, certificate not verified: ${socket.authorizationError}`;
790
1217
  socket.destroy();
791
1218
  resolve({ ok: true, ms: Date.now() - t0, detail });
792
1219
  });
793
- socket.once("timeout", () => { socket.destroy(); resolve({ ok: false, ms: Date.now() - t0, error: `握手超时(>${DOCTOR_TIMEOUT_MS}ms)` }); });
1220
+ socket.once("timeout", () => { socket.destroy(); resolve({ ok: false, ms: Date.now() - t0, error: `handshake timed out after ${DOCTOR_TIMEOUT_MS}ms` }); });
794
1221
  socket.once("error", (e) => resolve({ ok: false, ms: Date.now() - t0, error: e.message }));
795
1222
  });
796
1223
  }
797
1224
 
798
- // 手写一条最小 HTTP/1.1 Upgrade 请求,只看是否拿到 101——不建立真实 WebSocket 帧连接。
1225
+ // One minimal HTTP/1.1 Upgrade request; only whether a 101 comes back matters. No WebSocket frames.
799
1226
  function probeWsUpgrade({ host, port, path, useTls, origin }) {
800
1227
  return new Promise((resolve) => {
801
1228
  const t0 = Date.now();
@@ -811,7 +1238,7 @@ function probeWsUpgrade({ host, port, path, useTls, origin }) {
811
1238
  };
812
1239
  const onOpen = (socket) => {
813
1240
  let buf = "";
814
- const timer = setTimeout(() => { socket.destroy(); finish({ ok: false, error: `升级响应超时(>${DOCTOR_TIMEOUT_MS}ms)` }); }, DOCTOR_TIMEOUT_MS);
1241
+ const timer = setTimeout(() => { socket.destroy(); finish({ ok: false, error: `no upgrade response after ${DOCTOR_TIMEOUT_MS}ms` }); }, DOCTOR_TIMEOUT_MS);
815
1242
  socket.on("data", (chunk) => {
816
1243
  buf += chunk.toString("latin1");
817
1244
  if (!buf.includes("\r\n\r\n")) return;
@@ -819,7 +1246,7 @@ function probeWsUpgrade({ host, port, path, useTls, origin }) {
819
1246
  const statusLine = buf.split("\r\n")[0];
820
1247
  socket.destroy();
821
1248
  const ok = /^HTTP\/1\.\d 101\b/.test(statusLine);
822
- finish(ok ? { ok, detail: statusLine } : { ok, error: `未收到 101 切换协议响应: ${statusLine}` });
1249
+ finish(ok ? { ok, detail: statusLine } : { ok, error: `expected 101 Switching Protocols, got: ${statusLine}` });
823
1250
  });
824
1251
  socket.once("error", (e) => { clearTimeout(timer); finish({ ok: false, error: e.message }); });
825
1252
  socket.write(req);
@@ -828,83 +1255,41 @@ function probeWsUpgrade({ host, port, path, useTls, origin }) {
828
1255
  ? tls.connect({ host, port, servername: host, timeout: DOCTOR_TIMEOUT_MS })
829
1256
  : net.connect({ host, port, timeout: DOCTOR_TIMEOUT_MS });
830
1257
  socket.once(useTls ? "secureConnect" : "connect", () => onOpen(socket));
831
- socket.once("timeout", () => { socket.destroy(); finish({ ok: false, error: `连接超时(>${DOCTOR_TIMEOUT_MS}ms)` }); });
1258
+ socket.once("timeout", () => { socket.destroy(); finish({ ok: false, error: `timed out after ${DOCTOR_TIMEOUT_MS}ms` }); });
832
1259
  socket.once("error", (e) => finish({ ok: false, error: e.message }));
833
1260
  });
834
1261
  }
835
1262
 
836
- function printLayer(name, r) {
837
- const mark = r.ok ? "✓" : "✗";
838
- const msg = r.ok ? (r.detail ? `→ ${r.detail}` : "") : (r.error || "");
839
- console.log(` ${mark} ${name} (${r.ms}ms)${msg ? ` ${msg}` : ""}`);
840
- }
841
-
842
- function printLocalLayer(name, r) {
843
- const mark = r.ok ? "✓" : "⚠";
844
- const msg = r.ok ? (r.detail ? `→ ${r.detail}` : "") : (r.error || "");
845
- const elapsed = Number.isFinite(r.ms) ? ` (${r.ms}ms)` : "";
846
- console.log(` ${mark} ${name}${elapsed}${msg ? ` ${msg}` : ""}`);
847
- }
848
-
849
- // 本地事实汇总:服务进程存活、conn-state.json 连接态、凭证有无、FDA。返回连接态三态:
850
- // "connected" | "not-connected"(有快照但非 connected,如 connecting/reconnecting)
851
- // | "unknown"(服务未运行,或无快照——daemon 版本较旧还没写、或刚启动)。
852
- // 三态区分是因为"无快照"不等于"未连接":旧版 worker 不写 conn-state.json,把它当"未连接"
853
- // 会把"连接态未知"误报成"认证/授权层有问题"(2026-07-23 实操验收发现)。
854
- function printLocalFacts() {
855
- console.log("\n Daemon 状态\n ───────────");
856
- const registered = fs.existsSync(CRED);
857
- console.log(` 凭证: ${registered ? "已登记" : "未登记"}`);
858
- const { running, active } = serviceRunningInfo();
859
- console.log(` 服务: ${active}`);
860
- let connState = "unknown";
861
- if (running) {
862
- const conn = readConnState();
863
- if (conn?.state && CONN_STATE_LABEL[conn.state]) {
864
- console.log(` 中心: ${CONN_STATE_LABEL[conn.state]}`);
865
- connState = conn.state === "connected" ? "connected" : "not-connected";
866
- } else {
867
- console.log(" 中心: (无连接态快照)");
868
- }
869
- } else {
870
- console.log(" 中心: 未探测(服务未运行)");
871
- }
872
- if (IS_MAC) console.log(` FDA: ${fdaLabel(readFdaStatus())}`);
873
- console.log("");
874
- return { registered, running, connState };
875
- }
876
-
877
- function printConclusion(level, msg) {
878
- const mark = level === "ok" ? "✓" : level === "warning" ? "⚠" : "✗";
879
- console.log(` ${mark} ${msg}\n`);
1263
+ /** One probe row: ` ✓ Name 12ms detail`. `soft` failures are warnings, not errors. */
1264
+ function printProbe(name, r, { soft = false } = {}) {
1265
+ const mark = r.ok ? "✓" : soft ? "!" : "✗";
1266
+ const elapsed = Number.isFinite(r.ms) ? `${r.ms}ms` : "";
1267
+ const msg = r.ok ? (r.detail || "") : (r.error || "");
1268
+ info(` ${mark} ${name.padEnd(28)} ${elapsed.padStart(6)} ${msg}`.trimEnd());
880
1269
  }
881
1270
 
882
- async function probeCenter(target) {
1271
+ async function probeServer(target) {
883
1272
  const { host, port, useTls } = target;
884
1273
  const dnsR = await probeDns(host);
885
- printLayer("DNS 解析", dnsR);
886
- if (!dnsR.ok) {
887
- return { ok: false, reason: "DNS 解析失败——检查网络连接/DNS 配置,或该域名是否可达。" };
888
- }
1274
+ printProbe("DNS lookup", dnsR);
1275
+ if (!dnsR.ok) return { ok: false, reason: "DNS lookup failed.", next: "Check your network and DNS settings." };
889
1276
 
890
1277
  const tcpR = await probeTcp(host, port);
891
- printLayer(`TCP 连接 (${host}:${port})`, tcpR);
892
- if (!tcpR.ok) {
893
- return { ok: false, reason: "DNS 可解析但 TCP 连不上——防火墙/代理拦截,或目标端口未开放。" };
894
- }
1278
+ printProbe(`TCP connection (${host}:${port})`, tcpR);
1279
+ if (!tcpR.ok) return { ok: false, reason: "DNS works, but the TCP connection failed.", next: "A firewall or proxy may block it, or the port is closed." };
895
1280
 
896
1281
  if (useTls) {
897
1282
  const tlsR = await probeTls(host, port);
898
- printLayer("TLS 握手", tlsR);
1283
+ printProbe("TLS handshake", tlsR);
899
1284
  if (!tlsR.ok) {
900
- return { ok: false, reason: "TCP 可连但 TLS 握手失败——可能是企业代理 MITM 证书、系统时间错误,或服务端证书问题。" };
1285
+ return { ok: false, reason: "TCP works, but the TLS handshake failed.", next: "A corporate proxy, a wrong system clock or the server's certificate may be the cause." };
901
1286
  }
902
1287
  }
903
1288
 
904
1289
  const wsR = await probeWsUpgrade(target);
905
- printLayer("WS 升级握手", wsR);
1290
+ printProbe("WebSocket upgrade", wsR);
906
1291
  if (!wsR.ok) {
907
- return { ok: false, reason: "网络层通但 WebSocket 升级被拒——可能是反代/负载均衡未正确转发 Upgrade 头,或路径不对。" };
1292
+ return { ok: false, reason: "The network works, but the WebSocket upgrade was refused.", next: "A reverse proxy may not forward the Upgrade header, or the path is wrong." };
908
1293
  }
909
1294
  return { ok: true };
910
1295
  }
@@ -912,140 +1297,213 @@ async function probeCenter(target) {
912
1297
  async function probeLocalDirect() {
913
1298
  const portResult = localGatewayPort();
914
1299
  if (!portResult.ok) {
915
- printLocalLayer("Gateway bind", { ok: false, error: portResult.error });
916
- printLocalLayer("Loopback WS", { ok: false, error: "固定 gateway 端口未知,无法探测" });
917
- const grant = readLocalGatewaySummary();
918
- printLocalLayer("本地 grant", grant);
1300
+ printProbe("Local port", { ok: false, error: portResult.error }, { soft: true });
1301
+ printProbe("Local connection", { ok: false, error: "local port unknown; not checked" }, { soft: true });
1302
+ printProbe("Browser pairing", readLocalGatewaySummary(), { soft: true });
919
1303
  return { ready: false };
920
1304
  }
921
1305
 
922
1306
  const bind = await probeTcp("127.0.0.1", portResult.port);
923
- printLocalLayer(`Gateway bind (127.0.0.1:${portResult.port})`, bind);
1307
+ printProbe(`Local port (127.0.0.1:${portResult.port})`, bind, { soft: true });
924
1308
  const grant = readLocalGatewaySummary();
925
- printLocalLayer("本地 grant", grant);
1309
+ printProbe("Browser pairing", grant, { soft: true });
926
1310
 
927
1311
  let loopback;
928
1312
  if (!bind.ok) {
929
- loopback = { ok: false, error: "gateway 未监听,跳过 WS 握手" };
1313
+ loopback = { ok: false, error: "local port not listening; skipped" };
930
1314
  } else {
931
1315
  loopback = await probeWsUpgrade({
932
1316
  host: "127.0.0.1",
933
1317
  port: portResult.port,
934
1318
  path: "/device",
935
1319
  useTls: false,
936
- // 没有持久 Origin 时仍发一个合法 Origin;403 能区分“coflux gateway 可达但未配对”。
1320
+ // Without a stored Origin still send a valid one: a 403 tells "reachable but not paired" apart.
937
1321
  origin: grant.origin || "http://127.0.0.1",
938
1322
  });
939
1323
  }
940
- printLocalLayer("Loopback WS(主机侧)", loopback);
1324
+ printProbe("Local connection", loopback, { soft: true });
941
1325
  return { ready: bind.ok && grant.ready && loopback.ok };
942
1326
  }
943
1327
 
944
1328
  async function cmdDoctor() {
945
1329
  const s = readSettings();
946
1330
  const serverUrl = s.serverUrl || DEFAULT_SERVER;
947
- console.log(`\n 中心网络 —— ${serverUrl}\n ────────────────────────────`);
948
- let center;
1331
+ info(`Server ${serverUrl}`);
1332
+ let server;
949
1333
  try {
950
- center = await probeCenter(parseServerUrl(serverUrl));
1334
+ server = await probeServer(parseServerUrl(serverUrl));
951
1335
  } catch (error) {
952
- printLayer("server URL", { ok: false, ms: 0, error: `${serverUrl}(${error.message})` });
953
- center = { ok: false, reason: "中心地址配置无效。" };
1336
+ printProbe("Server URL", { ok: false, ms: 0, error: error.message });
1337
+ server = { ok: false, reason: "The server URL is not valid.", next: "Fix it with cofluxd up --server <url>." };
954
1338
  }
955
1339
 
956
- console.log("\n 本地直连\n ────────");
1340
+ info("");
1341
+ info("Local access");
957
1342
  const direct = await probeLocalDirect();
958
- const facts = printLocalFacts();
959
- const relayReady = center.ok && facts.running && facts.connState === "connected";
960
1343
 
1344
+ info("");
1345
+ const { running, label } = await serviceState();
1346
+ const registered = fs.existsSync(CRED);
1347
+ // Three states, because "no snapshot" is not "not connected": an older runtime does not write one.
1348
+ let connState = "unknown";
1349
+ let connection = running ? "unknown" : "not checked; the service is not running";
1350
+ if (running) {
1351
+ const conn = readConnState();
1352
+ if (CONNECTION_LABEL[conn?.state]) {
1353
+ connection = CONNECTION_LABEL[conn.state];
1354
+ connState = conn.state === "connected" ? "connected" : "not-connected";
1355
+ }
1356
+ }
1357
+ table([
1358
+ ["Service", label],
1359
+ ["Account", registered ? "added" : "not added yet"],
1360
+ ["Connection", connection],
1361
+ ...(IS_MAC ? [["Disk access", readFdaStatus() === "granted" ? "granted" : "not granted; run cofluxd fda"]] : []),
1362
+ ]);
1363
+ info("");
1364
+
1365
+ const relayReady = server.ok && running && connState === "connected";
961
1366
  if (direct.ready && relayReady) {
962
- printConclusion("ok", "本地直连与中心 relay 均可用。");
1367
+ success("The server connection and direct local access both work.");
963
1368
  } else if (!direct.ready && relayReady) {
964
- printConclusion("warning", "直连降级:中心 relay 仍可用,daemon 不是离线;检查上面的 gateway/grant/loopback 项。");
965
- } else if (direct.ready && !center.ok) {
966
- printConclusion("warning", "中心不可达,但本地直连可用;已加载且已配对页面可继续会话,刷新/冷启动不保证。");
1369
+ warn("Direct local access is degraded, but the server connection works: the device is online.", "Check the local access items above.");
1370
+ } else if (direct.ready && !server.ok) {
1371
+ warn("The server is unreachable, but direct local access works.", "Pages already open keep working; reloading them may not.");
967
1372
  } else if (direct.ready) {
968
- printConclusion("warning", "本地直连可用;中心网络可达,但 daemon→中心连接未确认,relay 状态未知。");
969
- } else if (center.ok) {
970
- printConclusion("warning", "直连降级;中心网络可达,但 daemon→中心连接未确认。不要据此把 daemon 判为离线,查 `cofluxd logs`。");
1373
+ warn("Direct local access works. The server is reachable, but the device has not confirmed its connection.", "Check again in a moment with cofluxd status, or see cofluxd logs.");
1374
+ } else if (server.ok) {
1375
+ warn("Direct local access is degraded, and the device has not confirmed its server connection.", "This does not mean the device is offline. See cofluxd logs.");
971
1376
  } else {
972
- printConclusion("error", `${center.reason || "中心不可达"} 同时本地直连降级;当前两条路径都未确认可用。`);
1377
+ printError(`${server.reason} Direct local access is degraded too.`, server.next);
973
1378
  }
974
1379
  }
975
1380
 
976
- function cmdLogs(v) {
977
- if (IS_MAC) {
978
- if (!fs.existsSync(LOG_FILE)) die(`暂无日志 ${LOG_FILE}`);
1381
+ async function cmdLogs(v) {
1382
+ const watcher = await watchRequest("status");
1383
+ if (IS_MAC || watcher || serviceMode() === "self-managed") {
1384
+ if (!fs.existsSync(LOG_FILE)) die("There is no log yet.", "Start the service with cofluxd up.");
979
1385
  run("tail", v.follow ? ["-f", LOG_FILE] : ["-n", "100", LOG_FILE], { stdio: "inherit" });
980
1386
  } else if (IS_LINUX) {
981
1387
  run("journalctl", ["--user", "-u", "coflux-daemon.service", ...(v.follow ? ["-f"] : ["-n", "100"])], { stdio: "inherit" });
982
1388
  }
983
1389
  }
984
1390
 
985
- // 完全磁盘访问权限(FDA)引导:macOS 不允许程序自动弹出 FDA 授权窗(Apple 刻意设计),
986
- // 上限就是检测 + 跳转系统设置 + 引导手动添加。授权对象是 supervisor 二进制本身——worker/PTY/agent
987
- // 都是它的子进程,TCC 按 launchd 服务的 responsible process 归属,一次授权覆盖全树。
1391
+ // Full Disk Access (FDA): macOS never lets a program open the FDA prompt itself, by design. The best
1392
+ // this can do is detect, open System Settings and guide. The grant goes to the launcher binary: the
1393
+ // runtime and agents are its children, and TCC attributes them to the launchd job's responsible process.
988
1394
  async function cmdFda() {
989
- if (!IS_MAC) die("此命令仅 macOS 可用(Linux 无 TCC/FDA 概念,无需授权)");
990
- console.log("\n 完全磁盘访问权限(FDA)引导\n ──────────────────────────\n");
991
- console.log(" macOS 不支持程序自动弹出 FDA 授权窗,需手动在「系统设置」里添加。");
992
- console.log(" 请把下面这个二进制拖进「隐私与安全性 → 完全磁盘访问权限」列表并勾选:\n");
993
- console.log(` ${SUP_BIN}\n`);
994
- console.log(" 即将打开系统设置面板,并在 Finder 中定位该二进制……\n");
1395
+ if (!IS_MAC) die("cofluxd fda is only for macOS.", "Linux needs no extra permission.");
1396
+ info("Grant Full Disk Access to Coflux");
1397
+ info("");
1398
+ info("macOS asks you to add the program yourself. In System Settings, open");
1399
+ info("Privacy & Security > Full Disk Access, then drag in this file and turn it on:");
1400
+ info("");
1401
+ info(` ${bold(LAUNCHER_BIN)}`);
1402
+ info("");
1403
+ info("Opening System Settings and showing the file in Finder...");
995
1404
  run("open", ["x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"]);
996
- run("open", ["-R", SUP_BIN]);
1405
+ run("open", ["-R", LAUNCHER_BIN]);
997
1406
  const rl = createInterface({ input: process.stdin, output: process.stdout });
998
1407
  try {
999
- await rl.question(" 添加并勾选后按回车,重启服务使授权生效… ");
1408
+ await rl.question("Press Enter once it is added and turned on... ");
1000
1409
  } finally {
1001
1410
  rl.close();
1002
1411
  }
1003
- restartService();
1004
- console.log("\n✓ 已重启服务(FDA 对已运行进程不生效,必须重启才能生效)。稍候用 `cofluxd status` 确认已授予。");
1412
+ // The grant only applies to processes started after it.
1413
+ await restartService("launchd", executorRuntime());
1414
+ success("Service restarted. Check the result with cofluxd status in a moment.");
1005
1415
  }
1006
1416
 
1007
- function cmdUninstall(v) {
1008
- stopService();
1417
+ async function cmdUninstall(v) {
1418
+ const watcher = await watchRequest("status");
1419
+ const mode = watcher ? "self-managed" : serviceMode();
1420
+ if (mode === "self-managed") await stopSelfManaged();
1421
+ else stopService(mode, { strict: true });
1009
1422
  for (const file of IS_MAC ? [PLIST, PTYD_PLIST] : [UNIT, PTYD_UNIT]) {
1010
1423
  try { fs.rmSync(file); } catch { /* */ }
1011
1424
  }
1012
- if (IS_LINUX) run("systemctl", ["--user", "daemon-reload"]);
1013
- if (v.purge) { try { fs.rmSync(HOME, { recursive: true, force: true }); } catch { /* */ } console.log("✓ 已卸载并清除 " + HOME); }
1014
- else console.log(`✓ 已卸载服务(保留二进制/配置/凭证于 ${HOME};--purge 可全清)`);
1015
- }
1016
-
1017
- const HELP = `cofluxd —— coflux daemon 管理
1018
-
1019
- cofluxd 首次=up(打印浏览器授权链接),已配置=status
1020
- cofluxd up [flags] 幂等:首次装+起,已装则按当前 settings.json 重装服务并重启
1021
- cofluxd up --key <密钥> 用 Coflux 添加设备里生成的一次性密钥直接接入账号(不打印授权链接;已接入则不做改动)
1022
- cofluxd status 服务器/登记(含"等待授权")/服务/连接状态
1023
- cofluxd doctor 中心网络 + gateway bind/grant/loopback + daemon 状态分层自检
1024
- cofluxd update 下载新二进制(不重启;supervisor / ptyd 有变化时提示用 restart 应用)
1025
- cofluxd restart 重启 supervisor 应用新版本(终端留在 ptyd 里,不结束会话)
1026
- cofluxd restart --ptyd 连 ptyd 一起重启(⚠ 结束本机所有活会话;仅 ptyd 自身更新时需要)
1027
- cofluxd fda [仅 macOS] 引导授予完全磁盘访问权限(避免 PTY 因 TCC 弹窗卡住)
1028
- cofluxd logs [-f] 看 daemon 日志
1029
- cofluxd down 停止
1030
- cofluxd uninstall [--purge] 卸载(--purge 连二进制/配置/凭证一并删)
1031
-
1032
- up flags: --server <ws://.../daemon> --name <名> --shell <路径> --key <cf_join_…>(一次性接入密钥,1 小时内有效)
1033
- 通用: --version <vX|latest>(不传时 up 沿用已有二进制,update 默认 latest) --bin-dir <dir>(用本地 cargo 产物) --no-start
1034
- 配置都在 ~/.coflux/settings.json(serverUrl/deviceName/shell),daemon 直接读;改后重跑 cofluxd up 生效。`;
1035
-
1036
- const { values, positionals } = parseArgs({
1037
- allowPositionals: true,
1038
- options: {
1039
- server: { type: "string" }, name: { type: "string" }, shell: { type: "string" }, key: { type: "string" },
1040
- version: { type: "string" }, "bin-dir": { type: "string" },
1041
- "no-start": { type: "boolean", default: false }, purge: { type: "boolean", default: false },
1042
- ptyd: { type: "boolean", default: false },
1043
- follow: { type: "boolean", short: "f", default: false }, help: { type: "boolean", short: "h", default: false },
1044
- },
1045
- });
1425
+ if (IS_LINUX && systemdBooted()) run("systemctl", ["--user", "daemon-reload"]);
1426
+ clearServiceMode();
1427
+ if (v.purge) {
1428
+ try { fs.rmSync(HOME, { recursive: true, force: true }); } catch { /* */ }
1429
+ success(`Removed Coflux and everything in ${HOME}`);
1430
+ } else {
1431
+ success("Service removed");
1432
+ info(`Binaries, settings and credentials stay in ${HOME}. Run cofluxd uninstall --purge to remove them too.`);
1433
+ }
1434
+ }
1435
+
1436
+ const HELP = `Run this machine as a Coflux device: install, start, stop and update its service.
1437
+
1438
+ Usage:
1439
+ cofluxd <command> [flags]
1440
+
1441
+ Commands:
1442
+ up Install and start the service, then add this device to your account
1443
+ run Run the service in the foreground, as a container's entry point
1444
+ status Show the device, its service and its connection
1445
+ doctor Check the connection to the server and local access
1446
+ update Download the latest version; restart afterwards to apply it
1447
+ restart Restart the service; terminals stay open
1448
+ restart --ptyd Restart everything; closes all terminals on this device
1449
+ fda Grant Full Disk Access (macOS only)
1450
+ logs [-f] Show the service log
1451
+ down Stop the service; closes all terminals
1452
+ uninstall Remove the service
1453
+
1454
+ With no command, cofluxd runs up on a new device and status on a configured one.
1455
+
1456
+ Flags for up and run:
1457
+ --server <url> Server to connect to (default ${DEFAULT_SERVER})
1458
+ --name <name> Device name (default: the hostname)
1459
+ --shell <path> Shell for new terminals
1460
+ --key <key> One-time join key from Add Device in Coflux, valid for 1 hour
1461
+ --no-start Install without starting (up only)
1462
+
1463
+ Flags for up, run and update:
1464
+ --version <tag> Version to install, such as v2.15.0, or latest. up and run keep the
1465
+ installed version; update installs the latest.
1466
+ --bin-dir <dir> Install from a local build instead of downloading
1467
+
1468
+ Other flags:
1469
+ --purge With uninstall: also remove binaries, settings and credentials
1470
+ -f, --follow With logs: keep printing new lines
1471
+ -h, --help Show this help
1472
+
1473
+ Settings are kept in ~/.coflux/settings.json; run cofluxd up again after editing it.
1474
+
1475
+ On Linux without systemd, cofluxd runs the service itself. It does not start on boot; in a
1476
+ container, use cofluxd run as the entry point. For sign-in and terminals, use coflux.`;
1477
+
1478
+ /** parseArgs errors, first sentence only (the same wording as coflux). */
1479
+ function argumentError(error) {
1480
+ const first = String(error?.message ?? error).split(". ")[0];
1481
+ const missing = /^Option '(.+)' argument missing/.exec(first);
1482
+ if (missing) return `Option '${missing[1]}' needs a value`;
1483
+ const unexpected = /^Option '(.+)' does not take an argument/.exec(first);
1484
+ if (unexpected) return `Option '${unexpected[1]}' does not take a value`;
1485
+ return first;
1486
+ }
1487
+
1488
+ let parsedArgs;
1489
+ try {
1490
+ parsedArgs = parseArgs({
1491
+ allowPositionals: true,
1492
+ options: {
1493
+ server: { type: "string" }, name: { type: "string" }, shell: { type: "string" }, key: { type: "string" },
1494
+ version: { type: "string" }, "bin-dir": { type: "string" },
1495
+ "no-start": { type: "boolean", default: false }, purge: { type: "boolean", default: false },
1496
+ ptyd: { type: "boolean", default: false },
1497
+ follow: { type: "boolean", short: "f", default: false }, help: { type: "boolean", short: "h", default: false },
1498
+ },
1499
+ });
1500
+ } catch (error) {
1501
+ die(argumentError(error));
1502
+ }
1503
+ const { values, positionals } = parsedArgs;
1046
1504
  const cmd = positionals[0] || (fs.existsSync(SETTINGS) ? "status" : "up");
1047
1505
  if (values.help || cmd === "help") { console.log(HELP); process.exit(0); }
1048
- const handlers = { up: cmdUp, update: cmdUpdate, restart: cmdRestart, down: cmdDown, status: cmdStatus, doctor: cmdDoctor, fda: cmdFda, logs: cmdLogs, uninstall: cmdUninstall, "service-files": cmdServiceFiles };
1506
+ const handlers = { up: cmdUp, run: cmdRun, update: cmdUpdate, restart: cmdRestart, down: cmdDown, status: cmdStatus, doctor: cmdDoctor, fda: cmdFda, logs: cmdLogs, uninstall: cmdUninstall, "service-files": cmdServiceFiles };
1049
1507
  const handler = handlers[cmd];
1050
- if (!handler) die(`未知命令: ${cmd}\n登录与终端操作请使用 coflux。\n\n${HELP}`);
1508
+ if (!handler) die(`Unknown command: ${cmd}`, "Run cofluxd --help to see the commands. For sign-in and terminals, use coflux.");
1051
1509
  await handler(values);