cofluxd 0.2.0 → 0.4.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/README.md +11 -9
- package/cofluxd.mjs +296 -65
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,25 +11,27 @@ npm i -g cofluxd
|
|
|
11
11
|
## 用法
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
|
-
cofluxd #
|
|
15
|
-
cofluxd
|
|
16
|
-
cofluxd
|
|
17
|
-
cofluxd
|
|
18
|
-
cofluxd status # 服务器/登记(含"等待授权")/服务状态
|
|
14
|
+
cofluxd # 首次=up(起服务后打印浏览器授权链接),之后=看状态
|
|
15
|
+
cofluxd up # 幂等:零参数即可装/起;已装则按当前配置重装服务并重启
|
|
16
|
+
cofluxd status # 服务器/登记(含"等待授权")/服务/连接状态
|
|
17
|
+
cofluxd doctor # 分层连通性自检(DNS→TCP→TLS→WS 升级)+ 本地状态汇总
|
|
19
18
|
cofluxd logs -f # 看 daemon 日志
|
|
20
|
-
cofluxd update #
|
|
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
|
-
|
|
26
|
+
`cofluxd up` 起服务后会打印一个一次性授权链接,在浏览器用已登录的账号打开确认即可(链接可在任意设备打开,包括无头设备),无需先去 web 控制台生成密钥。已登记设备重跑 `up` 不会重新触发授权。
|
|
27
|
+
|
|
28
|
+
连不上时用 `cofluxd doctor` 逐层排查(DNS 解析 → TCP 连接 → TLS 握手 → WS 升级握手),失败层会给出具体指向;各层都通但仍未连接说明问题在认证层,查 `cofluxd logs`。
|
|
29
|
+
|
|
30
|
+
> `onboard`、`reload` 命令已移除:onboard 并入零参数 `up`,reload 并入幂等化后的 `up`(重跑 `up` 即按 settings.json 重装服务并重启)。
|
|
29
31
|
|
|
30
32
|
## 配置
|
|
31
33
|
|
|
32
|
-
所有配置在 `~/.coflux/settings.json`(`serverUrl` / `
|
|
34
|
+
所有配置在 `~/.coflux/settings.json`(`serverUrl` / `deviceName` / `shell`),**daemon 直接读这个文件**。手改后重跑 `cofluxd up` 生效。
|
|
33
35
|
|
|
34
36
|
支持 macOS(launchd)/ Linux(systemd user service);服务崩溃自启、开机自启。
|
|
35
37
|
|
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"); //
|
|
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 FDA_STATUS = join(HOME, "fda-status"); // supervisor 启动时探测落盘(仅 macOS,见 crates/supervisor/src/fda.rs)
|
|
22
28
|
const SUP_BIN = join(BIN_DIR, "coflux-supervisor");
|
|
23
29
|
const WRK_BIN = join(BIN_DIR, "coflux-worker");
|
|
24
30
|
const IS_MAC = platform() === "darwin";
|
|
@@ -44,6 +50,27 @@ function readPendingAuth() {
|
|
|
44
50
|
try { return JSON.parse(fs.readFileSync(PENDING_AUTH, "utf8")); } catch { return null; }
|
|
45
51
|
}
|
|
46
52
|
|
|
53
|
+
function readConnState() {
|
|
54
|
+
try { return JSON.parse(fs.readFileSync(CONN_STATE, "utf8")); } catch { return null; }
|
|
55
|
+
}
|
|
56
|
+
const CONN_STATE_LABEL = { connecting: "连接中", connected: "已连接", reconnecting: "重连中" };
|
|
57
|
+
function formatDuration(ms) {
|
|
58
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
59
|
+
if (s < 60) return `${s}秒`;
|
|
60
|
+
const m = Math.floor(s / 60);
|
|
61
|
+
if (m < 60) return `${m}分钟`;
|
|
62
|
+
const h = Math.floor(m / 60);
|
|
63
|
+
return `${h}小时${m % 60}分钟`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// "granted" | "denied" | "unknown" | null(文件不存在——supervisor 还没起过/还没写)
|
|
67
|
+
function readFdaStatus() {
|
|
68
|
+
try { return fs.readFileSync(FDA_STATUS, "utf8").trim(); } catch { return null; }
|
|
69
|
+
}
|
|
70
|
+
function fdaLabel(status) {
|
|
71
|
+
return status === "granted" ? "已授予" : status === "denied" ? "未授予" : "未知";
|
|
72
|
+
}
|
|
73
|
+
|
|
47
74
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
48
75
|
|
|
49
76
|
async function download(url, dest) {
|
|
@@ -65,7 +92,9 @@ async function resolveLatestTag() {
|
|
|
65
92
|
}
|
|
66
93
|
}
|
|
67
94
|
|
|
68
|
-
|
|
95
|
+
// skipIfPresent:up 的幂等语义——二进制已存在且调用方未显式传 version 时跳过下载,
|
|
96
|
+
// 避免重跑 up 变成隐式升级(用户拍板,见 plan 035 Decisions)。update/--bin-dir/显式 --version 不受影响。
|
|
97
|
+
async function ensureBinaries({ version, binDir, skipIfPresent }) {
|
|
69
98
|
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
70
99
|
if (binDir) {
|
|
71
100
|
for (const b of ["coflux-supervisor", "coflux-worker"]) {
|
|
@@ -77,9 +106,13 @@ async function ensureBinaries({ version, binDir }) {
|
|
|
77
106
|
console.log(`✓ 用本地二进制(${binDir})`);
|
|
78
107
|
return;
|
|
79
108
|
}
|
|
109
|
+
if (skipIfPresent && !version && fs.existsSync(SUP_BIN) && fs.existsSync(WRK_BIN)) {
|
|
110
|
+
console.log(`✓ 二进制已存在(${BIN_DIR}),跳过下载(用 cofluxd update 升级)`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
80
113
|
const t = rustTarget();
|
|
81
114
|
let base;
|
|
82
|
-
if (version === "latest") {
|
|
115
|
+
if (!version || version === "latest") {
|
|
83
116
|
const tag = await resolveLatestTag();
|
|
84
117
|
if (tag) console.log(`最新版本: ${tag}`);
|
|
85
118
|
base = tag ? `https://github.com/${REPO}/releases/download/${tag}` : `https://github.com/${REPO}/releases/latest/download`;
|
|
@@ -93,12 +126,11 @@ async function ensureBinaries({ version, binDir }) {
|
|
|
93
126
|
}
|
|
94
127
|
}
|
|
95
128
|
|
|
96
|
-
// 写 settings.json(daemon
|
|
97
|
-
function applyConfig({ serverUrl,
|
|
129
|
+
// 写 settings.json(daemon 直接读)。
|
|
130
|
+
function applyConfig({ serverUrl, deviceName, shell }) {
|
|
98
131
|
fs.mkdirSync(HOME, { recursive: true });
|
|
99
132
|
fs.chmodSync(HOME, 0o700);
|
|
100
|
-
const
|
|
101
|
-
const settings = { serverUrl, deviceName, enrollKey: enrollKey || old.enrollKey || "" };
|
|
133
|
+
const settings = { serverUrl, deviceName };
|
|
102
134
|
if (shell) settings.shell = shell;
|
|
103
135
|
fs.writeFileSync(SETTINGS, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
|
|
104
136
|
return settings;
|
|
@@ -160,21 +192,25 @@ function stopService() {
|
|
|
160
192
|
else if (IS_LINUX) run("systemctl", ["--user", "stop", "coflux-daemon.service"]);
|
|
161
193
|
}
|
|
162
194
|
|
|
163
|
-
async function applyAndStart({ serverUrl,
|
|
164
|
-
await ensureBinaries({ version, binDir });
|
|
165
|
-
|
|
195
|
+
async function applyAndStart({ serverUrl, deviceName, shell, version, binDir, noStart }) {
|
|
196
|
+
await ensureBinaries({ version, binDir, skipIfPresent: true });
|
|
197
|
+
applyConfig({ serverUrl, deviceName, shell });
|
|
166
198
|
// 非默认服务器醒目提示:保存值/--server 仍生效(不强制覆盖),但防止 staging 之类残留值静默错连。
|
|
167
199
|
if (serverUrl !== DEFAULT_SERVER) {
|
|
168
200
|
console.log(`⚠ 使用非默认服务器: ${serverUrl}`);
|
|
169
201
|
}
|
|
170
202
|
installService(!noStart);
|
|
171
203
|
console.log(noStart ? "已安装(未启动)。" : `✓ daemon 已启动 → ${serverUrl}`);
|
|
172
|
-
if (!noStart && !
|
|
173
|
-
//
|
|
204
|
+
if (!noStart && !fs.existsSync(CRED)) {
|
|
205
|
+
// 未登记 → 走浏览器授权,轮询 daemon 落盘的链接/凭证文件。
|
|
174
206
|
await waitForAuthorization();
|
|
175
207
|
} else {
|
|
176
208
|
cmdStatus();
|
|
177
209
|
}
|
|
210
|
+
if (IS_MAC && !noStart && readFdaStatus() !== "granted") {
|
|
211
|
+
console.log("\n⚠ 完全磁盘访问权限尚未授予:PTY 里访问桌面/文稿/下载等目录时,系统弹窗可能因无人点击而卡住。");
|
|
212
|
+
console.log(" 运行 `cofluxd fda` 完成一次性授权(授予后需重启服务生效)。");
|
|
213
|
+
}
|
|
178
214
|
}
|
|
179
215
|
|
|
180
216
|
// 轮询 ~/.coflux/pending-auth.json(授权链接)与 credentials.json(登记成功)给出全程反馈。
|
|
@@ -209,47 +245,37 @@ async function cmdUp(v) {
|
|
|
209
245
|
const s = readSettings();
|
|
210
246
|
await applyAndStart({
|
|
211
247
|
serverUrl: v.server || s.serverUrl || DEFAULT_SERVER,
|
|
212
|
-
enrollKey: v["enroll-key"],
|
|
213
248
|
deviceName: v.name || s.deviceName || hostname(),
|
|
214
249
|
shell: v.shell || s.shell,
|
|
215
250
|
version: v.version, binDir: v["bin-dir"], noStart: v["no-start"],
|
|
216
251
|
});
|
|
217
252
|
}
|
|
218
253
|
|
|
219
|
-
async function cmdOnboard(v) {
|
|
220
|
-
const s = readSettings();
|
|
221
|
-
// 服务器地址不再交互询问(用户拍板 2026-07-04):--server > 已保存值 > 默认公共服务;
|
|
222
|
-
// 优先级与 cmdUp 一致(见 packages/cli/cofluxd.mjs 里 applyAndStart 的非默认提示)。
|
|
223
|
-
const serverUrl = v.server || s.serverUrl || DEFAULT_SERVER;
|
|
224
|
-
console.log("\n 欢迎使用 coflux —— 配置这台设备\n ──────────────────────────────\n");
|
|
225
|
-
console.log(` 服务器: ${serverUrl}\n`);
|
|
226
|
-
console.log(" 登记方式:留空走浏览器授权(推荐,起服务后打印链接,登录确认即可);\n 已有登记密钥可直接粘贴。\n");
|
|
227
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
228
|
-
try {
|
|
229
|
-
const enrollKey = (v["enroll-key"] ?? (await rl.question("登记密钥(可留空): "))).trim();
|
|
230
|
-
const deviceName = (await rl.question(`设备名 [${s.deviceName || hostname()}]: `)).trim() || s.deviceName || hostname();
|
|
231
|
-
rl.close();
|
|
232
|
-
console.log("");
|
|
233
|
-
await applyAndStart({ serverUrl, enrollKey, deviceName, shell: s.shell, version: v.version, binDir: v["bin-dir"], noStart: v["no-start"] });
|
|
234
|
-
} finally {
|
|
235
|
-
rl.close();
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function cmdReload() {
|
|
240
|
-
if (!fs.existsSync(SETTINGS)) die("无 settings.json,先 cofluxd up 或 cofluxd onboard");
|
|
241
|
-
restartService(); // daemon 重启时重新读 settings.json
|
|
242
|
-
console.log("✓ 已重启(daemon 重新读取 settings.json)");
|
|
243
|
-
}
|
|
244
|
-
|
|
245
254
|
function cmdDown() { stopService(); console.log("✓ 已停止"); }
|
|
246
255
|
|
|
256
|
+
// 只有 supervisor 真正靠这条命令更新——它不自动升级(持 PTY,重启会杀会话,见 plan 017)。
|
|
257
|
+
// worker 运行中会被 server 自动热升级到最新 stable(无需停服);这里顺带刷新的是 worker 的
|
|
258
|
+
// "内置兜底二进制"(supervisor 冷启动/热升级缓存皆无时的 fallback),日常可不管。
|
|
247
259
|
async function cmdUpdate(v) {
|
|
248
|
-
if (!fs.existsSync(SETTINGS)) die("尚未安装,先 cofluxd up
|
|
249
|
-
|
|
260
|
+
if (!fs.existsSync(SETTINGS)) die("尚未安装,先 cofluxd up");
|
|
261
|
+
const version = v.version || "latest";
|
|
262
|
+
await ensureBinaries({ version, binDir: v["bin-dir"] });
|
|
250
263
|
restartService();
|
|
251
|
-
console.log(`✓ 已更新到 ${v["bin-dir"] ? "本地产物" :
|
|
252
|
-
console.log("
|
|
264
|
+
console.log(`✓ supervisor 已更新到 ${v["bin-dir"] ? "本地产物" : version} 并重启`);
|
|
265
|
+
console.log(" worker 由 server 自动热升级,通常无需手动更新;此命令只更新 supervisor(及其兜底 worker 二进制)。");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// launchd(macOS) / systemd --user(Linux) 服务活跃态查询,status 与 doctor 共用。
|
|
269
|
+
function serviceRunningInfo() {
|
|
270
|
+
if (IS_MAC) {
|
|
271
|
+
const running = run("launchctl", ["list", "com.coflux.daemon"]).status === 0;
|
|
272
|
+
return { running, active: running ? "运行中" : "未运行" };
|
|
273
|
+
}
|
|
274
|
+
if (IS_LINUX) {
|
|
275
|
+
const active = (run("systemctl", ["--user", "is-active", "coflux-daemon.service"]).stdout || "").trim() || "未运行";
|
|
276
|
+
return { running: active === "active", active };
|
|
277
|
+
}
|
|
278
|
+
return { running: false, active: "未运行" };
|
|
253
279
|
}
|
|
254
280
|
|
|
255
281
|
function cmdStatus() {
|
|
@@ -267,19 +293,196 @@ function cmdStatus() {
|
|
|
267
293
|
} else {
|
|
268
294
|
console.log("凭证: 未登记");
|
|
269
295
|
}
|
|
270
|
-
|
|
271
|
-
if (IS_MAC) {
|
|
272
|
-
running = run("launchctl", ["list", "com.coflux.daemon"]).status === 0;
|
|
273
|
-
active = running ? "运行中" : "未运行";
|
|
274
|
-
} else if (IS_LINUX) {
|
|
275
|
-
active = (run("systemctl", ["--user", "is-active", "coflux-daemon.service"]).stdout || "").trim() || "未运行";
|
|
276
|
-
running = active === "active";
|
|
277
|
-
}
|
|
296
|
+
const { running, active } = serviceRunningInfo();
|
|
278
297
|
let pid = "";
|
|
279
298
|
if (running) {
|
|
280
299
|
try { pid = ` (worker pid ${fs.readFileSync(join(HOME, "worker.pid"), "utf8").trim()})`; } catch { /* */ }
|
|
281
300
|
}
|
|
282
301
|
console.log(`服务: ${active}${pid}`);
|
|
302
|
+
// conn-state.json 是 worker 生前写的快照:进程不在时是 stale 数据,忽略——不能让"进程活着"
|
|
303
|
+
// 误当成"在线",也不能让 stale 文件在进程死后继续显示假的连接态。
|
|
304
|
+
if (running) {
|
|
305
|
+
const conn = readConnState();
|
|
306
|
+
if (conn?.state && CONN_STATE_LABEL[conn.state]) {
|
|
307
|
+
const dur = Number.isFinite(conn.since) ? formatDuration(Date.now() - conn.since) : null;
|
|
308
|
+
console.log(`连接: ${CONN_STATE_LABEL[conn.state]}${dur ? `(已 ${dur})` : ""}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (IS_MAC) {
|
|
312
|
+
const fda = readFdaStatus();
|
|
313
|
+
const hint = fda === "granted" ? "" : "(`cofluxd fda` 引导授权,避免弹窗卡住 PTY 会话)";
|
|
314
|
+
console.log(`FDA: ${fdaLabel(fda)}${hint}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/* ------------------------------ doctor:分层连通性自检 ------------------------------ */
|
|
319
|
+
// 只做传输层探测(DNS/TCP/TLS/WS 升级),不解析 coflux 协议消息——CLI 保持零协议
|
|
320
|
+
// (见 plan 035 Decisions)。每层探测成功即断开,不占用 server 的已认证连接名额;
|
|
321
|
+
// WS 升级对 server 是一条未认证连接,server 侧 authDeadline 自然回收。
|
|
322
|
+
const DOCTOR_TIMEOUT_MS = 5000;
|
|
323
|
+
|
|
324
|
+
function parseServerUrl(serverUrl) {
|
|
325
|
+
const u = new URL(serverUrl);
|
|
326
|
+
const useTls = u.protocol === "wss:";
|
|
327
|
+
if (!useTls && u.protocol !== "ws:") throw new Error(`不支持的协议: ${u.protocol}(需 ws:// 或 wss://)`);
|
|
328
|
+
const port = u.port ? Number(u.port) : (useTls ? 443 : 80);
|
|
329
|
+
const path = u.pathname || "/";
|
|
330
|
+
return { host: u.hostname, port, path, useTls };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function timed(fn) {
|
|
334
|
+
const t0 = Date.now();
|
|
335
|
+
try { return { ok: true, ms: Date.now() - t0, ...(await fn()) }; }
|
|
336
|
+
catch (e) { return { ok: false, ms: Date.now() - t0, error: e?.message || String(e) }; }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function probeDns(host) {
|
|
340
|
+
return timed(async () => {
|
|
341
|
+
const addrs = await dns.lookup(host, { all: true });
|
|
342
|
+
return { detail: addrs.map((a) => a.address).join(", ") };
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function probeTcp(host, port) {
|
|
347
|
+
return new Promise((resolve) => {
|
|
348
|
+
const t0 = Date.now();
|
|
349
|
+
const socket = net.connect({ host, port, timeout: DOCTOR_TIMEOUT_MS });
|
|
350
|
+
const done = (ok, extra) => { socket.destroy(); resolve({ ok, ms: Date.now() - t0, ...extra }); };
|
|
351
|
+
socket.once("connect", () => done(true, {}));
|
|
352
|
+
socket.once("timeout", () => done(false, { error: `连接超时(>${DOCTOR_TIMEOUT_MS}ms)` }));
|
|
353
|
+
socket.once("error", (e) => done(false, { error: e.message }));
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function probeTls(host, port) {
|
|
358
|
+
return new Promise((resolve) => {
|
|
359
|
+
const t0 = Date.now();
|
|
360
|
+
const socket = tls.connect({ host, port, servername: host, timeout: DOCTOR_TIMEOUT_MS }, () => {
|
|
361
|
+
const detail = socket.authorized ? socket.getProtocol() : `${socket.getProtocol()}(证书未验证: ${socket.authorizationError})`;
|
|
362
|
+
socket.destroy();
|
|
363
|
+
resolve({ ok: true, ms: Date.now() - t0, detail });
|
|
364
|
+
});
|
|
365
|
+
socket.once("timeout", () => { socket.destroy(); resolve({ ok: false, ms: Date.now() - t0, error: `握手超时(>${DOCTOR_TIMEOUT_MS}ms)` }); });
|
|
366
|
+
socket.once("error", (e) => resolve({ ok: false, ms: Date.now() - t0, error: e.message }));
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 手写一条最小 HTTP/1.1 Upgrade 请求,只看是否拿到 101——不建立真实 WebSocket 帧连接。
|
|
371
|
+
function probeWsUpgrade({ host, port, path, useTls }) {
|
|
372
|
+
return new Promise((resolve) => {
|
|
373
|
+
const t0 = Date.now();
|
|
374
|
+
const key = crypto.randomBytes(16).toString("base64");
|
|
375
|
+
const req = `GET ${path} HTTP/1.1\r\nHost: ${host}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${key}\r\n\r\n`;
|
|
376
|
+
const finish = (r) => resolve({ ms: Date.now() - t0, ...r });
|
|
377
|
+
const onOpen = (socket) => {
|
|
378
|
+
let buf = "";
|
|
379
|
+
const timer = setTimeout(() => { socket.destroy(); finish({ ok: false, error: `升级响应超时(>${DOCTOR_TIMEOUT_MS}ms)` }); }, DOCTOR_TIMEOUT_MS);
|
|
380
|
+
socket.on("data", (chunk) => {
|
|
381
|
+
buf += chunk.toString("latin1");
|
|
382
|
+
if (!buf.includes("\r\n\r\n")) return;
|
|
383
|
+
clearTimeout(timer);
|
|
384
|
+
const statusLine = buf.split("\r\n")[0];
|
|
385
|
+
socket.destroy();
|
|
386
|
+
const ok = /^HTTP\/1\.\d 101\b/.test(statusLine);
|
|
387
|
+
finish(ok ? { ok, detail: statusLine } : { ok, error: `未收到 101 切换协议响应: ${statusLine}` });
|
|
388
|
+
});
|
|
389
|
+
socket.once("error", (e) => { clearTimeout(timer); finish({ ok: false, error: e.message }); });
|
|
390
|
+
socket.write(req);
|
|
391
|
+
};
|
|
392
|
+
const socket = useTls
|
|
393
|
+
? tls.connect({ host, port, servername: host, timeout: DOCTOR_TIMEOUT_MS })
|
|
394
|
+
: net.connect({ host, port, timeout: DOCTOR_TIMEOUT_MS });
|
|
395
|
+
socket.once(useTls ? "secureConnect" : "connect", () => onOpen(socket));
|
|
396
|
+
socket.once("timeout", () => { socket.destroy(); finish({ ok: false, error: `连接超时(>${DOCTOR_TIMEOUT_MS}ms)` }); });
|
|
397
|
+
socket.once("error", (e) => finish({ ok: false, error: e.message }));
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function printLayer(name, r) {
|
|
402
|
+
const mark = r.ok ? "✓" : "✗";
|
|
403
|
+
const msg = r.ok ? (r.detail ? `→ ${r.detail}` : "") : (r.error || "");
|
|
404
|
+
console.log(` ${mark} ${name} (${r.ms}ms)${msg ? ` ${msg}` : ""}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// 本地事实汇总:服务进程存活、conn-state.json 连接态、凭证有无、FDA。返回连接态三态:
|
|
408
|
+
// "connected" | "not-connected"(有快照但非 connected,如 connecting/reconnecting)
|
|
409
|
+
// | "unknown"(服务未运行,或无快照——daemon 版本较旧还没写、或刚启动)。
|
|
410
|
+
// 三态区分是因为"无快照"不等于"未连接":旧版 worker 不写 conn-state.json,把它当"未连接"
|
|
411
|
+
// 会把"连接态未知"误报成"认证/授权层有问题"(2026-07-23 实操验收发现)。
|
|
412
|
+
function printLocalFacts() {
|
|
413
|
+
console.log("\n 本地状态\n ────────");
|
|
414
|
+
console.log(` 凭证: ${fs.existsSync(CRED) ? "已登记" : "未登记"}`);
|
|
415
|
+
const { running, active } = serviceRunningInfo();
|
|
416
|
+
console.log(` 服务: ${active}`);
|
|
417
|
+
let connState = "unknown";
|
|
418
|
+
if (running) {
|
|
419
|
+
const conn = readConnState();
|
|
420
|
+
if (conn?.state && CONN_STATE_LABEL[conn.state]) {
|
|
421
|
+
console.log(` 连接: ${CONN_STATE_LABEL[conn.state]}`);
|
|
422
|
+
connState = conn.state === "connected" ? "connected" : "not-connected";
|
|
423
|
+
} else {
|
|
424
|
+
console.log(" 连接: (无快照)");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (IS_MAC) console.log(` FDA: ${fdaLabel(readFdaStatus())}`);
|
|
428
|
+
console.log("");
|
|
429
|
+
return connState;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function printConclusion(ok, msg) {
|
|
433
|
+
console.log(` ${ok ? "✓" : "✗"} ${msg}\n`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function cmdDoctor() {
|
|
437
|
+
const s = readSettings();
|
|
438
|
+
const serverUrl = s.serverUrl || DEFAULT_SERVER;
|
|
439
|
+
console.log(`\n 连通性自检 —— ${serverUrl}\n ────────────────────────────\n`);
|
|
440
|
+
let target;
|
|
441
|
+
try { target = parseServerUrl(serverUrl); }
|
|
442
|
+
catch (e) { die(`server_url 解析失败: ${serverUrl}(${e.message})`); }
|
|
443
|
+
const { host, port, useTls } = target;
|
|
444
|
+
|
|
445
|
+
const dnsR = await probeDns(host);
|
|
446
|
+
printLayer("DNS 解析", dnsR);
|
|
447
|
+
if (!dnsR.ok) {
|
|
448
|
+
printConclusion(false, "DNS 解析失败——检查网络连接/DNS 配置,或该域名是否可达。");
|
|
449
|
+
printLocalFacts();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const tcpR = await probeTcp(host, port);
|
|
454
|
+
printLayer(`TCP 连接 (${host}:${port})`, tcpR);
|
|
455
|
+
if (!tcpR.ok) {
|
|
456
|
+
printConclusion(false, "DNS 可解析但 TCP 连不上——防火墙/代理拦截,或目标端口未开放。");
|
|
457
|
+
printLocalFacts();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (useTls) {
|
|
462
|
+
const tlsR = await probeTls(host, port);
|
|
463
|
+
printLayer("TLS 握手", tlsR);
|
|
464
|
+
if (!tlsR.ok) {
|
|
465
|
+
printConclusion(false, "TCP 可连但 TLS 握手失败——可能是企业代理 MITM 证书、系统时间错误,或服务端证书问题。");
|
|
466
|
+
printLocalFacts();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const wsR = await probeWsUpgrade(target);
|
|
472
|
+
printLayer("WS 升级握手", wsR);
|
|
473
|
+
if (!wsR.ok) {
|
|
474
|
+
printConclusion(false, "网络层通但 WebSocket 升级被拒——可能是反代/负载均衡未正确转发 Upgrade 头,或路径不对。");
|
|
475
|
+
printLocalFacts();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const connState = printLocalFacts();
|
|
480
|
+
const conclusion = {
|
|
481
|
+
connected: "各层连通性正常。",
|
|
482
|
+
"not-connected": "各层连通性正常,但本地未显示已连接——问题大概率在认证/授权层而非网络层,查 `cofluxd logs`。",
|
|
483
|
+
unknown: "各层连通性正常;本地连接态未知(daemon 版本较旧或刚启动,尚无 conn-state 快照),如有异常查 `cofluxd logs`。",
|
|
484
|
+
}[connState];
|
|
485
|
+
printConclusion(true, conclusion);
|
|
283
486
|
}
|
|
284
487
|
|
|
285
488
|
function cmdLogs(v) {
|
|
@@ -291,6 +494,28 @@ function cmdLogs(v) {
|
|
|
291
494
|
}
|
|
292
495
|
}
|
|
293
496
|
|
|
497
|
+
// 完全磁盘访问权限(FDA)引导:macOS 不允许程序自动弹出 FDA 授权窗(Apple 刻意设计),
|
|
498
|
+
// 上限就是检测 + 跳转系统设置 + 引导手动添加。授权对象是 supervisor 二进制本身——worker/PTY/agent
|
|
499
|
+
// 都是它的子进程,TCC 按 launchd 服务的 responsible process 归属,一次授权覆盖全树。
|
|
500
|
+
async function cmdFda() {
|
|
501
|
+
if (!IS_MAC) die("此命令仅 macOS 可用(Linux 无 TCC/FDA 概念,无需授权)");
|
|
502
|
+
console.log("\n 完全磁盘访问权限(FDA)引导\n ──────────────────────────\n");
|
|
503
|
+
console.log(" macOS 不支持程序自动弹出 FDA 授权窗,需手动在「系统设置」里添加。");
|
|
504
|
+
console.log(" 请把下面这个二进制拖进「隐私与安全性 → 完全磁盘访问权限」列表并勾选:\n");
|
|
505
|
+
console.log(` ${SUP_BIN}\n`);
|
|
506
|
+
console.log(" 即将打开系统设置面板,并在 Finder 中定位该二进制……\n");
|
|
507
|
+
run("open", ["x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"]);
|
|
508
|
+
run("open", ["-R", SUP_BIN]);
|
|
509
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
510
|
+
try {
|
|
511
|
+
await rl.question(" 添加并勾选后按回车,重启服务使授权生效… ");
|
|
512
|
+
} finally {
|
|
513
|
+
rl.close();
|
|
514
|
+
}
|
|
515
|
+
restartService();
|
|
516
|
+
console.log("\n✓ 已重启服务(FDA 对已运行进程不生效,必须重启才能生效)。稍候用 `cofluxd status` 确认已授予。");
|
|
517
|
+
}
|
|
518
|
+
|
|
294
519
|
function cmdUninstall(v) {
|
|
295
520
|
stopService();
|
|
296
521
|
try { fs.rmSync(IS_MAC ? PLIST : UNIT); } catch { /* */ }
|
|
@@ -301,28 +526,34 @@ function cmdUninstall(v) {
|
|
|
301
526
|
|
|
302
527
|
const HELP = `cofluxd —— coflux daemon 管理
|
|
303
528
|
|
|
304
|
-
cofluxd
|
|
305
|
-
cofluxd
|
|
306
|
-
cofluxd
|
|
307
|
-
cofluxd
|
|
308
|
-
cofluxd update
|
|
309
|
-
cofluxd
|
|
529
|
+
cofluxd 首次=up(打印浏览器授权链接),已配置=status
|
|
530
|
+
cofluxd up [flags] 幂等:首次装+起,已装则按当前 settings.json 重装服务并重启
|
|
531
|
+
cofluxd status 服务器/登记(含"等待授权")/服务/连接状态
|
|
532
|
+
cofluxd doctor 分层连通性自检(DNS→TCP→TLS→WS 升级)+ 本地状态汇总
|
|
533
|
+
cofluxd update 更新本地 supervisor 二进制并重启(worker 由 server 自动热升级)
|
|
534
|
+
cofluxd fda [仅 macOS] 引导授予完全磁盘访问权限(避免 PTY 因 TCC 弹窗卡住)
|
|
310
535
|
cofluxd logs [-f] 看 daemon 日志
|
|
311
536
|
cofluxd down 停止
|
|
312
537
|
cofluxd uninstall [--purge] 卸载(--purge 连二进制/配置/凭证一并删)
|
|
313
538
|
|
|
314
|
-
up flags: --server <ws://.../daemon> --
|
|
315
|
-
通用: --version <vX|latest>(默认 latest) --bin-dir <dir>(用本地 cargo 产物) --no-start
|
|
316
|
-
配置都在 ~/.coflux/settings.json(serverUrl/
|
|
539
|
+
up flags: --server <ws://.../daemon> --name <名> --shell <路径>
|
|
540
|
+
通用: --version <vX|latest>(不传时 up 沿用已有二进制,update 默认 latest) --bin-dir <dir>(用本地 cargo 产物) --no-start
|
|
541
|
+
配置都在 ~/.coflux/settings.json(serverUrl/deviceName/shell),daemon 直接读;改后重跑 cofluxd up 生效。`;
|
|
542
|
+
|
|
543
|
+
// onboard/reload 已在命令面重梳中移除(用户拍板 2026-07-23,见 plans/035):onboard 的交互
|
|
544
|
+
// 问答只剩"问设备名",价值不足以撑一个命令;reload 与幂等化后的 up 语义重复。
|
|
545
|
+
const MIGRATED = {
|
|
546
|
+
onboard: "onboard 已并入 up,直接运行 `cofluxd up`",
|
|
547
|
+
reload: "reload 已并入 up(up 现幂等,会按 settings.json 重装服务并重启),直接运行 `cofluxd up`",
|
|
548
|
+
};
|
|
317
549
|
|
|
318
550
|
const { values, positionals } = parseArgs({
|
|
319
551
|
allowPositionals: true,
|
|
320
552
|
options: {
|
|
321
553
|
server: { type: "string" },
|
|
322
|
-
"enroll-key": { type: "string" },
|
|
323
554
|
name: { type: "string" },
|
|
324
555
|
shell: { type: "string" },
|
|
325
|
-
version: { type: "string"
|
|
556
|
+
version: { type: "string" },
|
|
326
557
|
"bin-dir": { type: "string" },
|
|
327
558
|
"no-start": { type: "boolean", default: false },
|
|
328
559
|
purge: { type: "boolean", default: false },
|
|
@@ -333,9 +564,9 @@ const { values, positionals } = parseArgs({
|
|
|
333
564
|
|
|
334
565
|
let cmd = positionals[0];
|
|
335
566
|
if (values.help || cmd === "help") { console.log(HELP); process.exit(0); }
|
|
336
|
-
if (!cmd) cmd = fs.existsSync(SETTINGS) ? "status" : "
|
|
567
|
+
if (!cmd) cmd = fs.existsSync(SETTINGS) ? "status" : "up"; // 首次裸跑 → 引导
|
|
337
568
|
|
|
338
|
-
const handlers = { up: cmdUp,
|
|
569
|
+
const handlers = { up: cmdUp, update: cmdUpdate, down: cmdDown, status: cmdStatus, doctor: cmdDoctor, fda: cmdFda, logs: cmdLogs, uninstall: cmdUninstall };
|
|
339
570
|
const h = handlers[cmd];
|
|
340
|
-
if (!h) die(`未知命令: ${cmd}\n\n${HELP}`);
|
|
571
|
+
if (!h) die(`未知命令: ${cmd}${MIGRATED[cmd] ? `\n${MIGRATED[cmd]}` : ""}\n\n${HELP}`);
|
|
341
572
|
await h(values);
|