@mrrisega/dsh-remote 0.7.7 → 0.7.9

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.
@@ -50,7 +50,16 @@ const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
50
50
  const REPO_URL = "https://github.com/mrRisega/dsh-remote";
51
51
 
52
52
  // ---------- 平台(0.6.7 起支持 Windows) ----------
53
- const IS_WIN = process.platform === "win32";
53
+ /**
54
+ * 有效平台:`DSH_SETUP_PLATFORM` 可覆盖(**只用于测试**,让 win32 分支能在 macOS/Linux 上被
55
+ * 行为级跑一遍;生产不设置 → 与 `process.platform` 完全一致,行为不变)。
56
+ *
57
+ * 为什么值得开这个口子:Windows 的进程发现 / 自检扫描在开发机上根本跑不到,而它们恰恰是
58
+ * 最贵的一类 bug —— 2026-10 那次「活着的 bridge 被判成没运行 → 反复 taskkill → bridge_wedged」
59
+ * 里,win32 的两条扫描分支(`Name='node.exe'` 过滤 + doctor 的 tasklist 无命令行)**从来没被真正执行过**。
60
+ */
61
+ const SETUP_PLATFORM = String(process.env.DSH_SETUP_PLATFORM || process.platform).toLowerCase();
62
+ const IS_WIN = SETUP_PLATFORM === "win32";
54
63
  /**
55
64
  * Windows 自启动 = 任务计划程序(Task Scheduler)里的登录任务。
56
65
  * 为什么不是「启动文件夹快捷方式」:生成 .lnk 需要额外依赖或 COM 调用,而 schtasks 系统自带,
@@ -587,17 +596,55 @@ function schtasks(args, timeoutMs = 15000) {
587
596
  }
588
597
  }
589
598
 
590
- /** 写 pid 文件(Windows 进程发现用;失败不致命,插件半还有 PowerShell 兜底扫描)。 */
599
+ /**
600
+ * 运行期留痕:写/删 pid 文件这类"非关键"动作失败时**必须留一行**(追加到安装日志)。
601
+ *
602
+ * 为什么不再静默 `catch {}`:pid 文件是插件半发现进程的唯一入口(Windows 无 pgrep/ps)。
603
+ * 它一旦"莫名消失",现场能看到的只有「bridge 未运行」——而**为什么**消失(EBUSY / 杀软 /
604
+ * 权限 / 并发截断)一点线索都没有。2026-10 那次排查里,这一层缺失直接把方向带偏。
605
+ * 日志本身写失败也不得影响业务(磁盘满/目录不可写 → 静默放弃)。
606
+ */
607
+ function logSetupIssue(line) {
608
+ try {
609
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
610
+ fs.appendFileSync(path.join(CONFIG_DIR, ".dsh-setup-install.log"),
611
+ `\n[${new Date().toISOString()}] [dsh-setup] ${line}\n`);
612
+ } catch { /* 日志失败不得影响业务 */ }
613
+ }
614
+
615
+ /** 写 pid 文件(Windows 进程发现用;失败不致命,插件半还有 PowerShell 兜底扫描,但必须留痕)。 */
591
616
  function writePidFile(name, pid) {
592
617
  if (!Number.isInteger(pid) || pid <= 0) return;
593
618
  try {
594
619
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
595
620
  fs.writeFileSync(path.join(CONFIG_DIR, name), String(pid));
596
- } catch { /* 非关键 */ }
621
+ } catch (e) {
622
+ logSetupIssue(`写 pid 文件失败(${name},pid=${pid}):${e && (e.code || e.message)}`);
623
+ }
597
624
  }
598
625
 
599
- function removePidFile(name) {
600
- try { fs.rmSync(path.join(CONFIG_DIR, name), { force: true }); } catch { /* 非关键 */ }
626
+ /**
627
+ * 删除 pid 文件。
628
+ *
629
+ * `expectPid`:**只在文件内容等于它时才删**(不传 = 旧语义「无条件删」,仅留给确实要清理
630
+ * 整份残留的场景;本文件 `run()` 里的所有调用点都必须传)。
631
+ *
632
+ * 为什么必须校验归属:pid 文件是"谁持有谁负责"的:父进程无条件删会把**别的实例(或孤儿)**
633
+ * 的 pid 文件删掉,状态立刻变成「进程还活着、pid 文件没了」。2026-10 事故的根因链第 2 步
634
+ * 正是这个:新 bridge 撞本地实例锁秒退 → watcher 的 exit 钩子无条件删 `.dsh-bridge.pid` →
635
+ * 真正持有隧道的孤儿 bridge 从面板上"消失" → 被判成"没运行" → 反复重启 → `bridge_wedged`。
636
+ */
637
+ function removePidFile(name, expectPid = null) {
638
+ const file = path.join(CONFIG_DIR, name);
639
+ try {
640
+ if (expectPid != null) {
641
+ const cur = readPidFile(name);
642
+ if (cur !== expectPid) return; // 不是我们的(或已被别人改写/删除)→ 一个字节都不动
643
+ }
644
+ fs.rmSync(file, { force: true });
645
+ } catch (e) {
646
+ logSetupIssue(`删除 pid 文件失败(${name}):${e && (e.code || e.message)}`);
647
+ }
601
648
  }
602
649
 
603
650
  function readPidFile(name) {
@@ -607,6 +654,22 @@ function readPidFile(name) {
607
654
  } catch { return null; }
608
655
  }
609
656
 
657
+ /**
658
+ * 取某 pid 的**完整命令行**(跨平台)。取不到 → 空串(调用方一律按"不认识这个进程"处理)。
659
+ *
660
+ * Windows 分支是本次修复的关键之一:旧实现只有 `ps -p <pid> -o command=`,而 Windows
661
+ * 没有 `ps`(`sh()` 走 cmd.exe)⇒ `looksLikeWatcher/looksLikeBridge` 在 Windows 上**恒 false**
662
+ * ⇒ 接管/回收上一任进程这两条安全回收路径在 Windows 上**从不生效**。这里改用
663
+ * `windowsProcessCommandLine()`(PowerShell CIM 按 ProcessId 点查,与 bridge 自己
664
+ * `instanceLockVerdict` 用的是同族手法)。
665
+ */
666
+ function processCommandLine(pid) {
667
+ if (!Number.isInteger(pid) || pid <= 0) return "";
668
+ if (IS_WIN) return windowsProcessCommandLine(pid);
669
+ const r = sh(`ps -p ${pid} -o command=`);
670
+ return r.ok ? String(r.stdout || "") : "";
671
+ }
672
+
610
673
  /**
611
674
  * 这个 pid 看起来**真的**是 watcher 吗?
612
675
  *
@@ -616,22 +679,21 @@ function readPidFile(name) {
616
679
  */
617
680
  function looksLikeWatcher(pid) {
618
681
  if (!Number.isInteger(pid) || pid <= 0) return false;
619
- const r = sh(`ps -p ${pid} -o command=`);
620
- return Boolean(r.ok && /dsh-setup\.mjs/.test(r.stdout));
682
+ return /dsh-setup\.mjs/.test(processCommandLine(pid));
621
683
  }
622
684
 
623
685
  /**
624
686
  * 这个 pid 是不是我们的 **bridge 子进程**(不是 watcher、也不是别的 node)。
625
687
  *
626
688
  * 为什么要单独判:接管时我们要回收"上一任遗留的 bridge",而 pid 可能已被系统回收给无关进程 ——
627
- * 对无关进程发 SIGKILL 是不可接受的。所以对 `ps` 出来的命令行做**双重**校验:
689
+ * 对无关进程发 SIGKILL 是不可接受的。所以对命令行做**双重**校验:
628
690
  * 必须同时出现 bridge 脚本名与 clients/dsh-remote 路径片段。
691
+ * (Windows 分支见 `processCommandLine`:Electron 宿主下拉起的 bridge 可能不叫 node.exe,
692
+ * 所以**不能**靠镜像名判断,只能靠命令行。)
629
693
  */
630
694
  function looksLikeBridge(pid) {
631
695
  if (!Number.isInteger(pid) || pid <= 0) return false;
632
- const r = sh(`ps -p ${pid} -o command=`);
633
- if (!r.ok) return false;
634
- const cmd = String(r.stdout || "");
696
+ const cmd = processCommandLine(pid);
635
697
  return /dsh-bridge\.mjs/.test(cmd) && /clients[\\/]dsh-remote/.test(cmd);
636
698
  }
637
699
 
@@ -692,29 +754,60 @@ function isThisRuntimeBridgeCmd(cmd) {
692
754
  return norm(c).includes(norm(needle));
693
755
  }
694
756
 
695
- /** 列出本机属于**本运行时**的 bridge 进程 pid(命令行校验;取不到命令行 → 空数组)。 */
757
+ /**
758
+ * Windows:一次性 PowerShell CIM **全量枚举**进程的 {pid, name, cmd}。
759
+ *
760
+ * 返回值的三态是**契约**(doctor 的"假证据"就是被两态混用坑出来的):
761
+ * · `null` → **扫描不可用**(无 PowerShell / 超时 / 退出码非 0 / JSON 解析失败)
762
+ * · `[]` → 扫描成功,但确实一个进程都没有(或输出为空)
763
+ * · 数组 → 扫描成功,逐项 `{ pid, name, cmd }`
764
+ *
765
+ * 🔴 为什么不按镜像名过滤(旧实现是 `-Filter "Name='node.exe'"`):
766
+ * 桌面端(Electron)宿主起的 watcher 镜像名**一定不是** node.exe
767
+ *(`process.execPath` = 桌面端 exe),bridge 的镜像名则取决于本机有没有真 node
768
+ *(`preferredNode()` 回落 Electron 时为桌面端 exe)。按 node.exe 过滤 ⇒ 这类进程
769
+ * **永远扫不到** ⇒ 活着的孤儿 bridge 清不掉、doctor 恒报 0/0。
770
+ * 全量枚举 + 命令行匹配的代价是 PowerShell 多返回一些行(一次性 8s 上限),换来
771
+ * "与镜像名彻底解耦"——与 bridge 自己的单实例锁判定(按 ProcessId 查 CommandLine)同族手法。
772
+ */
773
+ function windowsCimProcesses() {
774
+ if (!IS_WIN) return null;
775
+ const script = [
776
+ "try { $p = Get-CimInstance Win32_Process }",
777
+ "catch { $p = Get-WmiObject Win32_Process }",
778
+ "$p | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress",
779
+ ].join("; ");
780
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
781
+ try {
782
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
783
+ { encoding: "utf8", timeout: 8000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 });
784
+ if (r && r.error) return null; // ENOENT(没 PowerShell)等
785
+ if (r && typeof r.status === "number" && r.status !== 0) return null; // 命令失败 ≠ 没有进程
786
+ const raw = String((r && r.stdout) || "").trim();
787
+ if (!raw) return [];
788
+ const parsed = JSON.parse(raw);
789
+ const out = [];
790
+ for (const p of Array.isArray(parsed) ? parsed : [parsed]) {
791
+ const pid = Number(p && p.ProcessId);
792
+ if (!Number.isInteger(pid) || pid <= 0) continue;
793
+ out.push({ pid, name: String((p && p.Name) || ""), cmd: String((p && p.CommandLine) || "") });
794
+ }
795
+ return out;
796
+ } catch { return null; } // 无 PowerShell / 解析失败 → 调用方按"扫描不可用"处理,绝不输出 0
797
+ }
798
+
799
+ /** 列出本机属于**本运行时**的 bridge 进程 pid(命令行校验;扫描不可用/取不到命令行 → 空数组)。 */
696
800
  function listRuntimeBridgePids() {
697
801
  const pids = [];
698
802
  if (IS_WIN) {
699
- // 一次性 PowerShell CIM 枚举(逐个 windowsProcessCommandLine 会起 N 个进程,太贵)
700
- const script = [
701
- "try { $p = Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" }",
702
- "catch { $p = Get-WmiObject Win32_Process -Filter \"Name='node.exe'\" }",
703
- "$p | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress",
704
- ].join("; ");
705
- const encoded = Buffer.from(script, "utf16le").toString("base64");
706
- try {
707
- const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
708
- { encoding: "utf8", timeout: 8000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 });
709
- const raw = String(r.stdout || "").trim();
710
- if (raw) {
711
- const parsed = JSON.parse(raw);
712
- for (const p of Array.isArray(parsed) ? parsed : [parsed]) {
713
- const pid = Number(p && p.ProcessId);
714
- if (Number.isInteger(pid) && pid > 0 && isThisRuntimeBridgeCmd(p.CommandLine)) pids.push(pid);
715
- }
716
- }
717
- } catch { /* 无 PowerShell / 解析失败 → 不杀任何进程 */ }
803
+ // 全量 CIM 枚举(**不按镜像名过滤**:Electron 宿主拉起的进程可能不叫 node.exe)
804
+ const procs = windowsCimProcesses();
805
+ if (!procs) return pids; // 扫描不可用 → 一个都不杀(失败方向必须是漏杀,不能误杀)
806
+ for (const p of procs) {
807
+ // isThisRuntimeBridgeCmd 的严格性保持不变:命令行必须同时含 dsh-bridge.mjs 与
808
+ // **本运行时**的 clients/dsh-remote 路径(另一个运行时目录的进程不动,避免跨配置目录误杀)
809
+ if (isThisRuntimeBridgeCmd(p.cmd)) pids.push(p.pid);
810
+ }
718
811
  return pids;
719
812
  }
720
813
  const r = sh("pgrep -fl 'dsh-bridge\\.mjs'");
@@ -1811,7 +1904,8 @@ async function runBridge() {
1811
1904
  if (pidAlive(leftover) && looksLikeBridge(leftover)) {
1812
1905
  try { process.kill(leftover, "SIGKILL"); } catch { /* 已退出 */ }
1813
1906
  }
1814
- removePidFile(BRIDGE_PID_FILE);
1907
+ // 只删**刚才确认属于我们的**那个 pid(内容已核对过是 leftover);不无条件删整份文件
1908
+ removePidFile(BRIDGE_PID_FILE, leftover);
1815
1909
  }
1816
1910
  }
1817
1911
  // ── C2:清扫「pid 文件之外的孤儿 bridge」(命令行校验,绝不误杀)──────────────
@@ -1999,8 +2093,16 @@ async function runBridge() {
1999
2093
  // POSIX 上该选项被忽略。
2000
2094
  windowsHide: true
2001
2095
  });
2096
+ // 记下这个子进程的 pid:退出钩子删 pid 文件时必须核对归属(见 removePidFile 注释)。
2097
+ // 为什么不能直接用 bridgeProc.pid:bridgeProc 之后会被下一次 spawn 覆盖,而旧子进程的
2098
+ // exit 回调仍会跑 —— 那时若去删"当前"pid 文件,就会把新实例的 pid 文件删掉。
2099
+ const spawnedBridgePid = bridgeProc.pid;
2002
2100
  bridgeProc.on("exit", (code) => {
2003
- removePidFile(BRIDGE_PID_FILE);
2101
+ // ⚠️ 只删**属于这个子进程**的 pid 文件(内容 == 它的 pid)。
2102
+ // 旧实现无条件删:新 bridge 撞本地实例锁秒退(code=4)是**常态事件**,每次都把
2103
+ // 真正活着的那条 bridge(孤儿)的 pid 文件删掉 → 面板「bridge 未运行」→ 反复重启
2104
+ // → bridge_wedged。这是 2026-10 事故根因链的第 2 步。
2105
+ removePidFile(BRIDGE_PID_FILE, spawnedBridgePid);
2004
2106
  // C2 互踢根治的 watcher 半边:bridge 连续 2 次收到 code=4000(= 本机已有同 deviceId 的
2005
2107
  // 另一个 bridge 把本连接顶掉)后会以"重复实例"退出码优雅退出(见 dsh-bridge.mjs)。
2006
2108
  // 这种退出**绝不能**照常 10s 重拉:本机已经有另一个实例在跑,重拉只会继续乒乓互踢。
@@ -2056,7 +2158,18 @@ async function runBridge() {
2056
2158
  // watcher 自身的 pid 也要落盘(插件半据此判断"守护在跑"并避免重复拉起)
2057
2159
  {
2058
2160
  writePidFile(WATCHER_PID_FILE, process.pid);
2059
- const cleanup = () => { removePidFile(WATCHER_PID_FILE); removePidFile(BRIDGE_PID_FILE); };
2161
+ /**
2162
+ * 退出清理:**只删属于自己的 pid 文件**。
2163
+ * watcher 文件属于本进程;bridge 文件属于**本进程拉起的那个子进程**。
2164
+ * 无条件删(旧行为)会把孤儿/别的实例的 pid 文件删掉 → 状态变成「进程还活着、pid 文件没了」,
2165
+ * 正是本次事故根因链的第 2 步;失败方向必须是"残留一份过期文件"(插件按 pidAlive 会判掉),
2166
+ * 而不是"删掉别人活着的证据"。
2167
+ */
2168
+ const cleanup = () => {
2169
+ removePidFile(WATCHER_PID_FILE, process.pid);
2170
+ const childPid = bridgeProc && bridgeProc.pid;
2171
+ if (Number.isInteger(childPid) && childPid > 0) removePidFile(BRIDGE_PID_FILE, childPid);
2172
+ };
2060
2173
  process.on("exit", cleanup);
2061
2174
  for (const sig of ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"]) {
2062
2175
  try {
@@ -3341,7 +3454,11 @@ async function doctorCmd(argv = []) {
3341
3454
  };
3342
3455
 
3343
3456
  console.log("dsh-remote 自检(doctor,只读,不改任何东西)");
3344
- console.log("时间:", new Date().toISOString(), "| 平台:", `${process.platform}/${process.arch}`, "| node:", process.version);
3457
+ // 平台:若被 DSH_SETUP_PLATFORM 覆盖(仅测试用),如实标注 —— 否则"平台 darwin 却走了 win32 扫描"会非常难解释
3458
+ const platformLabel = SETUP_PLATFORM === process.platform
3459
+ ? `${process.platform}/${process.arch}`
3460
+ : `${SETUP_PLATFORM}/${process.arch}(DSH_SETUP_PLATFORM 覆盖;实际 ${process.platform})`;
3461
+ console.log("时间:", new Date().toISOString(), "| 平台:", platformLabel, "| node:", process.version);
3345
3462
  console.log("配置目录:", CONFIG_DIR);
3346
3463
 
3347
3464
  // ── ① 插件与运行环境版本 ────────────────────────────────────────────────
@@ -3373,20 +3490,55 @@ async function doctorCmd(argv = []) {
3373
3490
  const bridgePid = readPidFile(BRIDGE_PID_FILE);
3374
3491
  console.log(` watcher pid : ${watcherPid || "-"}${watcherPid ? (alive(watcherPid) ? "(在跑)" : "(已死,pid 文件过期)") : ""}`);
3375
3492
  console.log(` bridge pid : ${bridgePid || "-"}${bridgePid ? (alive(bridgePid) ? "(在跑)" : "(已死,pid 文件过期)") : ""}`);
3376
- // 进程扫描(平台各自的方式):只报数量,不猜
3493
+ // 进程扫描(平台各自的方式):只报**扫描拿到的真实数据**;扫不到就说"扫描不可用"。
3494
+ //
3495
+ // 🔴 旧实现是本次「假证据」的直接来源:Windows 侧用
3496
+ // `tasklist /FI "IMAGENAME eq node.exe" /FO CSV` 拿镜像名列表,再用
3497
+ // /dsh-setup\.mjs/、/dsh-bridge\.mjs/ 去过滤 **CSV 行** —— 而 tasklist 的输出里
3498
+ // **根本没有命令行**,于是两个计数在 Windows 上**恒为 0/0**,与真实情况毫无关系。
3499
+ // 2026-10 那次排查正是被这条"证据"带向「进程真的没起来」的错误方向。
3500
+ // 现在:Windows 复用 CIM 全量枚举(windowsCimProcesses,**带 CommandLine、不按镜像名过滤**),
3501
+ // 按命令行分别统计 watcher / bridge,并打出 pid + 解释器名 + 命令行摘要;
3502
+ // 扫描不可用(没 PowerShell / 超时 / 命令失败)时**显式打印「扫描不可用」**,
3503
+ // 绝不输出一个会被误读成"没有进程"的 0。
3377
3504
  try {
3378
- const r = process.platform === "win32"
3379
- ? sh("tasklist /FI \"IMAGENAME eq node.exe\" /FO CSV 2>NUL")
3380
- : sh("pgrep -fl 'dsh-setup.mjs|dsh-bridge.mjs' 2>/dev/null");
3381
- const lines = String(r.stdout || "").split("\n").map((l) => l.trim()).filter((l) => l && !/pgrep/.test(l));
3382
- const watchers = lines.filter((l) => /dsh-setup\.mjs/.test(l));
3383
- const bridges = lines.filter((l) => /dsh-bridge\.mjs/.test(l)).filter((l) => !/dsh-setup/.test(l));
3384
- console.log(` 实测进程 : watcher ${watchers.length} 个 / bridge ${bridges.length} 个`);
3385
- if (watchers.length && !bridges.length) console.log(" ⚠️ 守护在跑、bridge 没起来 —— 这类情况看日志尾部(通常是在等 dsh web 或登录限流)");
3386
- if (!watchers.length && !bridges.length) {
3387
- console.log(" ⚠️ 一个进程都没在跑。手动起一次看报错:`dsh-remote run`(前台,能直接看到输出)");
3388
- }
3389
- } catch (e) { console.log(` 实测进程 : 扫描失败(${e.message})`); }
3505
+ if (IS_WIN) {
3506
+ const procs = windowsCimProcesses();
3507
+ if (!procs) {
3508
+ console.log(" 实测进程 : 扫描不可用(PowerShell/CIM 取不到进程列表:可能没装 PowerShell、被策略禁用或超时)");
3509
+ console.log(" ⚠️ 这条**不能**当作「进程没在跑」的证据 —— 只看上面的 pid 文件结论与日志尾部。");
3510
+ } else {
3511
+ const watchers = procs.filter((p) => /dsh-setup\.mjs/.test(p.cmd));
3512
+ const bridges = procs.filter((p) => /dsh-bridge\.mjs/.test(p.cmd)).filter((p) => !/dsh-setup/.test(p.cmd));
3513
+ console.log(` 实测进程 : watcher ${watchers.length} 个 / bridge ${bridges.length} 个(CIM 全量扫描,按命令行识别)`);
3514
+ for (const [kind, list] of [["watcher", watchers], ["bridge", bridges]]) {
3515
+ for (const p of list) {
3516
+ const brief = p.cmd.length > 160 ? `${p.cmd.slice(0, 157)}...` : p.cmd;
3517
+ console.log(` · ${kind} pid=${p.pid} 解释器=${p.name || "?"} 命令行=${brief || "(取不到)"}`);
3518
+ }
3519
+ }
3520
+ if (watchers.length && !bridges.length) console.log(" ⚠️ 守护在跑、bridge 没起来 —— 这类情况看日志尾部(通常是在等 dsh web 或登录限流)");
3521
+ if (!watchers.length && !bridges.length) {
3522
+ console.log(" ⚠️ 一个进程都没在跑。手动起一次看报错:`dsh-remote run`(前台,能直接看到输出)");
3523
+ }
3524
+ }
3525
+ } else {
3526
+ const r = sh("pgrep -fl 'dsh-setup.mjs|dsh-bridge.mjs' 2>/dev/null");
3527
+ if (!r.ok && r.code !== 1) {
3528
+ // pgrep 退出码 1 = "没有匹配的进程"(合法的 0),其它码 = 工具不可用(不能当 0 读)
3529
+ console.log(` 实测进程 : 扫描不可用(pgrep 退出码 ${r.code}:本机没有 pgrep 或被禁用)`);
3530
+ } else {
3531
+ const lines = String(r.stdout || "").split("\n").map((l) => l.trim()).filter((l) => l && !/pgrep/.test(l));
3532
+ const watchers = lines.filter((l) => /dsh-setup\.mjs/.test(l));
3533
+ const bridges = lines.filter((l) => /dsh-bridge\.mjs/.test(l)).filter((l) => !/dsh-setup/.test(l));
3534
+ console.log(` 实测进程 : watcher ${watchers.length} 个 / bridge ${bridges.length} 个`);
3535
+ if (watchers.length && !bridges.length) console.log(" ⚠️ 守护在跑、bridge 没起来 —— 这类情况看日志尾部(通常是在等 dsh web 或登录限流)");
3536
+ if (!watchers.length && !bridges.length) {
3537
+ console.log(" ⚠️ 一个进程都没在跑。手动起一次看报错:`dsh-remote run`(前台,能直接看到输出)");
3538
+ }
3539
+ }
3540
+ }
3541
+ } catch (e) { console.log(` 实测进程 : 扫描不可用(${e.message})`); }
3390
3542
  if (process.platform === "linux") {
3391
3543
  const hasUser = Boolean(process.env.XDG_RUNTIME_DIR);
3392
3544
  console.log(` 自启动 : systemd --user ${hasUser ? "(有 runtime dir)" : "(无 XDG_RUNTIME_DIR → 本机没有 user systemd,插件会用后台进程兜底)"}`);