@mrrisega/dsh-remote 0.6.13 → 0.6.15

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 (38) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +124 -14
  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 +283 -1
  6. package/clients/dsh-remote/test/boot-tip.test.mjs +158 -0
  7. package/clients/dsh-remote/test/dsh-events.test.mjs +89 -0
  8. package/clients/dsh-remote/test/e2ee-bridge.test.mjs +73 -3
  9. package/clients/dsh-remote/test/e2ee-client.test.mjs +42 -1
  10. package/clients/dsh-remote/test/e2ee-shim.test.mjs +10 -1
  11. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +18 -1
  12. package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +325 -0
  13. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +3 -1
  14. package/clients/dsh-remote/test/upstream-discovery.test.mjs +196 -0
  15. package/clients/dsh-remote/test/wechat-runtime.test.mjs +268 -1
  16. package/clients/dsh-remote/upstream-discovery.mjs +448 -0
  17. package/clients/dsh-remote/wechat-channel.mjs +86 -3
  18. package/clients/dsh-remote/wechat-runtime.mjs +253 -10
  19. package/dsh-setup.mjs +225 -23
  20. package/package.json +1 -1
  21. package/packages/dsh-remote-web/lib/client.js +31 -1
  22. package/packages/dsh-remote-web/lib/index.js +464 -15
  23. package/packages/dsh-remote-web/package.json +1 -1
  24. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +124 -14
  25. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +22 -2
  26. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +97 -13
  27. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +31 -3
  28. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +283 -1
  29. package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +448 -0
  30. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +86 -3
  31. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +253 -10
  32. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +225 -23
  33. package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +16 -1
  34. package/packages/dsh-remote-web/test/doctor-cli.test.mjs +117 -0
  35. package/packages/dsh-remote-web/test/linux-bridge.test.mjs +346 -0
  36. package/packages/dsh-remote-web/test/picker-pin.test.mjs +201 -0
  37. package/packages/dsh-remote-web/test/self-manage.test.mjs +12 -0
  38. package/packages/dsh-remote-web/test/watcher-upstream-port.test.mjs +170 -0
@@ -29,6 +29,7 @@ import path from "node:path";
29
29
  import { spawn, spawnSync, execSync } from "node:child_process";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import { childStopped, stopChildGracefully } from "./clients/dsh-remote/src/lifecycle.mjs";
32
+ import { discoverUpstream, resolveUpstreamHint, FALLBACK_UPSTREAM } from "./clients/dsh-remote/upstream-discovery.mjs";
32
33
 
33
34
  const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); // 本包目录(仓库或 node_modules)
34
35
  const IS_NPM_INSTALL = THIS_DIR.includes(`${path.sep}node_modules${path.sep}`);
@@ -169,6 +170,7 @@ function printHelp() {
169
170
  dsh-remote status 查看配置与服务状态
170
171
  dsh-remote plugin 手动安装 dsh web 远程访问插件(--uninstall 卸载)
171
172
  dsh-remote repair 修复插件挂载(dsh web 起不来时用;只碰 profile,不联网)
173
+ dsh-remote doctor 一条命令自检「为什么连不上」(只读;含上游/进程/微信/日志尾部)
172
174
  dsh-remote --help 显示本用法(等同 dsh-remote help)
173
175
 
174
176
  自建模式(可选):
@@ -201,7 +203,11 @@ const KNOWN_FLAGS = new Set([
201
203
  process.exit(1);
202
204
  }
203
205
  }
204
- 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
+ }
205
211
 
206
212
  /** 自启动服务应指向的 dsh-setup.mjs:优先配置目录内的固化副本,否则当前执行文件。 */
207
213
  function runtimeSetupPath() {
@@ -763,12 +769,32 @@ function restartBridgeService() {
763
769
  return startBridgeDarwin(plistPath);
764
770
  }
765
771
  if (process.platform === "linux") {
766
- const r = sh(`systemctl --user restart dsh-bridge`);
767
- if (!r.ok) return { ok: false, status: "failed", detail: (r.stderr || r.stdout).trim() || "systemctl restart 失败" };
768
- const a = sh(`systemctl --user is-active dsh-bridge`);
769
- return a.ok && a.stdout.trim() === "active"
770
- ? { ok: true, status: "running", pid: null }
771
- : { 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 与后台进程均启动失败" };
772
798
  }
773
799
  return { ok: false, status: "unsupported", detail: `平台 ${process.platform} 不支持自启动` };
774
800
  }
@@ -779,14 +805,21 @@ function restartBridgeService() {
779
805
  * 【2026-09-23 用户实测事故】这里原本处处写死 `http://127.0.0.1:3080`:只要用户的 dsh web
780
806
  * 不在默认端口(`dsh web --port 8090`、profile 里配了 port、或 `--port 0` 让系统分配空闲端口),
781
807
  * watcher 的在线探测就**永远**失败 → bridge 永远不启动,而面板只会说「正在启动 Bridge…」。
782
- * 优先级:显式环境变量 > 插件半留下的端口文件 > DSH_WEB_URL > 默认 3080。
783
- * 为什么要有「端口文件」这一层:开机自启(登录任务 / launchd)拉起 watcher 时**没有**任何环境变量,
784
- * 只有插件半知道 dsh web 真实监听在哪个端口,所以它会把地址落盘(见插件 half 的 publishUpstream)。
808
+ * 优先级:显式环境变量 > **运行时动态发现** > 插件半留下的端口文件 > DSH_WEB_URL > 默认 3080。
809
+ *
810
+ * 为什么还要「动态发现」这一层(2026-09-23 第二起实测,DSH Desktop):
811
+ * DSH Desktop(Electron 壳 `dsh-plugin-desktop`)默认把 Web 服务放在 **43120**,被占用还会 +1;
812
+ * 而端口文件要等**插件半**加载后才有。于是「端口文件还没写」或「写的是上一轮的旧端口」时,
813
+ * 光靠文件仍会永久探错端口。发现逻辑(含 DSH 进程实际监听端口 + Desktop 端口区间 + 身份校验)
814
+ * 统一在 clients/dsh-remote/upstream-discovery.mjs,watcher 与 bridge 共用同一份实现。
785
815
  */
786
816
  const UPSTREAM_FILE = ".dsh-upstream";
817
+ /** 运行时动态发现到的上游(优于可能过期的端口文件;进程内缓存)。 */
818
+ let discoveredUpstream = "";
787
819
  function upstreamUrl() {
788
820
  const explicit = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim();
789
821
  if (explicit) return explicit.replace(/\/+$/, "");
822
+ if (discoveredUpstream) return discoveredUpstream;
790
823
  try {
791
824
  const fromFile = String(fs.readFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), "utf8")).trim();
792
825
  if (/^https?:\/\/[^\s]+$/i.test(fromFile)) return fromFile.replace(/\/+$/, "");
@@ -798,28 +831,69 @@ function upstreamUrl() {
798
831
  if (u.port) return `http://127.0.0.1:${u.port}`; // bridge 的 loopback 围栏只认 127.0.0.1
799
832
  } catch { /* 非 URL:忽略 */ }
800
833
  }
801
- return "http://127.0.0.1:3080";
834
+ return FALLBACK_UPSTREAM;
802
835
  }
803
836
  /** 上游端口(诊断/日志用;取不到就回退 3080)。 */
804
837
  function upstreamPort() {
805
838
  try { return Number(new URL(upstreamUrl()).port) || 3080; } catch { return 3080; }
806
839
  }
807
840
 
841
+ /** 发现结果缓存与冷却:避免每轮轮询都去 lsof/pgrep + 逐个端口探测。 */
842
+ let upstreamDiscoveryAt = 0;
843
+ let upstreamDiscoveryInflight = null;
844
+ const UPSTREAM_DISCOVERY_COOLDOWN_MS = 15_000;
845
+
808
846
  /**
809
- * dsh web 是否正在运行(默认 127.0.0.1:3080,端口以 upstreamUrl() 为准)。
810
- * bridge 本身依赖 dsh web 才工作:dsh web 没开时 bridge 起来也会立刻退出,
811
- * 所以「装完当下 bridge 没在跑」是**正常状态**,不能当失败吓用户(见 install 汇总)。
847
+ * 动态发现上游(带身份校验),成功后写入 `discoveredUpstream` 供全局复用。
848
+ * @param {boolean} force 忽略冷却(探测失败后的重试用)
812
849
  */
813
- async function isDshWebUp(timeoutMs = 1200) {
850
+ async function refreshUpstreamDiscovery(force = false) {
851
+ if (upstreamDiscoveryInflight) return upstreamDiscoveryInflight;
852
+ const now = Date.now();
853
+ if (!force && now - upstreamDiscoveryAt < UPSTREAM_DISCOVERY_COOLDOWN_MS) return upstreamUrl();
854
+ upstreamDiscoveryAt = now;
855
+ upstreamDiscoveryInflight = (async () => {
856
+ try {
857
+ const found = await discoverUpstream({ relayDir: CONFIG_DIR, log: (m) => console.log(`[dsh-remote] ${m}`) });
858
+ // 「提示地址验证通过」与「候选命中」都值得记下来;fallback(什么都没找到)不污染现有判断
859
+ if (found.url && found.source !== "fallback") discoveredUpstream = found.url;
860
+ return upstreamUrl();
861
+ } catch {
862
+ return upstreamUrl();
863
+ } finally {
864
+ upstreamDiscoveryInflight = null;
865
+ }
866
+ })();
867
+ return upstreamDiscoveryInflight;
868
+ }
869
+
870
+ /** 上游是否**有响应**(判据刻意宽松:4xx/5xx 也算"端口在听",那种情况问题在别处)。 */
871
+ async function upstreamReachable(url = upstreamUrl(), timeoutMs = 1200) {
814
872
  try {
815
873
  const ac = new AbortController();
816
874
  const timer = setTimeout(() => ac.abort(), timeoutMs);
817
- await fetch(upstreamUrl() + "/", { signal: ac.signal });
875
+ await fetch(url + "/", { signal: ac.signal });
818
876
  clearTimeout(timer);
819
877
  return true;
820
878
  } catch { return false; }
821
879
  }
822
880
 
881
+ /**
882
+ * dsh web 是否正在运行(端口以 upstreamUrl() 为准,**绝不写死 3080**)。
883
+ * bridge 本身依赖 dsh web 才工作:dsh web 没开时 bridge 起来也会立刻退出,
884
+ * 所以「装完当下 bridge 没在跑」是**正常状态**,不能当失败吓用户(见 install 汇总)。
885
+ *
886
+ * 探测失败时会**主动做一次动态发现**再试一次 —— 这一条正是 DSH Desktop 场景的关键:
887
+ * 上游在 43120(Desktop 默认,占用还会 +1),而端口文件可能还没写或写着上一轮的旧端口。
888
+ */
889
+ async function isDshWebUp(timeoutMs = 1200) {
890
+ if (await upstreamReachable(upstreamUrl(), timeoutMs)) return true;
891
+ const before = upstreamUrl();
892
+ const after = await refreshUpstreamDiscovery(true);
893
+ if (after === before) return false;
894
+ return upstreamReachable(after, timeoutMs);
895
+ }
896
+
823
897
  /**
824
898
  * 解析 dsh web profile 目录(安装与插件管理共用同一口径)。
825
899
  */
@@ -1125,14 +1199,13 @@ async function runBridge() {
1125
1199
  /** 限流提示只打一次,避免刷屏。 */
1126
1200
  let warnedRateLimit = false;
1127
1201
 
1128
- // watcher:检测 dsh web 是否存活(端口以 upstreamUrl() 为准,绝不写死 3080),存活才启动 bridge
1129
- const checkUpstream = () => new Promise((resolve) => {
1130
- const t = setTimeout(() => resolve(false), 2000);
1131
- fetch(upstreamUrl() + "/")
1132
- .then(() => { clearTimeout(t); resolve(true); })
1133
- .catch(() => { clearTimeout(t); resolve(false); });
1134
- });
1202
+ // watcher:检测 dsh web 是否存活(端口动态解析,绝不写死 3080),存活才启动 bridge。
1203
+ // 走 isDshWebUp():探测失败会自动做一次动态发现(DSH Desktop 的 43120 / 自定义端口都靠它)。
1204
+ const checkUpstream = () => isDshWebUp(2000);
1135
1205
 
1206
+ // 先做一次动态发现再打印/派生:否则日志会长期写着错误的端口(用户实测就卡在
1207
+ // 「等待 dsh web(127.0.0.1:3080)启动...」而真实上游在 43120)。
1208
+ await refreshUpstreamDiscovery(true);
1136
1209
  console.log(`[dsh-remote] 等待 dsh web(${upstreamUrl()})启动...`);
1137
1210
  let bridgeProc = null;
1138
1211
  let starting = false;
@@ -2284,6 +2357,134 @@ async function pluginCmd(argv) {
2284
2357
  * resolve = dsh 的裸包名解析能不能走通(浏览器半注入面板就靠它)
2285
2358
  * client = dsh.client.platform 声明在不在
2286
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
+
2287
2488
  function printPluginDiagnostics(profileDir) {
2288
2489
  const nmPlugin = path.join(profileDir, "node_modules", PLUGIN_ID);
2289
2490
  const link = describePluginLink(profileDir);
@@ -2369,6 +2570,7 @@ else if (cmd === "settings") settingsHint();
2369
2570
  else if (cmd === "run") await runBridge();
2370
2571
  else if (cmd === "plugin") await pluginCmd(process.argv.slice(3));
2371
2572
  else if (cmd === "repair") repairProfile(process.argv.slice(3));
2573
+ else if (cmd === "doctor") await doctorCmd(process.argv.slice(3));
2372
2574
  else if (cmd === "status") {
2373
2575
  const cfg = loadConfig();
2374
2576
  const local = Boolean(cfg.local_key);
@@ -370,8 +370,23 @@ test("运行时 dsh-setup.mjs:上游端口只有一处真相源,watcher/子
370
370
  const code = SETUP_SRC.split("\n").filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join("\n");
371
371
  assert.ok(!/fetch\("http:\/\/127\.0\.0\.1:3080/.test(code), "探测不得写死 3080(dsh web 可能在别的端口)");
372
372
  assert.ok(!/findstr ":3080"|iTCP:3080/.test(code), "按端口找 dsh web 进程也不得写死 3080");
373
- assert.ok(/fetch\(upstreamUrl\(\) \+ "\/"\)/.test(code), "watcher 的在线判据必须用 upstreamUrl()");
373
+ assert.ok(/async function upstreamReachable\(url = upstreamUrl\(\)/.test(code), "在线判据必须用解析出来的上游地址");
374
+ assert.ok(/await upstreamReachable\(upstreamUrl\(\)/.test(code), "isDshWebUp 必须基于 upstreamUrl() 探测");
374
375
  assert.ok(/DSH_BRIDGE_UPSTREAM: upstreamUrl\(\)/.test(code), "必须把上游显式透传给 bridge 子进程");
376
+ // ── 上游端口动态发现(2026-09-23 第二起实测:DSH Desktop 默认 43120,占用还会 +1)──
377
+ // 只靠"端口文件"不够:文件可能还没写、或写着上一轮的旧端口;那时必须自己去找。
378
+ assert.ok(
379
+ /from "\.\/clients\/dsh-remote\/upstream-discovery\.mjs"/.test(SETUP_SRC),
380
+ "必须复用共享的上游发现实现(watcher/bridge 同一份,避免两边漂移)"
381
+ );
382
+ assert.ok(
383
+ /const after = await refreshUpstreamDiscovery\(true\)/.test(code),
384
+ "探测失败必须主动做一次动态发现再试(否则端口一变就永远卡在 starting)"
385
+ );
386
+ assert.ok(
387
+ /await refreshUpstreamDiscovery\(true\);/.test(code),
388
+ "启动时先发现再打印:日志不得再谎称固定端口"
389
+ );
375
390
  // 半装运行时的根因之一:本安装器自己 import 的 src/lifecycle.mjs 必须在同步表里
376
391
  assert.ok(/"src\/lifecycle\.mjs"/.test(SETUP_SRC), "RUNTIME_CLIENT_FILES 必须包含 src/lifecycle.mjs(否则会漏同步出半装运行时)");
377
392
  });
@@ -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
+ });