@mrrisega/dsh-remote 0.6.14 → 0.6.16

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 (35) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +64 -13
  2. package/clients/dsh-remote/dsh-events.mjs +22 -2
  3. package/clients/dsh-remote/e2ee-client.mjs +97 -13
  4. package/clients/dsh-remote/e2ee-shim-script.js +31 -3
  5. package/clients/dsh-remote/mobile-adapter.mjs +190 -108
  6. package/clients/dsh-remote/test/dsh-events.test.mjs +89 -0
  7. package/clients/dsh-remote/test/e2ee-bridge.test.mjs +73 -3
  8. package/clients/dsh-remote/test/e2ee-client.test.mjs +42 -1
  9. package/clients/dsh-remote/test/e2ee-shim.test.mjs +10 -1
  10. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +45 -1
  11. package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +325 -0
  12. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +3 -1
  13. package/clients/dsh-remote/test/wechat-runtime.test.mjs +268 -1
  14. package/clients/dsh-remote/upstream-discovery.mjs +186 -30
  15. package/clients/dsh-remote/wechat-channel.mjs +86 -3
  16. package/clients/dsh-remote/wechat-runtime.mjs +253 -10
  17. package/dsh-setup.mjs +161 -7
  18. package/package.json +1 -1
  19. package/packages/dsh-remote-web/lib/client.js +31 -1
  20. package/packages/dsh-remote-web/lib/index.js +392 -49
  21. package/packages/dsh-remote-web/package.json +1 -1
  22. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +64 -13
  23. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +22 -2
  24. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +97 -13
  25. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +31 -3
  26. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +190 -108
  27. package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +186 -30
  28. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +86 -3
  29. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +253 -10
  30. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +161 -7
  31. package/packages/dsh-remote-web/test/doctor-cli.test.mjs +117 -0
  32. package/packages/dsh-remote-web/test/linux-bridge.test.mjs +346 -0
  33. package/packages/dsh-remote-web/test/picker-pin.test.mjs +80 -27
  34. package/packages/dsh-remote-web/test/self-manage.test.mjs +12 -0
  35. package/clients/dsh-remote/test/boot-tip.test.mjs +0 -158
@@ -170,6 +170,7 @@ function printHelp() {
170
170
  dsh-remote status 查看配置与服务状态
171
171
  dsh-remote plugin 手动安装 dsh web 远程访问插件(--uninstall 卸载)
172
172
  dsh-remote repair 修复插件挂载(dsh web 起不来时用;只碰 profile,不联网)
173
+ dsh-remote doctor 一条命令自检「为什么连不上」(只读;含上游/进程/微信/日志尾部)
173
174
  dsh-remote --help 显示本用法(等同 dsh-remote help)
174
175
 
175
176
  自建模式(可选):
@@ -202,7 +203,11 @@ const KNOWN_FLAGS = new Set([
202
203
  process.exit(1);
203
204
  }
204
205
  }
205
- try { ensureRuntimeCopy(); } catch (e) { console.warn(`⚠️ 运行时固化跳过: ${e.message}`); }
206
+ // `doctor` 承诺"只读":它连运行时脚本同步都不做 —— 否则"自检"会先往用户磁盘写 9 个文件,
207
+ // 与承诺不符(也会把"我什么都没改却多了文件"变成新的困惑)。
208
+ if (process.argv[2] !== "doctor") {
209
+ try { ensureRuntimeCopy(); } catch (e) { console.warn(`⚠️ 运行时固化跳过: ${e.message}`); }
210
+ }
206
211
 
207
212
  /** 自启动服务应指向的 dsh-setup.mjs:优先配置目录内的固化副本,否则当前执行文件。 */
208
213
  function runtimeSetupPath() {
@@ -764,12 +769,32 @@ function restartBridgeService() {
764
769
  return startBridgeDarwin(plistPath);
765
770
  }
766
771
  if (process.platform === "linux") {
767
- const r = sh(`systemctl --user restart dsh-bridge`);
768
- if (!r.ok) return { ok: false, status: "failed", detail: (r.stderr || r.stdout).trim() || "systemctl restart 失败" };
769
- const a = sh(`systemctl --user is-active dsh-bridge`);
770
- return a.ok && a.stdout.trim() === "active"
771
- ? { ok: true, status: "running", pid: null }
772
- : { ok: false, status: "failed", detail: (a.stdout || a.stderr).trim() };
772
+ // 【0.6.15】飞牛 fnOS / 群晖这类 NAS 上**没有 user systemd 会话**(插件跑在系统服务里,
773
+ // 没有 XDG_RUNTIME_DIR)→ `systemctl --user` 只会报 "Failed to connect to bus",
774
+ // 于是这一支原本必然失败、且没有任何兜底。现在:有会话就用 systemd(可自愈),
775
+ // 没有就退到脱离进程(spawnDetachedBridge,与 macOS 的兜底同一条路径)。
776
+ const hasUserSystemd = Boolean(process.env.XDG_RUNTIME_DIR) && sh("systemctl --user show-environment").ok;
777
+ if (hasUserSystemd) {
778
+ const r = sh(`systemctl --user restart dsh-bridge`);
779
+ if (r.ok) {
780
+ const a = sh(`systemctl --user is-active dsh-bridge`);
781
+ if (a.ok && a.stdout.trim() === "active") return { ok: true, status: "running", pid: null };
782
+ return { ok: false, status: "failed", detail: (a.stdout || a.stderr).trim() };
783
+ }
784
+ // 有会话却重启失败:继续往下走脱离进程兜底,绝不留一个"重启失败但没有任何进程"的空档
785
+ }
786
+ const d = spawnDetachedBridge();
787
+ if (d.ok) {
788
+ return {
789
+ ok: true,
790
+ status: "degraded-detached",
791
+ pid: d.pid,
792
+ detail: hasUserSystemd
793
+ ? "systemd --user 重启失败,已改为后台进程运行(现在可用;systemd 恢复后下一次重启会回到托管)"
794
+ : "本机没有 user systemd 会话(NAS/容器常见),已改为后台进程运行(现在可用,但不会开机自启)",
795
+ };
796
+ }
797
+ return { ok: false, status: "failed", detail: d.error || "systemd 与后台进程均启动失败" };
773
798
  }
774
799
  return { ok: false, status: "unsupported", detail: `平台 ${process.platform} 不支持自启动` };
775
800
  }
@@ -2332,6 +2357,134 @@ async function pluginCmd(argv) {
2332
2357
  * resolve = dsh 的裸包名解析能不能走通(浏览器半注入面板就靠它)
2333
2358
  * client = dsh.client.platform 声明在不在
2334
2359
  */
2360
+ /**
2361
+ * `dsh-remote doctor` —— **一条命令回答「为什么连不上」**(只读,不改任何东西)。
2362
+ *
2363
+ * 【为什么产品上必须有它】2026-09-25 排查两条用户反馈(fb_7b4ee6ec9862 / fb_4cc2c9df749c)时,
2364
+ * 判断"bridge 到底有没有被拉起来"花了大量时间,而且**只能靠翻日志行数估时间** ——
2365
+ * 因为当时的日志连时间戳都没有,用户端也没有任何一个入口能把
2366
+ * 「上游端口 / 守护状态 / 事件订阅 / 待补发队列」一次性说清楚。
2367
+ * 那两个用户都是 linux + 「starting / bridge 未运行」,而我们的自愈当时在 Linux 上**根本没有实现**
2368
+ * (见 0.6.15 的 startBridgeLinux):如果有一份这样的自检,用户自己 10 秒就能看出问题在哪。
2369
+ *
2370
+ * 输出四段:① 身份与版本 ② 上游(含来源与探测结论) ③ 进程与自启动 ④ 通道(微信/加密/待补发)
2371
+ * ⑤ 日志尾部(带时间戳)。任何一项失败都只影响那一行,绝不中断整份自检。
2372
+ *
2373
+ * @param {string[]} argv 额外参数(--profile <dir> 透传给插件挂载诊断)
2374
+ */
2375
+ async function doctorCmd(argv = []) {
2376
+ const mask = (s) => (s ? String(s).slice(0, 3) + "****" + String(s).slice(-2) : "");
2377
+ const ms = (t) => (t ? new Date(Number(t)).toISOString().replace("T", " ").slice(0, 19) : "-");
2378
+ const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; } };
2379
+ const readText = (p) => { try { return fs.readFileSync(p, "utf8"); } catch { return ""; } };
2380
+ const alive = (pid) => {
2381
+ if (!Number.isInteger(pid) || pid <= 0) return false;
2382
+ try { process.kill(pid, 0); return true; } catch (e) { return e && e.code === "EPERM"; }
2383
+ };
2384
+
2385
+ console.log("dsh-remote 自检(doctor,只读,不改任何东西)");
2386
+ console.log("时间:", new Date().toISOString(), "| 平台:", `${process.platform}/${process.arch}`, "| node:", process.version);
2387
+ console.log("配置目录:", CONFIG_DIR);
2388
+
2389
+ // ── ① 插件与运行环境版本 ────────────────────────────────────────────────
2390
+ const pluginPkg = readJson(path.join(CONFIG_DIR, "node_modules", PLUGIN_ID, "package.json"))
2391
+ || readJson(new URL("./packages/dsh-remote-web/package.json", import.meta.url));
2392
+ const setupVersion = readText(path.join(CONFIG_DIR, ".dsh-setup-version")).trim();
2393
+ console.log("\n① 版本");
2394
+ console.log(` 插件 : ${pluginPkg && pluginPkg.version ? "v" + pluginPkg.version : "(读不到)"}`);
2395
+ console.log(` 运行环境 : ${setupVersion ? "v" + setupVersion : "(未固化 / 未知)"}`);
2396
+ console.log(` 运行环境入口 : ${fs.existsSync(runtimeSetupPath()) ? runtimeSetupPath() : "缺失!"}`);
2397
+ try {
2398
+ const profileDir = resolveProfileDir(argv);
2399
+ if (fs.existsSync(path.join(profileDir, "package.json"))) printPluginDiagnostics(profileDir);
2400
+ else console.log(` [plugin] 未找到 profile(${profileDir})`);
2401
+ } catch (e) { console.log(` [plugin] 诊断失败:${e.message}`); }
2402
+
2403
+ // ── ② 上游 dsh web(bridge 要连的那个本机地址) ──────────────────────────
2404
+ console.log("\n② 上游 dsh web");
2405
+ const hint = resolveUpstreamHint({ relayDir: CONFIG_DIR });
2406
+ const probed = await discoverUpstream({ relayDir: CONFIG_DIR, log: (m) => console.log(` ${m}`) });
2407
+ console.log(` 提示来源 : ${hint.url ? `${hint.url}(${hint.source})` : "(无)"}`);
2408
+ console.log(` 最终采用 : ${probed.url}(${probed.source})`);
2409
+ console.log(` 身份校验 : ${probed.source === "fallback" ? "⚠️ 未找到在听的 dsh web(会一直重试)" : "✅ 已确认为本机 dsh web"}`);
2410
+ if (probed.probed && probed.probed.length) console.log(` 探测过的端口 : ${probed.probed.join(", ")}`);
2411
+
2412
+ // ── ③ 守护与 bridge 进程 ───────────────────────────────────────────────
2413
+ console.log("\n③ 进程与自启动");
2414
+ const watcherPid = readPidFile(WATCHER_PID_FILE);
2415
+ const bridgePid = readPidFile(BRIDGE_PID_FILE);
2416
+ console.log(` watcher pid : ${watcherPid || "-"}${watcherPid ? (alive(watcherPid) ? "(在跑)" : "(已死,pid 文件过期)") : ""}`);
2417
+ console.log(` bridge pid : ${bridgePid || "-"}${bridgePid ? (alive(bridgePid) ? "(在跑)" : "(已死,pid 文件过期)") : ""}`);
2418
+ // 进程扫描(平台各自的方式):只报数量,不猜
2419
+ try {
2420
+ const r = process.platform === "win32"
2421
+ ? sh("tasklist /FI \"IMAGENAME eq node.exe\" /FO CSV 2>NUL")
2422
+ : sh("pgrep -fl 'dsh-setup.mjs|dsh-bridge.mjs' 2>/dev/null");
2423
+ const lines = String(r.stdout || "").split("\n").map((l) => l.trim()).filter((l) => l && !/pgrep/.test(l));
2424
+ const watchers = lines.filter((l) => /dsh-setup\.mjs/.test(l));
2425
+ const bridges = lines.filter((l) => /dsh-bridge\.mjs/.test(l)).filter((l) => !/dsh-setup/.test(l));
2426
+ console.log(` 实测进程 : watcher ${watchers.length} 个 / bridge ${bridges.length} 个`);
2427
+ if (watchers.length && !bridges.length) console.log(" ⚠️ 守护在跑、bridge 没起来 —— 这类情况看日志尾部(通常是在等 dsh web 或登录限流)");
2428
+ if (!watchers.length && !bridges.length) {
2429
+ console.log(" ⚠️ 一个进程都没在跑。手动起一次看报错:`dsh-remote run`(前台,能直接看到输出)");
2430
+ }
2431
+ } catch (e) { console.log(` 实测进程 : 扫描失败(${e.message})`); }
2432
+ if (process.platform === "linux") {
2433
+ const hasUser = Boolean(process.env.XDG_RUNTIME_DIR);
2434
+ console.log(` 自启动 : systemd --user ${hasUser ? "(有 runtime dir)" : "(无 XDG_RUNTIME_DIR → 本机没有 user systemd,插件会用后台进程兜底)"}`);
2435
+ if (hasUser) {
2436
+ const r = sh("systemctl --user is-active dsh-bridge");
2437
+ console.log(` systemd 状态 : ${r.ok ? r.stdout.trim() : (r.stderr || "").trim() || "查不到"}`);
2438
+ }
2439
+ }
2440
+
2441
+ // ── ④ 通道:E2EE / 微信 / 待补发 ───────────────────────────────────────
2442
+ console.log("\n④ 通道");
2443
+ const e2ee = readJson(path.join(CONFIG_DIR, ".e2ee-state.json"));
2444
+ console.log(` 端到端加密 : ${e2ee ? (e2ee.enabled ? `已启用(${e2ee.reason || "ok"})` : `未启用(${e2ee.reason || "?"})`) : "(无状态文件 = 明文)"}`);
2445
+ const wx = readJson(path.join(CONFIG_DIR, ".wechat-state.json"));
2446
+ if (!wx) {
2447
+ console.log(" 微信通道 : 未使用(无状态文件)");
2448
+ } else {
2449
+ const outbox = Array.isArray(wx.pending_outbox) ? wx.pending_outbox.length : 0;
2450
+ const faults = wx.last_fault && wx.last_fault.code ? `${wx.last_fault.code}(${ms(wx.last_fault.at)})` : "无";
2451
+ console.log(` 微信通道 : ${wx.bound ? "已绑定" : "未绑定"} | 最近成功推送 ${ms(wx.last_push_ok_at)}`);
2452
+ console.log(` 待补发通知 : ${outbox ? `⚠️ ${outbox} 条(下次在微信里发一句话就会补上)` : "0 条"}`);
2453
+ console.log(` 最近订阅异常 : ${faults}`);
2454
+ console.log(` 当前会话 : ${wx.current_session_title || "-"}(${wx.current_session_id || "-"})`);
2455
+ console.log(` 最近完成 : ${wx.last_completed_session_title || "-"}(${ms(wx.last_completed_at)})`);
2456
+ if (outbox) console.log(` ℹ️ 入站消息的形状(字段名,用于定位 context_token 缺失):${wx.inbound_shape || "(还没记到)"}`);
2457
+ }
2458
+ const cfg = loadConfig();
2459
+ console.log(` 账号 : ${mask(cfg.phone) || (cfg.local_key ? "自建(本地密钥)" : "(未登录)")}`);
2460
+ console.log(` 中继/API : ${cfg.tunnel_url || "-"} ${cfg.api_url ? `| ${cfg.api_url}` : ""}`);
2461
+
2462
+ // ── ⑤ 日志尾部(现在每行都有时间戳,直接能对时间) ─────────────────────
2463
+ console.log("\n⑤ 日志尾部(bridge + 安装)");
2464
+ for (const name of [".dsh-bridge.log", ".dsh-setup-install.log"]) {
2465
+ const p = path.join(CONFIG_DIR, name);
2466
+ if (!fs.existsSync(p)) { console.log(` ${name}: (无)`); continue; }
2467
+ let tail = "";
2468
+ try {
2469
+ const st = fs.statSync(p);
2470
+ const len = Math.min(st.size, 8 * 1024);
2471
+ const fd = fs.openSync(p, "r");
2472
+ const buf = Buffer.alloc(len);
2473
+ fs.readSync(fd, buf, 0, len, Math.max(0, st.size - len));
2474
+ fs.closeSync(fd);
2475
+ tail = buf.toString("utf8");
2476
+ } catch { /* 读不了就当没有 */ }
2477
+ const lines = tail.split("\n").map((l) => l.trim()).filter(Boolean).slice(-12);
2478
+ console.log(` --- ${name}(末尾 ${lines.length} 行 / ${p})---`);
2479
+ for (const l of lines) console.log(" " + l.slice(0, 220));
2480
+ console.log("");
2481
+ }
2482
+ console.log("结论怎么读:① 版本对不上 → 面板点「一键修复 / 重装运行环境」;");
2483
+ console.log(" ② 上游 fallback → 把 dsh web 起在本机可回环访问的端口(插件会自动发现);");
2484
+ console.log(" ③ 没有任何进程 → 先手动 `dsh-remote run` 看前台输出;");
2485
+ console.log(" ④ 待补发 > 0 → 在微信里随便发一句话即可补收。");
2486
+ }
2487
+
2335
2488
  function printPluginDiagnostics(profileDir) {
2336
2489
  const nmPlugin = path.join(profileDir, "node_modules", PLUGIN_ID);
2337
2490
  const link = describePluginLink(profileDir);
@@ -2417,6 +2570,7 @@ else if (cmd === "settings") settingsHint();
2417
2570
  else if (cmd === "run") await runBridge();
2418
2571
  else if (cmd === "plugin") await pluginCmd(process.argv.slice(3));
2419
2572
  else if (cmd === "repair") repairProfile(process.argv.slice(3));
2573
+ else if (cmd === "doctor") await doctorCmd(process.argv.slice(3));
2420
2574
  else if (cmd === "status") {
2421
2575
  const cfg = loadConfig();
2422
2576
  const local = Boolean(cfg.local_key);
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `dsh-remote doctor` —— 一条命令自检「为什么连不上」的契约测试。
4
+ *
5
+ * 【为什么要这个功能】2026-09-25 两条用户反馈(fb_7b4ee6ec9862 / fb_4cc2c9df749c)都是
6
+ * linux + 「连接阶段: starting / bridge 未运行」,而排查时**没有任何一个入口**能一次性看清
7
+ * 「上游端口怎么来的 / 守护在不在 / 事件订阅有没有断 / 有没有通知发不出去」——
8
+ * 只能靠翻日志(而当时日志连时间戳都没有,连"这条错误发生在用户回话之前还是之后"都判断不了)。
9
+ *
10
+ * 本用例锁三件事:
11
+ * ① 关键结论必须出现在输出里(上游来源与探测结论、进程、待补发、订阅异常、日志尾部);
12
+ * ② 日志尾部必须**带时间戳**(否则等于没有);
13
+ * ③ `doctor` 必须**真的只读**(连运行时脚本同步都不做,目录不留任何新文件)。
14
+ */
15
+ import assert from "node:assert/strict";
16
+ import { spawnSync } from "node:child_process";
17
+ import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import test from "node:test";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
24
+ const SETUP = path.join(HERE, "..", "..", "..", "dsh-setup.mjs");
25
+
26
+ /** 造一个"有故事"的配置目录:微信有待补发 + 有订阅异常 + 日志带时间戳。 */
27
+ function makeRelayDir() {
28
+ const root = mkdtempSync(path.join(os.tmpdir(), "dsh-doctor-"));
29
+ const relayDir = path.join(root, ".dsh-remote");
30
+ const home = path.join(root, "home");
31
+ mkdirSync(relayDir, { recursive: true });
32
+ mkdirSync(home, { recursive: true });
33
+ writeFileSync(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
34
+ api_url: "http://127.0.0.1:1", phone: "13800000000", password: "x", device_id: "dev-test",
35
+ }));
36
+ writeFileSync(path.join(relayDir, ".e2ee-state.json"), JSON.stringify({ enabled: true, reason: "ok" }));
37
+ writeFileSync(path.join(relayDir, ".wechat-state.json"), JSON.stringify({
38
+ bound: true,
39
+ last_push_ok_at: 1790331177460,
40
+ pending_outbox: [{ text: "跑完了", kind: "stopped", at: 1790331000000 }],
41
+ last_fault: { code: "follow-limit", message: "follow 流已达上限 8", at: 1790331100000 },
42
+ current_session_id: "session-abc", current_session_title: "当前任务",
43
+ last_completed_session_id: "session-done", last_completed_session_title: "刚跑完的活",
44
+ last_completed_at: 1790331165248,
45
+ inbound_shape: "msg_id,from_user_id,item_list",
46
+ }));
47
+ writeFileSync(path.join(relayDir, ".dsh-bridge.log"),
48
+ "[2026-09-25T09:29:58.147Z] [bridge] 隧道已连\n[2026-09-25T09:30:01.000Z] [wechat] sendmessage 失败:ret=-2\n");
49
+ writeFileSync(path.join(relayDir, ".dsh-setup-version"), "0.6.15\n");
50
+ return { root, relayDir, home, cleanup: () => rmSync(root, { recursive: true, force: true }) };
51
+ }
52
+
53
+ function runDoctor(env) {
54
+ const r = spawnSync(process.execPath, [SETUP, "doctor"], {
55
+ encoding: "utf8",
56
+ timeout: 60000,
57
+ env: {
58
+ ...process.env,
59
+ HOME: env.home,
60
+ DSH_RELAY_DIR: env.relayDir,
61
+ DSH_REMOTE_TELEMETRY: "0",
62
+ DSH_RELAY_DEFAULT_API: "http://127.0.0.1:1",
63
+ // 隔离:不碰真实 launchd / systemd
64
+ DSH_RELAY_SKIP_SERVICE: "1",
65
+ },
66
+ });
67
+ return String(r.stdout || "") + String(r.stderr || "");
68
+ }
69
+
70
+ test("★ doctor:上游来源/进程/待补发/订阅异常/日志尾部 必须一条命令全看到", () => {
71
+ const env = makeRelayDir();
72
+ try {
73
+ const out = runDoctor(env);
74
+ // ① 身份
75
+ assert.match(out, /dsh-remote 自检/, "要有明确的标题(用户才知道这是自检报告)");
76
+ assert.match(out, /① 版本/, "版本段缺失");
77
+ // ② 上游:来源与探测结论都要有("3080 是默认值还是实测"是历史上最容易搞错的一点)
78
+ assert.match(out, /② 上游 dsh web/, "上游段缺失");
79
+ assert.match(out, /最终采用\s*: http:\/\/127\.0\.0\.1:/, "必须给出最终采用的上游地址");
80
+ assert.match(out, /身份校验\s*:/, "必须给出身份校验结论(否则用户不知道那个端口是不是 dsh web)");
81
+ // ③ 进程
82
+ assert.match(out, /③ 进程与自启动/, "进程段缺失");
83
+ assert.match(out, /watcher pid\s*:/, "要看得到守护 pid");
84
+ assert.match(out, /实测进程\s*:/, "要看得到实测进程数(pid 文件会过期)");
85
+ // ④ 通道:待补发与订阅异常是"没收到推送"的两个可判定信号
86
+ assert.match(out, /④ 通道/, "通道段缺失");
87
+ assert.match(out, /待补发通知\s*: ⚠️ 1 条/, "待补发条数必须显示(通知发不出去时唯一的可见信号)");
88
+ assert.match(out, /最近订阅异常\s*: follow-limit/, "订阅异常必须显示(旧实现这类故障完全静默)");
89
+ assert.match(out, /端到端加密\s*: 已启用/, "加密状态要显示");
90
+ assert.match(out, /最近完成\s*: 刚跑完的活/, "最近完成的任务要显示(回话目标就认它)");
91
+ assert.match(out, /msg_id,from_user_id,item_list/, "入站字段形状要显示(用于定位 context_token 缺失)");
92
+ // 手机号必须脱敏
93
+ assert.ok(!out.includes("13800000000"), "诊断报告会外发,手机号必须脱敏");
94
+ // ⑤ 日志尾部要带时间戳(没有时间戳 = 无法对时间,正是那次排查最贵的一课)
95
+ assert.match(out, /⑤ 日志尾部/, "日志段缺失");
96
+ assert.match(out, /\[2026-09-25T09:29:58\.147Z\]/, "日志行必须带时间戳");
97
+ assert.match(out, /sendmessage 失败/, "日志尾部要真的把内容带出来");
98
+ } finally { env.cleanup(); }
99
+ });
100
+
101
+ test("★ doctor 必须**真的只读**:不新建任何文件(否则「自检」本身就是一次写盘)", () => {
102
+ const env = makeRelayDir();
103
+ try {
104
+ const before = readdirSync(env.relayDir).sort();
105
+ runDoctor(env);
106
+ const after = readdirSync(env.relayDir).sort();
107
+ assert.deepEqual(after, before, "doctor 不得新增或删除配置目录里的任何文件");
108
+ assert.ok(!readdirSync(env.home).includes(".dsh-remote"), "也不得在 HOME 下凭空建目录");
109
+ } finally { env.cleanup(); }
110
+ });
111
+
112
+ test("用法里必须有 doctor(否则用户不知道有这个入口)", () => {
113
+ const r = spawnSync(process.execPath, [SETUP, "--help"], { encoding: "utf8", timeout: 30000 });
114
+ const out = String(r.stdout || "") + String(r.stderr || "");
115
+ assert.match(out, /dsh-remote doctor/, "用法里要列出 doctor");
116
+ assert.match(out, /为什么连不上/, "要一句话说清它是干什么的");
117
+ });