cofluxd 0.3.0 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +29 -9
  2. package/cofluxd.mjs +356 -65
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -11,25 +11,45 @@ npm i -g cofluxd
11
11
  ## 用法
12
12
 
13
13
  ```sh
14
- cofluxd # 首次=交互式引导(问登记密钥/设备名,不问服务器地址),之后=看状态
15
- cofluxd onboard # 显式重新走交互式配置
16
- cofluxd up # 零参数即可:起服务后打印浏览器授权链接,登录确认即完成登记
17
- cofluxd up --enroll-key <KEY> # 走存量登记密钥流程(web「添加设备」给的命令)
18
- cofluxd status # 服务器/登记(含"等待授权")/服务状态
14
+ cofluxd # 首次=up(起服务后打印浏览器授权链接),之后=看状态
15
+ cofluxd up # 幂等:零参数即可装/起;已装则按当前配置重装服务并重启
16
+ cofluxd status # 服务器/登记(含"等待授权")/服务/连接状态
17
+ cofluxd doctor # 中心网络 + gateway/grant/loopback + daemon 状态分层自检
19
18
  cofluxd logs -f # 看 daemon 日志
20
- cofluxd update # 更新二进制并重启(worker 另可由 server 远程热升级)
21
- cofluxd reload # 改了 settings.json 后重启生效
19
+ cofluxd update # 更新本地 supervisor 二进制并重启(worker server 自动热升级)
22
20
  cofluxd down # 停止
23
21
  cofluxd uninstall [--purge] # 卸载(--purge 连二进制/配置/凭证一并删)
24
22
  ```
25
23
 
26
24
  默认连公共服务 `wss://api.coflux.dev/daemon`(自托管用 `--server` 改;已保存的地址继续生效,非默认时会有醒目提示)。
27
25
 
28
- 不带 `--enroll-key` 时(推荐、默认):`cofluxd up` 起服务后会打印一个一次性授权链接,在浏览器用已登录的账号打开确认即可,无需先去 web 控制台生成密钥。仍可用 `--enroll-key`(从 web 控制台「添加设备」获取)走无头/脚本化登记。
26
+ `cofluxd up` 起服务后会打印一个一次性授权链接,在浏览器用已登录的账号打开确认即可(链接可在任意设备打开,包括无头设备),无需先去 web 控制台生成密钥。已登记设备重跑 `up` 不会重新触发授权。
27
+
28
+ ## 本地优先与 doctor
29
+
30
+ desktop web 与 daemon 同机时,terminal/普通 Device RPC 优先连接本机固定 gateway(默认
31
+ `127.0.0.1:8788`);失败会自动走中心 opaque relay。远端访问始终可走 relay。`cofluxd doctor` 把两条
32
+ 路径分开诊断:
33
+
34
+ ```text
35
+ 中心:DNS → TCP → TLS → WebSocket
36
+ 本地:gateway bind → 持久 grant/Origin → loopback WebSocket
37
+ 状态:daemon → 中心的实际连接状态
38
+ ```
39
+
40
+ - 本地项失败:结论是“直连降级”,只影响同机低延迟路径;中心 relay 正常时 daemon 仍在线可用。
41
+ - 中心项失败:已经加载、已经配对且 cached direct 可用的页面仍能控制存活 session;刷新/冷启动不保证。
42
+ - 网络层都通但 daemon 未连接:查看 `cofluxd logs`,通常是认证、版本或服务进程问题。
43
+
44
+ doctor 只读取 gateway store 的结构、grant/Origin 数量和 bind 状态,不打印 browser 私钥、grant id、
45
+ device token 或其它凭证。它的 loopback 检查只做主机侧 WebSocket upgrade;浏览器自身的 LNA/permission
46
+ 仍以 Chrome/Safari/Firefox 页面实测为准。
47
+
48
+ > `onboard`、`reload` 命令已移除:onboard 并入零参数 `up`,reload 并入幂等化后的 `up`(重跑 `up` 即按 settings.json 重装服务并重启)。
29
49
 
30
50
  ## 配置
31
51
 
32
- 所有配置在 `~/.coflux/settings.json`(`serverUrl` / `enrollKey` / `deviceName` / `shell`,含密钥故权限 600),**daemon 直接读这个文件**。手改后 `cofluxd reload` 生效。
52
+ 所有配置在 `~/.coflux/settings.json`(`serverUrl` / `deviceName` / `shell`),**daemon 直接读这个文件**。手改后重跑 `cofluxd up` 生效。
33
53
 
34
54
  支持 macOS(launchd)/ Linux(systemd user service);服务崩溃自启、开机自启。
35
55
 
package/cofluxd.mjs CHANGED
@@ -8,6 +8,10 @@ import { join, dirname } from "node:path";
8
8
  import { createInterface } from "node:readline/promises";
9
9
  import fs from "node:fs";
10
10
  import { spawnSync } from "node:child_process";
11
+ import dns from "node:dns/promises";
12
+ import net from "node:net";
13
+ import tls from "node:tls";
14
+ import crypto from "node:crypto";
11
15
 
12
16
  // 默认中心服务(公共 SaaS);自托管用 --server 覆盖。
13
17
  const DEFAULT_SERVER = "wss://api.coflux.dev/daemon";
@@ -15,10 +19,12 @@ const DEFAULT_SERVER = "wss://api.coflux.dev/daemon";
15
19
  const REPO = "myWsq/coflux";
16
20
  const HOME = process.env.COFLUX_HOME || join(homedir(), ".coflux");
17
21
  const BIN_DIR = join(HOME, "bin");
18
- const SETTINGS = join(HOME, "settings.json"); // 用户配置(含一次性登记密钥)→ daemon 直接读;含密钥故 600
22
+ const SETTINGS = join(HOME, "settings.json"); // 用户配置(serverUrl/deviceName/shell)→ daemon 直接读;600 权限防同机其他用户窥探
19
23
  const LOG_FILE = join(HOME, "daemon.log");
20
24
  const CRED = join(HOME, "credentials.json");
21
25
  const PENDING_AUTH = join(HOME, "pending-auth.json"); // worker 落盘的待授权链接(daemon.authorizePending)
26
+ const CONN_STATE = join(HOME, "conn-state.json"); // worker 落盘的连接态快照(plan 033,见 crates/worker/src/conn_state.rs)
27
+ const LOCAL_GATEWAY_STORE = join(HOME, "local-gateway.json"); // gateway key/origin/grant;doctor 只读结构与数量,绝不打印秘密
22
28
  const FDA_STATUS = join(HOME, "fda-status"); // supervisor 启动时探测落盘(仅 macOS,见 crates/supervisor/src/fda.rs)
23
29
  const SUP_BIN = join(BIN_DIR, "coflux-supervisor");
24
30
  const WRK_BIN = join(BIN_DIR, "coflux-worker");
@@ -26,6 +32,7 @@ const IS_MAC = platform() === "darwin";
26
32
  const IS_LINUX = platform() === "linux";
27
33
  const PLIST = join(homedir(), "Library", "LaunchAgents", "com.coflux.daemon.plist");
28
34
  const UNIT = join(homedir(), ".config", "systemd", "user", "coflux-daemon.service");
35
+ const DEFAULT_LOCAL_GATEWAY_PORT = 8788; // 与 packages/crates protocol 的 LOCAL_GATEWAY_PORT 保持一致
29
36
 
30
37
  const die = (m) => { console.error("✗ " + m); process.exit(1); };
31
38
  const run = (cmd, args, opts = {}) => spawnSync(cmd, args, { encoding: "utf8", ...opts });
@@ -45,6 +52,59 @@ function readPendingAuth() {
45
52
  try { return JSON.parse(fs.readFileSync(PENDING_AUTH, "utf8")); } catch { return null; }
46
53
  }
47
54
 
55
+ function readConnState() {
56
+ try { return JSON.parse(fs.readFileSync(CONN_STATE, "utf8")); } catch { return null; }
57
+ }
58
+
59
+ function readLocalGatewaySummary() {
60
+ let store;
61
+ try { store = JSON.parse(fs.readFileSync(LOCAL_GATEWAY_STORE, "utf8")); }
62
+ catch {
63
+ return {
64
+ ok: false,
65
+ ready: false,
66
+ // JSON.parse 的错误在部分 Node 版本会带原文片段;store 含私钥,诊断输出绝不能回显。
67
+ error: fs.existsSync(LOCAL_GATEWAY_STORE) ? "grant store 无法解析" : "grant store 尚未创建",
68
+ };
69
+ }
70
+ if (store?.version !== 1 || !Array.isArray(store.origins) || !Array.isArray(store.grants)) {
71
+ return { ok: false, ready: false, error: "grant store 结构/版本无效" };
72
+ }
73
+ const origins = store.origins.filter((origin) => typeof origin === "string" && origin.length > 0);
74
+ const grants = store.grants.filter((grant) => grant && typeof grant.origin === "string" && grant.origin.length > 0);
75
+ const origin = grants.find((grant) => origins.includes(grant.origin))?.origin || origins[0];
76
+ const ready = grants.length > 0 && !!origin;
77
+ const summary = ready
78
+ ? `${grants.length} 个持久 grant / ${origins.length} 个 Origin`
79
+ : `${grants.length} 个 grant / ${origins.length} 个 Origin(尚无可用浏览器配对)`;
80
+ return {
81
+ ok: ready,
82
+ ready,
83
+ origin,
84
+ detail: ready ? summary : undefined,
85
+ error: ready ? undefined : summary,
86
+ };
87
+ }
88
+
89
+ function localGatewayPort() {
90
+ const raw = process.env.COFLUX_LOCAL_GATEWAY_PORT;
91
+ if (raw === undefined || raw === "") return { ok: true, port: DEFAULT_LOCAL_GATEWAY_PORT };
92
+ const port = Number(raw);
93
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
94
+ return { ok: false, error: `COFLUX_LOCAL_GATEWAY_PORT=${raw} 无法定位固定监听端口` };
95
+ }
96
+ return { ok: true, port };
97
+ }
98
+ const CONN_STATE_LABEL = { connecting: "连接中", connected: "已连接", reconnecting: "重连中" };
99
+ function formatDuration(ms) {
100
+ const s = Math.max(0, Math.round(ms / 1000));
101
+ if (s < 60) return `${s}秒`;
102
+ const m = Math.floor(s / 60);
103
+ if (m < 60) return `${m}分钟`;
104
+ const h = Math.floor(m / 60);
105
+ return `${h}小时${m % 60}分钟`;
106
+ }
107
+
48
108
  // "granted" | "denied" | "unknown" | null(文件不存在——supervisor 还没起过/还没写)
49
109
  function readFdaStatus() {
50
110
  try { return fs.readFileSync(FDA_STATUS, "utf8").trim(); } catch { return null; }
@@ -74,7 +134,9 @@ async function resolveLatestTag() {
74
134
  }
75
135
  }
76
136
 
77
- async function ensureBinaries({ version, binDir }) {
137
+ // skipIfPresent:up 的幂等语义——二进制已存在且调用方未显式传 version 时跳过下载,
138
+ // 避免重跑 up 变成隐式升级(用户拍板,见 plan 035 Decisions)。update/--bin-dir/显式 --version 不受影响。
139
+ async function ensureBinaries({ version, binDir, skipIfPresent }) {
78
140
  fs.mkdirSync(BIN_DIR, { recursive: true });
79
141
  if (binDir) {
80
142
  for (const b of ["coflux-supervisor", "coflux-worker"]) {
@@ -86,9 +148,13 @@ async function ensureBinaries({ version, binDir }) {
86
148
  console.log(`✓ 用本地二进制(${binDir})`);
87
149
  return;
88
150
  }
151
+ if (skipIfPresent && !version && fs.existsSync(SUP_BIN) && fs.existsSync(WRK_BIN)) {
152
+ console.log(`✓ 二进制已存在(${BIN_DIR}),跳过下载(用 cofluxd update 升级)`);
153
+ return;
154
+ }
89
155
  const t = rustTarget();
90
156
  let base;
91
- if (version === "latest") {
157
+ if (!version || version === "latest") {
92
158
  const tag = await resolveLatestTag();
93
159
  if (tag) console.log(`最新版本: ${tag}`);
94
160
  base = tag ? `https://github.com/${REPO}/releases/download/${tag}` : `https://github.com/${REPO}/releases/latest/download`;
@@ -102,12 +168,11 @@ async function ensureBinaries({ version, binDir }) {
102
168
  }
103
169
  }
104
170
 
105
- // 写 settings.json(daemon 直接读;含一次性登记密钥 → 600)。未提供 enrollKey 时保留旧值。
106
- function applyConfig({ serverUrl, enrollKey, deviceName, shell }) {
171
+ // 写 settings.json(daemon 直接读)。
172
+ function applyConfig({ serverUrl, deviceName, shell }) {
107
173
  fs.mkdirSync(HOME, { recursive: true });
108
174
  fs.chmodSync(HOME, 0o700);
109
- const old = readSettings();
110
- const settings = { serverUrl, deviceName, enrollKey: enrollKey || old.enrollKey || "" };
175
+ const settings = { serverUrl, deviceName };
111
176
  if (shell) settings.shell = shell;
112
177
  fs.writeFileSync(SETTINGS, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
113
178
  return settings;
@@ -169,17 +234,17 @@ function stopService() {
169
234
  else if (IS_LINUX) run("systemctl", ["--user", "stop", "coflux-daemon.service"]);
170
235
  }
171
236
 
172
- async function applyAndStart({ serverUrl, enrollKey, deviceName, shell, version, binDir, noStart }) {
173
- await ensureBinaries({ version, binDir });
174
- const settings = applyConfig({ serverUrl, enrollKey, deviceName, shell });
237
+ async function applyAndStart({ serverUrl, deviceName, shell, version, binDir, noStart }) {
238
+ await ensureBinaries({ version, binDir, skipIfPresent: true });
239
+ applyConfig({ serverUrl, deviceName, shell });
175
240
  // 非默认服务器醒目提示:保存值/--server 仍生效(不强制覆盖),但防止 staging 之类残留值静默错连。
176
241
  if (serverUrl !== DEFAULT_SERVER) {
177
242
  console.log(`⚠ 使用非默认服务器: ${serverUrl}`);
178
243
  }
179
244
  installService(!noStart);
180
245
  console.log(noStart ? "已安装(未启动)。" : `✓ daemon 已启动 → ${serverUrl}`);
181
- if (!noStart && !settings.enrollKey && !fs.existsSync(CRED)) {
182
- // 默认流程(零参数 up):无登记密钥、未登记 → 走浏览器授权,轮询 daemon 落盘的链接/凭证文件。
246
+ if (!noStart && !fs.existsSync(CRED)) {
247
+ // 未登记 → 走浏览器授权,轮询 daemon 落盘的链接/凭证文件。
183
248
  await waitForAuthorization();
184
249
  } else {
185
250
  cmdStatus();
@@ -222,47 +287,37 @@ async function cmdUp(v) {
222
287
  const s = readSettings();
223
288
  await applyAndStart({
224
289
  serverUrl: v.server || s.serverUrl || DEFAULT_SERVER,
225
- enrollKey: v["enroll-key"],
226
290
  deviceName: v.name || s.deviceName || hostname(),
227
291
  shell: v.shell || s.shell,
228
292
  version: v.version, binDir: v["bin-dir"], noStart: v["no-start"],
229
293
  });
230
294
  }
231
295
 
232
- async function cmdOnboard(v) {
233
- const s = readSettings();
234
- // 服务器地址不再交互询问(用户拍板 2026-07-04):--server > 已保存值 > 默认公共服务;
235
- // 优先级与 cmdUp 一致(见 packages/cli/cofluxd.mjs 里 applyAndStart 的非默认提示)。
236
- const serverUrl = v.server || s.serverUrl || DEFAULT_SERVER;
237
- console.log("\n 欢迎使用 coflux —— 配置这台设备\n ──────────────────────────────\n");
238
- console.log(` 服务器: ${serverUrl}\n`);
239
- console.log(" 登记方式:留空走浏览器授权(推荐,起服务后打印链接,登录确认即可);\n 已有登记密钥可直接粘贴。\n");
240
- const rl = createInterface({ input: process.stdin, output: process.stdout });
241
- try {
242
- const enrollKey = (v["enroll-key"] ?? (await rl.question("登记密钥(可留空): "))).trim();
243
- const deviceName = (await rl.question(`设备名 [${s.deviceName || hostname()}]: `)).trim() || s.deviceName || hostname();
244
- rl.close();
245
- console.log("");
246
- await applyAndStart({ serverUrl, enrollKey, deviceName, shell: s.shell, version: v.version, binDir: v["bin-dir"], noStart: v["no-start"] });
247
- } finally {
248
- rl.close();
249
- }
250
- }
251
-
252
- function cmdReload() {
253
- if (!fs.existsSync(SETTINGS)) die("无 settings.json,先 cofluxd up 或 cofluxd onboard");
254
- restartService(); // daemon 重启时重新读 settings.json
255
- console.log("✓ 已重启(daemon 重新读取 settings.json)");
256
- }
257
-
258
296
  function cmdDown() { stopService(); console.log("✓ 已停止"); }
259
297
 
298
+ // 只有 supervisor 真正靠这条命令更新——它不自动升级(持 PTY,重启会杀会话,见 plan 017)。
299
+ // worker 运行中会被 server 自动热升级到最新 stable(无需停服);这里顺带刷新的是 worker 的
300
+ // "内置兜底二进制"(supervisor 冷启动/热升级缓存皆无时的 fallback),日常可不管。
260
301
  async function cmdUpdate(v) {
261
- if (!fs.existsSync(SETTINGS)) die("尚未安装,先 cofluxd up / onboard");
262
- await ensureBinaries({ version: v.version, binDir: v["bin-dir"] });
302
+ if (!fs.existsSync(SETTINGS)) die("尚未安装,先 cofluxd up");
303
+ const version = v.version || "latest";
304
+ await ensureBinaries({ version, binDir: v["bin-dir"] });
263
305
  restartService();
264
- console.log(`✓ 已更新到 ${v["bin-dir"] ? "本地产物" : v.version} 并重启(supervisor)`);
265
- console.log(" 注:worker 还可由 server 远程热升级,无需停服。");
306
+ console.log(`✓ supervisor 已更新到 ${v["bin-dir"] ? "本地产物" : version} 并重启`);
307
+ console.log(" worker server 自动热升级,通常无需手动更新;此命令只更新 supervisor(及其兜底 worker 二进制)。");
308
+ }
309
+
310
+ // launchd(macOS) / systemd --user(Linux) 服务活跃态查询,status 与 doctor 共用。
311
+ function serviceRunningInfo() {
312
+ if (IS_MAC) {
313
+ const running = run("launchctl", ["list", "com.coflux.daemon"]).status === 0;
314
+ return { running, active: running ? "运行中" : "未运行" };
315
+ }
316
+ if (IS_LINUX) {
317
+ const active = (run("systemctl", ["--user", "is-active", "coflux-daemon.service"]).stdout || "").trim() || "未运行";
318
+ return { running: active === "active", active };
319
+ }
320
+ return { running: false, active: "未运行" };
266
321
  }
267
322
 
268
323
  function cmdStatus() {
@@ -280,19 +335,21 @@ function cmdStatus() {
280
335
  } else {
281
336
  console.log("凭证: 未登记");
282
337
  }
283
- let running = false, active = "未运行";
284
- if (IS_MAC) {
285
- running = run("launchctl", ["list", "com.coflux.daemon"]).status === 0;
286
- active = running ? "运行中" : "未运行";
287
- } else if (IS_LINUX) {
288
- active = (run("systemctl", ["--user", "is-active", "coflux-daemon.service"]).stdout || "").trim() || "未运行";
289
- running = active === "active";
290
- }
338
+ const { running, active } = serviceRunningInfo();
291
339
  let pid = "";
292
340
  if (running) {
293
341
  try { pid = ` (worker pid ${fs.readFileSync(join(HOME, "worker.pid"), "utf8").trim()})`; } catch { /* */ }
294
342
  }
295
343
  console.log(`服务: ${active}${pid}`);
344
+ // conn-state.json 是 worker 生前写的快照:进程不在时是 stale 数据,忽略——不能让"进程活着"
345
+ // 误当成"在线",也不能让 stale 文件在进程死后继续显示假的连接态。
346
+ if (running) {
347
+ const conn = readConnState();
348
+ if (conn?.state && CONN_STATE_LABEL[conn.state]) {
349
+ const dur = Number.isFinite(conn.since) ? formatDuration(Date.now() - conn.since) : null;
350
+ console.log(`连接: ${CONN_STATE_LABEL[conn.state]}${dur ? `(已 ${dur})` : ""}`);
351
+ }
352
+ }
296
353
  if (IS_MAC) {
297
354
  const fda = readFdaStatus();
298
355
  const hint = fda === "granted" ? "" : "(`cofluxd fda` 引导授权,避免弹窗卡住 PTY 会话)";
@@ -300,6 +357,235 @@ function cmdStatus() {
300
357
  }
301
358
  }
302
359
 
360
+ /* ------------------------------ doctor:中心 + 本地直连分层自检 ------------------------------ */
361
+ // 中心与 loopback 都只做传输层探测,不解析 coflux 协议消息——CLI 保持零协议。中心 WS
362
+ // 成功升级后立即断开;loopback 只验证已持久 Origin 能拿到 101,不发送 browser 私钥或 grant。
363
+ const DOCTOR_TIMEOUT_MS = 5000;
364
+
365
+ function parseServerUrl(serverUrl) {
366
+ const u = new URL(serverUrl);
367
+ const useTls = u.protocol === "wss:";
368
+ if (!useTls && u.protocol !== "ws:") throw new Error(`不支持的协议: ${u.protocol}(需 ws:// 或 wss://)`);
369
+ const port = u.port ? Number(u.port) : (useTls ? 443 : 80);
370
+ const path = u.pathname || "/";
371
+ return { host: u.hostname, port, path, useTls };
372
+ }
373
+
374
+ async function timed(fn) {
375
+ const t0 = Date.now();
376
+ try { return { ok: true, ms: Date.now() - t0, ...(await fn()) }; }
377
+ catch (e) { return { ok: false, ms: Date.now() - t0, error: e?.message || String(e) }; }
378
+ }
379
+
380
+ function probeDns(host) {
381
+ return timed(async () => {
382
+ const addrs = await dns.lookup(host, { all: true });
383
+ return { detail: addrs.map((a) => a.address).join(", ") };
384
+ });
385
+ }
386
+
387
+ function probeTcp(host, port) {
388
+ return new Promise((resolve) => {
389
+ const t0 = Date.now();
390
+ const socket = net.connect({ host, port, timeout: DOCTOR_TIMEOUT_MS });
391
+ const done = (ok, extra) => { socket.destroy(); resolve({ ok, ms: Date.now() - t0, ...extra }); };
392
+ socket.once("connect", () => done(true, {}));
393
+ socket.once("timeout", () => done(false, { error: `连接超时(>${DOCTOR_TIMEOUT_MS}ms)` }));
394
+ socket.once("error", (e) => done(false, { error: e.message }));
395
+ });
396
+ }
397
+
398
+ function probeTls(host, port) {
399
+ return new Promise((resolve) => {
400
+ const t0 = Date.now();
401
+ const socket = tls.connect({ host, port, servername: host, timeout: DOCTOR_TIMEOUT_MS }, () => {
402
+ const detail = socket.authorized ? socket.getProtocol() : `${socket.getProtocol()}(证书未验证: ${socket.authorizationError})`;
403
+ socket.destroy();
404
+ resolve({ ok: true, ms: Date.now() - t0, detail });
405
+ });
406
+ socket.once("timeout", () => { socket.destroy(); resolve({ ok: false, ms: Date.now() - t0, error: `握手超时(>${DOCTOR_TIMEOUT_MS}ms)` }); });
407
+ socket.once("error", (e) => resolve({ ok: false, ms: Date.now() - t0, error: e.message }));
408
+ });
409
+ }
410
+
411
+ // 手写一条最小 HTTP/1.1 Upgrade 请求,只看是否拿到 101——不建立真实 WebSocket 帧连接。
412
+ function probeWsUpgrade({ host, port, path, useTls, origin }) {
413
+ return new Promise((resolve) => {
414
+ const t0 = Date.now();
415
+ const key = crypto.randomBytes(16).toString("base64");
416
+ const hostHeader = host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`;
417
+ const originHeader = origin ? `Origin: ${origin}\r\n` : "";
418
+ const req = `GET ${path} HTTP/1.1\r\nHost: ${hostHeader}\r\n${originHeader}Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${key}\r\n\r\n`;
419
+ let settled = false;
420
+ const finish = (r) => {
421
+ if (settled) return;
422
+ settled = true;
423
+ resolve({ ms: Date.now() - t0, ...r });
424
+ };
425
+ const onOpen = (socket) => {
426
+ let buf = "";
427
+ const timer = setTimeout(() => { socket.destroy(); finish({ ok: false, error: `升级响应超时(>${DOCTOR_TIMEOUT_MS}ms)` }); }, DOCTOR_TIMEOUT_MS);
428
+ socket.on("data", (chunk) => {
429
+ buf += chunk.toString("latin1");
430
+ if (!buf.includes("\r\n\r\n")) return;
431
+ clearTimeout(timer);
432
+ const statusLine = buf.split("\r\n")[0];
433
+ socket.destroy();
434
+ const ok = /^HTTP\/1\.\d 101\b/.test(statusLine);
435
+ finish(ok ? { ok, detail: statusLine } : { ok, error: `未收到 101 切换协议响应: ${statusLine}` });
436
+ });
437
+ socket.once("error", (e) => { clearTimeout(timer); finish({ ok: false, error: e.message }); });
438
+ socket.write(req);
439
+ };
440
+ const socket = useTls
441
+ ? tls.connect({ host, port, servername: host, timeout: DOCTOR_TIMEOUT_MS })
442
+ : net.connect({ host, port, timeout: DOCTOR_TIMEOUT_MS });
443
+ socket.once(useTls ? "secureConnect" : "connect", () => onOpen(socket));
444
+ socket.once("timeout", () => { socket.destroy(); finish({ ok: false, error: `连接超时(>${DOCTOR_TIMEOUT_MS}ms)` }); });
445
+ socket.once("error", (e) => finish({ ok: false, error: e.message }));
446
+ });
447
+ }
448
+
449
+ function printLayer(name, r) {
450
+ const mark = r.ok ? "✓" : "✗";
451
+ const msg = r.ok ? (r.detail ? `→ ${r.detail}` : "") : (r.error || "");
452
+ console.log(` ${mark} ${name} (${r.ms}ms)${msg ? ` ${msg}` : ""}`);
453
+ }
454
+
455
+ function printLocalLayer(name, r) {
456
+ const mark = r.ok ? "✓" : "⚠";
457
+ const msg = r.ok ? (r.detail ? `→ ${r.detail}` : "") : (r.error || "");
458
+ const elapsed = Number.isFinite(r.ms) ? ` (${r.ms}ms)` : "";
459
+ console.log(` ${mark} ${name}${elapsed}${msg ? ` ${msg}` : ""}`);
460
+ }
461
+
462
+ // 本地事实汇总:服务进程存活、conn-state.json 连接态、凭证有无、FDA。返回连接态三态:
463
+ // "connected" | "not-connected"(有快照但非 connected,如 connecting/reconnecting)
464
+ // | "unknown"(服务未运行,或无快照——daemon 版本较旧还没写、或刚启动)。
465
+ // 三态区分是因为"无快照"不等于"未连接":旧版 worker 不写 conn-state.json,把它当"未连接"
466
+ // 会把"连接态未知"误报成"认证/授权层有问题"(2026-07-23 实操验收发现)。
467
+ function printLocalFacts() {
468
+ console.log("\n Daemon 状态\n ───────────");
469
+ const registered = fs.existsSync(CRED);
470
+ console.log(` 凭证: ${registered ? "已登记" : "未登记"}`);
471
+ const { running, active } = serviceRunningInfo();
472
+ console.log(` 服务: ${active}`);
473
+ let connState = "unknown";
474
+ if (running) {
475
+ const conn = readConnState();
476
+ if (conn?.state && CONN_STATE_LABEL[conn.state]) {
477
+ console.log(` 中心: ${CONN_STATE_LABEL[conn.state]}`);
478
+ connState = conn.state === "connected" ? "connected" : "not-connected";
479
+ } else {
480
+ console.log(" 中心: (无连接态快照)");
481
+ }
482
+ } else {
483
+ console.log(" 中心: 未探测(服务未运行)");
484
+ }
485
+ if (IS_MAC) console.log(` FDA: ${fdaLabel(readFdaStatus())}`);
486
+ console.log("");
487
+ return { registered, running, connState };
488
+ }
489
+
490
+ function printConclusion(level, msg) {
491
+ const mark = level === "ok" ? "✓" : level === "warning" ? "⚠" : "✗";
492
+ console.log(` ${mark} ${msg}\n`);
493
+ }
494
+
495
+ async function probeCenter(target) {
496
+ const { host, port, useTls } = target;
497
+ const dnsR = await probeDns(host);
498
+ printLayer("DNS 解析", dnsR);
499
+ if (!dnsR.ok) {
500
+ return { ok: false, reason: "DNS 解析失败——检查网络连接/DNS 配置,或该域名是否可达。" };
501
+ }
502
+
503
+ const tcpR = await probeTcp(host, port);
504
+ printLayer(`TCP 连接 (${host}:${port})`, tcpR);
505
+ if (!tcpR.ok) {
506
+ return { ok: false, reason: "DNS 可解析但 TCP 连不上——防火墙/代理拦截,或目标端口未开放。" };
507
+ }
508
+
509
+ if (useTls) {
510
+ const tlsR = await probeTls(host, port);
511
+ printLayer("TLS 握手", tlsR);
512
+ if (!tlsR.ok) {
513
+ return { ok: false, reason: "TCP 可连但 TLS 握手失败——可能是企业代理 MITM 证书、系统时间错误,或服务端证书问题。" };
514
+ }
515
+ }
516
+
517
+ const wsR = await probeWsUpgrade(target);
518
+ printLayer("WS 升级握手", wsR);
519
+ if (!wsR.ok) {
520
+ return { ok: false, reason: "网络层通但 WebSocket 升级被拒——可能是反代/负载均衡未正确转发 Upgrade 头,或路径不对。" };
521
+ }
522
+ return { ok: true };
523
+ }
524
+
525
+ async function probeLocalDirect() {
526
+ const portResult = localGatewayPort();
527
+ if (!portResult.ok) {
528
+ printLocalLayer("Gateway bind", { ok: false, error: portResult.error });
529
+ printLocalLayer("Loopback WS", { ok: false, error: "固定 gateway 端口未知,无法探测" });
530
+ const grant = readLocalGatewaySummary();
531
+ printLocalLayer("本地 grant", grant);
532
+ return { ready: false };
533
+ }
534
+
535
+ const bind = await probeTcp("127.0.0.1", portResult.port);
536
+ printLocalLayer(`Gateway bind (127.0.0.1:${portResult.port})`, bind);
537
+ const grant = readLocalGatewaySummary();
538
+ printLocalLayer("本地 grant", grant);
539
+
540
+ let loopback;
541
+ if (!bind.ok) {
542
+ loopback = { ok: false, error: "gateway 未监听,跳过 WS 握手" };
543
+ } else {
544
+ loopback = await probeWsUpgrade({
545
+ host: "127.0.0.1",
546
+ port: portResult.port,
547
+ path: "/device",
548
+ useTls: false,
549
+ // 没有持久 Origin 时仍发一个合法 Origin;403 能区分“coflux gateway 可达但未配对”。
550
+ origin: grant.origin || "http://127.0.0.1",
551
+ });
552
+ }
553
+ printLocalLayer("Loopback WS(主机侧)", loopback);
554
+ return { ready: bind.ok && grant.ready && loopback.ok };
555
+ }
556
+
557
+ async function cmdDoctor() {
558
+ const s = readSettings();
559
+ const serverUrl = s.serverUrl || DEFAULT_SERVER;
560
+ console.log(`\n 中心网络 —— ${serverUrl}\n ────────────────────────────`);
561
+ let center;
562
+ try {
563
+ center = await probeCenter(parseServerUrl(serverUrl));
564
+ } catch (error) {
565
+ printLayer("server URL", { ok: false, ms: 0, error: `${serverUrl}(${error.message})` });
566
+ center = { ok: false, reason: "中心地址配置无效。" };
567
+ }
568
+
569
+ console.log("\n 本地直连\n ────────");
570
+ const direct = await probeLocalDirect();
571
+ const facts = printLocalFacts();
572
+ const relayReady = center.ok && facts.running && facts.connState === "connected";
573
+
574
+ if (direct.ready && relayReady) {
575
+ printConclusion("ok", "本地直连与中心 relay 均可用。");
576
+ } else if (!direct.ready && relayReady) {
577
+ printConclusion("warning", "直连降级:中心 relay 仍可用,daemon 不是离线;检查上面的 gateway/grant/loopback 项。");
578
+ } else if (direct.ready && !center.ok) {
579
+ printConclusion("warning", "中心不可达,但本地直连可用;已加载且已配对页面可继续会话,刷新/冷启动不保证。");
580
+ } else if (direct.ready) {
581
+ printConclusion("warning", "本地直连可用;中心网络可达,但 daemon→中心连接未确认,relay 状态未知。");
582
+ } else if (center.ok) {
583
+ printConclusion("warning", "直连降级;中心网络可达,但 daemon→中心连接未确认。不要据此把 daemon 判为离线,查 `cofluxd logs`。");
584
+ } else {
585
+ printConclusion("error", `${center.reason || "中心不可达"} 同时本地直连降级;当前两条路径都未确认可用。`);
586
+ }
587
+ }
588
+
303
589
  function cmdLogs(v) {
304
590
  if (IS_MAC) {
305
591
  if (!fs.existsSync(LOG_FILE)) die(`暂无日志 ${LOG_FILE}`);
@@ -341,29 +627,34 @@ function cmdUninstall(v) {
341
627
 
342
628
  const HELP = `cofluxd —— coflux daemon 管理
343
629
 
344
- cofluxd 首次=交互式配置(onboard),已配置=status
345
- cofluxd onboard 交互式配置并启用
346
- cofluxd up [flags] 非交互装/起,零参数即可(不带 --enroll-key 时打印浏览器授权链接)
347
- cofluxd reload ~/.coflux/settings.json 重载并重启
348
- cofluxd update 更新二进制并重启(worker 另可远程热升级)
349
- cofluxd status 服务器/登记(含"等待授权")/服务状态
630
+ cofluxd 首次=up(打印浏览器授权链接),已配置=status
631
+ cofluxd up [flags] 幂等:首次装+起,已装则按当前 settings.json 重装服务并重启
632
+ cofluxd status 服务器/登记(含"等待授权")/服务/连接状态
633
+ cofluxd doctor 中心网络 + gateway bind/grant/loopback + daemon 状态分层自检
634
+ cofluxd update 更新本地 supervisor 二进制并重启(worker 由 server 自动热升级)
350
635
  cofluxd fda [仅 macOS] 引导授予完全磁盘访问权限(避免 PTY 因 TCC 弹窗卡住)
351
636
  cofluxd logs [-f] 看 daemon 日志
352
637
  cofluxd down 停止
353
638
  cofluxd uninstall [--purge] 卸载(--purge 连二进制/配置/凭证一并删)
354
639
 
355
- up flags: --server <ws://.../daemon> --enroll-key <KEY>(留空则走浏览器授权) --name <名> --shell <路径>
356
- 通用: --version <vX|latest>(默认 latest) --bin-dir <dir>(用本地 cargo 产物) --no-start
357
- 配置都在 ~/.coflux/settings.json(serverUrl/enrollKey/deviceName/shell,含密钥故 600),daemon 直接读;改后 cofluxd reload 生效。`;
640
+ up flags: --server <ws://.../daemon> --name <名> --shell <路径>
641
+ 通用: --version <vX|latest>(不传时 up 沿用已有二进制,update 默认 latest) --bin-dir <dir>(用本地 cargo 产物) --no-start
642
+ 配置都在 ~/.coflux/settings.json(serverUrl/deviceName/shell),daemon 直接读;改后重跑 cofluxd up 生效。`;
643
+
644
+ // onboard/reload 已在命令面重梳中移除(用户拍板 2026-07-23,见 plans/035):onboard 的交互
645
+ // 问答只剩"问设备名",价值不足以撑一个命令;reload 与幂等化后的 up 语义重复。
646
+ const MIGRATED = {
647
+ onboard: "onboard 已并入 up,直接运行 `cofluxd up`",
648
+ reload: "reload 已并入 up(up 现幂等,会按 settings.json 重装服务并重启),直接运行 `cofluxd up`",
649
+ };
358
650
 
359
651
  const { values, positionals } = parseArgs({
360
652
  allowPositionals: true,
361
653
  options: {
362
654
  server: { type: "string" },
363
- "enroll-key": { type: "string" },
364
655
  name: { type: "string" },
365
656
  shell: { type: "string" },
366
- version: { type: "string", default: "latest" },
657
+ version: { type: "string" },
367
658
  "bin-dir": { type: "string" },
368
659
  "no-start": { type: "boolean", default: false },
369
660
  purge: { type: "boolean", default: false },
@@ -374,9 +665,9 @@ const { values, positionals } = parseArgs({
374
665
 
375
666
  let cmd = positionals[0];
376
667
  if (values.help || cmd === "help") { console.log(HELP); process.exit(0); }
377
- if (!cmd) cmd = fs.existsSync(SETTINGS) ? "status" : "onboard"; // 首次裸跑 → 引导
668
+ if (!cmd) cmd = fs.existsSync(SETTINGS) ? "status" : "up"; // 首次裸跑 → 引导
378
669
 
379
- const handlers = { up: cmdUp, onboard: cmdOnboard, reload: cmdReload, update: cmdUpdate, down: cmdDown, status: cmdStatus, fda: cmdFda, logs: cmdLogs, uninstall: cmdUninstall };
670
+ const handlers = { up: cmdUp, update: cmdUpdate, down: cmdDown, status: cmdStatus, doctor: cmdDoctor, fda: cmdFda, logs: cmdLogs, uninstall: cmdUninstall };
380
671
  const h = handlers[cmd];
381
- if (!h) die(`未知命令: ${cmd}\n\n${HELP}`);
672
+ if (!h) die(`未知命令: ${cmd}${MIGRATED[cmd] ? `\n${MIGRATED[cmd]}` : ""}\n\n${HELP}`);
382
673
  await h(values);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cofluxd",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "coflux daemon 管理 CLI:装/起/停/升级 Rust daemon(supervisor + worker)",
5
5
  "type": "module",
6
6
  "bin": {