@mrrisega/dsh-remote 0.6.6 → 0.6.7-beta.2

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/dsh-setup.mjs CHANGED
@@ -26,7 +26,7 @@
26
26
  import fs from "node:fs";
27
27
  import os from "node:os";
28
28
  import path from "node:path";
29
- import { spawn, execSync } from "node:child_process";
29
+ import { spawn, spawnSync, execSync } from "node:child_process";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import { childStopped } from "./clients/dsh-remote/src/lifecycle.mjs";
32
32
 
@@ -45,6 +45,22 @@ const DEFAULT_API = "https://n.risegao.cn:13443/relay-api";
45
45
  const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
46
46
  const REPO_URL = "https://github.com/mrRisega/dsh-remote";
47
47
 
48
+ // ---------- 平台(0.6.7 起支持 Windows) ----------
49
+ const IS_WIN = process.platform === "win32";
50
+ /**
51
+ * Windows 自启动 = 任务计划程序(Task Scheduler)里的登录任务。
52
+ * 为什么不是「启动文件夹快捷方式」:生成 .lnk 需要额外依赖或 COM 调用,而 schtasks 系统自带,
53
+ * 且**不需要管理员**就能为当前用户建 ONLOGON 任务(默认「仅在用户登录时运行」,不存密码)。
54
+ */
55
+ const WIN_TASK_NAME = "dsh-remote-bridge";
56
+ /**
57
+ * watcher / bridge 的 pid 文件(Windows 必需)。
58
+ * Windows 没有 pgrep/ps,插件半(dsh-remote-web)与安装器都靠这两个文件发现进程:
59
+ * watcher = 本安装器 `run` 子命令的进程,bridge = 它拉起的 dsh-bridge.mjs 子进程。
60
+ */
61
+ const WATCHER_PID_FILE = ".dsh-watcher.pid";
62
+ const BRIDGE_PID_FILE = ".dsh-bridge.pid";
63
+
48
64
  // ---------- 运行时自物化(npm/npx 安装 → 固化到配置目录,脱离 npx 缓存) ----------
49
65
  // npx 每次安装的缓存目录(~/.npm/_npx/<hash>)不固定:缓存一旦清理,指向它的自启动服务
50
66
  // 就会像“找不到模块”一样崩溃。因此 npm 形态安装时把 dsh-setup.mjs + clients + 依赖(ws)
@@ -135,6 +151,49 @@ function ensureRuntimeCopy() {
135
151
  fs.writeFileSync(verFile, ver);
136
152
  console.log(`✅ 运行时: ${CONFIG_DIR}`);
137
153
  }
154
+ /** 打印用法(唯一实现:提前退出与末尾分派共用,避免两处漂移)。 */
155
+ function printHelp() {
156
+ console.log(`dsh-remote — 手机远程控制 dsh web(隧道模式)
157
+
158
+ 用法:
159
+ dsh-remote 一键安装(默认命令,无需任何参数;含插件与自启动)
160
+ dsh-remote settings 显示登录/连接配置指引(独立设置页已移除)
161
+ dsh-remote run 前台运行 bridge(调试)
162
+ dsh-remote status 查看配置与服务状态
163
+ dsh-remote plugin 手动安装 dsh web 远程控制插件(--uninstall 卸载)
164
+ dsh-remote repair 修复插件挂载(dsh web 起不来时用;只碰 profile,不联网)
165
+ dsh-remote --help 显示本用法(等同 dsh-remote help)
166
+
167
+ 自建模式(可选):
168
+ dsh-remote setup --server wss://你的域名:端口 --key 访问密钥
169
+
170
+ 登录/连接配置: 打开 dsh web → 设置 → 「远程控制」→ 注册或登录手机号即可
171
+ (自建用户切「自建服务」标签或直接用上方 setup 命令,无需另开页面)。
172
+ 文档: ${REPO_URL}
173
+ `);
174
+ }
175
+
176
+ // 认识的参数(只给 flag 也要能装上去,所以不认识时才拦)。
177
+ const KNOWN_FLAGS = new Set([
178
+ "--api", "--server", "--key", "--profile",
179
+ "--no-autostart", "--no-plugin", "--no-restart", "--uninstall"
180
+ ]);
181
+
182
+ // ⚠️ 这一整块必须挡在 ensureRuntimeCopy() **之前**:它会往 ~/.dsh-remote 同步运行时脚本。
183
+ // 看用法、或参数拼错,都不该改动用户磁盘 —— 此前 `dsh-remote --help` 会一路走到 setup,
184
+ // 什么都不问就开始安装。
185
+ {
186
+ const first = process.argv[2];
187
+ if (first === "-h" || first === "--help" || first === "help") {
188
+ printHelp();
189
+ process.exit(0);
190
+ }
191
+ if (first && first.startsWith("-") && !KNOWN_FLAGS.has(first)) {
192
+ console.error(`❌ 未知参数: ${first}`);
193
+ console.error(" 直接安装就用 dsh-remote(不带参数);查看用法用 dsh-remote --help。");
194
+ process.exit(1);
195
+ }
196
+ }
138
197
  try { ensureRuntimeCopy(); } catch (e) { console.warn(`⚠️ 运行时固化跳过: ${e.message}`); }
139
198
 
140
199
  /** 自启动服务应指向的 dsh-setup.mjs:优先配置目录内的固化副本,否则当前执行文件。 */
@@ -188,7 +247,7 @@ const NODE_BIN = preferredNode();
188
247
  */
189
248
  const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
190
249
 
191
- // ---------- 安装口径(随 bridge 环境变量注入;服务端存到设备行,供运营统计) ----------
250
+ // ---------- 安装口径(随 bridge 环境变量注入;服务端存到设备行,用于区分安装来源) ----------
192
251
  /**
193
252
  * install_source:本文件即「一键安装器」→ 默认 npx(用户自己跑 `npx @mrrisega/dsh-remote` 的那条路径)。
194
253
  * 插件市场那条路径由插件半自愈补装:插件 spawn 本安装器时显式传入
@@ -268,6 +327,95 @@ function sleepSync(ms) {
268
327
  catch { /* 极端环境不支持 → 退化为忙等一小会 */ const end = Date.now() + ms; while (Date.now() < end) { /* spin */ } }
269
328
  }
270
329
 
330
+ /**
331
+ * 运行 schtasks(Windows 任务计划程序)。
332
+ * **不走 sh()**:/TR 里必然带引号(node 路径与脚本路径都可能含空格),
333
+ * 经 cmd.exe 转一层会把嵌套引号绞碎;spawnSync 直接把 argv 交给 CreateProcess,
334
+ * 由 Node 负责转义,参数原样到达 schtasks。
335
+ */
336
+ function schtasks(args, timeoutMs = 15000) {
337
+ try {
338
+ const r = spawnSync("schtasks", args, { encoding: "utf8", timeout: timeoutMs, windowsHide: true });
339
+ return {
340
+ ok: r.status === 0,
341
+ code: r.status,
342
+ stdout: String(r.stdout || ""),
343
+ stderr: String(r.stderr || (r.error && r.error.message) || ""),
344
+ };
345
+ } catch (e) {
346
+ return { ok: false, code: -1, stdout: "", stderr: e.message };
347
+ }
348
+ }
349
+
350
+ /** 写 pid 文件(Windows 进程发现用;失败不致命,插件半还有 PowerShell 兜底扫描)。 */
351
+ function writePidFile(name, pid) {
352
+ if (!Number.isInteger(pid) || pid <= 0) return;
353
+ try {
354
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
355
+ fs.writeFileSync(path.join(CONFIG_DIR, name), String(pid));
356
+ } catch { /* 非关键 */ }
357
+ }
358
+
359
+ function removePidFile(name) {
360
+ try { fs.rmSync(path.join(CONFIG_DIR, name), { force: true }); } catch { /* 非关键 */ }
361
+ }
362
+
363
+ function readPidFile(name) {
364
+ try {
365
+ const n = Number(String(fs.readFileSync(path.join(CONFIG_DIR, name), "utf8")).trim());
366
+ return Number.isInteger(n) && n > 0 ? n : null;
367
+ } catch { return null; }
368
+ }
369
+
370
+ function pidAlive(pid) {
371
+ if (!Number.isInteger(pid) || pid <= 0) return false;
372
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
373
+ }
374
+
375
+ /**
376
+ * 注册 Windows 登录任务(幂等:先删再建)。
377
+ * /DELAY 0000:15:登录后等 15 秒再起,避免与桌面环境抢启动时机
378
+ * (watcher 本身就会等 dsh web 的 3080 端口,晚起没有任何副作用)。
379
+ * ⚠️ 已知限制(如实告知,不假装完美):ONLOGON 任务在用户会话里运行,登录时会有一个控制台窗口;
380
+ * 想彻底隐藏需要"不管用户是否登录都运行"(存密码/S4U) 或第三方隐藏器,都需要额外凭据或依赖,
381
+ * 故这里只做「能自启」,并在安装汇总里说明用户可在任务计划程序里勾「隐藏」。
382
+ */
383
+ let winTaskCache = null;
384
+
385
+ function writeWindowsTask() {
386
+ const node = NODE_BIN;
387
+ const setup = runtimeSetupPath();
388
+ const tr = `"${node}" "${setup}" run`;
389
+ if (tr.length > 255) {
390
+ console.log(`⚠️ 自启动命令过长(${tr.length} 字符,schtasks 上限 261):${tr}`);
391
+ console.log(" 建议把 dsh-remote 装到更短的路径,或用 `dsh-remote run` 手动运行 bridge。");
392
+ }
393
+ schtasks(["/Delete", "/TN", WIN_TASK_NAME, "/F"]); // 幂等清理(不存在也只是非 0 退出,不影响后续
394
+ const created = schtasks(["/Create", "/TN", WIN_TASK_NAME, "/TR", tr, "/SC", "ONLOGON", "/DELAY", "0000:15", "/F"]);
395
+ if (!created.ok) {
396
+ winTaskCache = false;
397
+ console.warn(`⚠️ 自启动任务注册失败: ${(created.stderr || created.stdout).trim() || `schtasks 退出码 ${created.code}`}`);
398
+ console.warn(" 可手动执行 `dsh-remote run` 运行 bridge(功能完全一样,只是不随登录自启)。");
399
+ return null;
400
+ }
401
+ winTaskCache = true;
402
+ return `任务计划程序 / ${WIN_TASK_NAME}`; // 汇总里展示的"路径"
403
+ }
404
+
405
+ /** 查询 Windows 登录任务是否已注册(退出码 0 = 存在;不解析本地化输出)。 */
406
+ function windowsTaskInstalled() {
407
+ return schtasks(["/Query", "/TN", WIN_TASK_NAME]).ok;
408
+ }
409
+
410
+ /**
411
+ * 本次进程内查询一次登录任务是否已注册(汇总里多处要用,避免反复起 schtasks)。
412
+ * 注册/删除任务时同步刷新缓存,保证汇总看到的是最终状态。
413
+ */
414
+ function winTaskOk() {
415
+ if (winTaskCache === null) winTaskCache = windowsTaskInstalled();
416
+ return winTaskCache;
417
+ }
418
+
271
419
  function writeAutostartFile() {
272
420
  const runCmd = `"${NODE_BIN}" "${runtimeSetupPath()}" run`;
273
421
  if (process.platform === "darwin") {
@@ -311,7 +459,8 @@ function writeAutostartFile() {
311
459
  fs.writeFileSync(unitPath, unit);
312
460
  return unitPath; // 路径在最终汇总里统一展示
313
461
  }
314
- console.log("⚠️ 当前平台暂不支持自启动,请手动运行 `dsh-remote run`");
462
+ if (IS_WIN) return writeWindowsTask();
463
+ console.log(`⚠️ 当前平台(${process.platform})暂不支持自启动,请手动运行 \`dsh-remote run\``);
315
464
  return null;
316
465
  }
317
466
 
@@ -429,6 +578,22 @@ function startBridgeDarwin(plistPath) {
429
578
  }
430
579
 
431
580
  function restartBridgeService() {
581
+ if (IS_WIN) {
582
+ if (!windowsTaskInstalled()) writeWindowsTask(); // 静默补生成(调用方会汇总展示)
583
+ // /Run 只是"现在跑一次";等 pid 文件出现才算真的起来了(schtasks 的退出码只说明任务被触发)
584
+ const r = schtasks(["/Run", "/TN", WIN_TASK_NAME]);
585
+ if (!r.ok) return { ok: false, status: "failed", detail: (r.stderr || r.stdout).trim() || `schtasks /Run 退出码 ${r.code}` };
586
+ for (let i = 0; i < 20; i += 1) {
587
+ const pid = readPidFile(WATCHER_PID_FILE);
588
+ if (pid && pidAlive(pid)) return { ok: true, status: "running", pid };
589
+ sleepSync(400);
590
+ }
591
+ return {
592
+ ok: false,
593
+ status: "degraded-detached",
594
+ detail: "已触发自启动任务,但没等到 bridge 守护进程(dsh web 可能还没运行;打开 dsh web 后会自动接上)",
595
+ };
596
+ }
432
597
  const svcFile = autostartFilePath();
433
598
  if (svcFile && !fs.existsSync(svcFile)) {
434
599
  writeAutostartFile(); // 静默补生成(调用方会汇总展示路径)
@@ -474,8 +639,21 @@ function resolveProfileDir(argv = []) {
474
639
  : process.env.DSH_PROFILE_DIR || path.join(os.homedir(), ".dsh", "profiles", "web");
475
640
  }
476
641
 
477
- /** 监听 127.0.0.1:3080 的进程 pid(= dsh web)。lsof 不可用时回退 pgrep。 */
642
+ /**
643
+ * 监听 127.0.0.1:3080 的进程 pid(= dsh web)。
644
+ * macOS/Linux:lsof → pgrep;Windows:netstat -ano 取 LISTENING 行的最后一列
645
+ * (netstat 是系统自带,比每次都起 PowerShell 快得多)。
646
+ */
478
647
  function dshWebPid() {
648
+ if (IS_WIN) {
649
+ const r = sh('netstat -ano | findstr ":3080"');
650
+ for (const line of String(r.stdout || "").split("\n")) {
651
+ if (!/LISTENING/i.test(line)) continue;
652
+ const m = line.trim().match(/(\d+)\s*$/);
653
+ if (m) return Number(m[1]);
654
+ }
655
+ return null;
656
+ }
479
657
  const l = sh("lsof -nP -iTCP:3080 -sTCP:LISTEN -t 2>/dev/null || true");
480
658
  const pid = String(l.stdout || "").trim().split(/\s+/).filter(Boolean)[0];
481
659
  if (pid && /^\d+$/.test(pid)) return Number(pid);
@@ -512,8 +690,10 @@ function dshWebLaunchdJob() {
512
690
  }
513
691
 
514
692
  function restartDshWeb() {
515
- const uid = process.getuid();
693
+ // ⚠️ process.getuid 只在 macOS 分支里取:Windows 上**没有这个函数**,
694
+ // 旧写法把它放在函数首行无条件调用 → 安装流程在 Windows 直接 TypeError 中断。
516
695
  if (process.platform === "darwin") {
696
+ const uid = process.getuid();
517
697
  const job = dshWebLaunchdJob();
518
698
  if (job) {
519
699
  const r = sh(`launchctl kickstart -k gui/${uid}/${job}`);
@@ -524,6 +704,7 @@ function restartDshWeb() {
524
704
  const r = sh("systemctl --user restart dsh-web 2>/dev/null || systemctl --user restart dsh 2>/dev/null");
525
705
  if (r.ok) return { ok: true, how: "systemctl --user restart dsh-web" };
526
706
  }
707
+ if (IS_WIN) return restartDshWebWindows();
527
708
  // 兜底:沿用原命令行重启(只在能拿到真实 node 入口时做,npx 缓存路径不可靠)
528
709
  const pid = dshWebPid();
529
710
  if (!pid) return { ok: false, how: "", detail: "未找到 dsh web 进程" };
@@ -540,6 +721,95 @@ function restartDshWeb() {
540
721
  }
541
722
  }
542
723
 
724
+ /**
725
+ * Windows:重启 dsh web(沿用原命令行自拉起)。
726
+ *
727
+ * 与插件半的重启助手同源思路:**先校验新进程拉得起来,再动旧进程**;用 node 跑一个 .mjs 助手,
728
+ * 不经过任何 shell(Windows 没有 /bin/sh,cmd.exe 的引号规则又极易把带空格的 node/入口路径绞碎)。
729
+ * 拿不到命令行(例如经 npx 启动)时如实拒绝,让用户手动重启,绝不"杀掉却起不来"。
730
+ */
731
+ function restartDshWebWindows() {
732
+ const pid = dshWebPid();
733
+ if (!pid) return { ok: false, how: "", detail: "未找到监听 3080 的进程(dsh web 没在运行?)" };
734
+ const cmdline = windowsProcessCommandLine(pid);
735
+ if (!cmdline) return { ok: false, how: "", detail: "无法获取 dsh web 的启动命令行,请手动重启" };
736
+ if (/_npx|node_modules\\\.bin|node_modules\/\.bin/.test(cmdline)) {
737
+ return { ok: false, how: "", detail: "dsh web 是经 npx 启动的,无法安全复用命令行,请手动重启" };
738
+ }
739
+ const argv = parseWindowsCommandLine(cmdline);
740
+ if (!argv.length || !fs.existsSync(argv[0])) {
741
+ return { ok: false, how: "", detail: "dsh web 的启动命令无法解析为可执行程序,请手动重启" };
742
+ }
743
+ const helper = path.join(CONFIG_DIR, ".dsh-restart-dshweb.mjs");
744
+ const plan = JSON.stringify({ pid, argv, log: path.join(CONFIG_DIR, ".dsh-restart.log") });
745
+ const src = `import { spawn, spawnSync } from "node:child_process";
746
+ import { appendFileSync, existsSync, openSync, closeSync } from "node:fs";
747
+ import { setTimeout as delay } from "node:timers/promises";
748
+ const PLAN = ${plan};
749
+ function logLine(m) { try { appendFileSync(PLAN.log, "\\n[" + new Date().toISOString() + "] " + m + "\\n"); } catch (e) {} }
750
+ if (!existsSync(PLAN.argv[0])) { logLine("重启中止:可执行文件不存在 " + PLAN.argv[0]); process.exit(1); }
751
+ function alive(p) { try { process.kill(p, 0); return true; } catch (e) { return e.code === "EPERM"; } }
752
+ logLine("dsh web 重启助手启动:旧 pid=" + PLAN.pid);
753
+ await delay(1000);
754
+ try { process.kill(PLAN.pid); } catch (e) {}
755
+ const deadline = Date.now() + 30000;
756
+ while (Date.now() < deadline && alive(PLAN.pid)) await delay(200);
757
+ if (alive(PLAN.pid)) spawnSync("taskkill", ["/PID", String(PLAN.pid), "/T", "/F"], { windowsHide: true });
758
+ let out = "ignore";
759
+ try { out = openSync(PLAN.log, "a"); } catch (e) {}
760
+ const child = spawn(PLAN.argv[0], PLAN.argv.slice(1), { detached: true, stdio: ["ignore", out, out], windowsHide: true });
761
+ child.on("error", (e) => logLine("重新拉起失败:" + e.message));
762
+ child.unref();
763
+ if (typeof out === "number") { try { closeSync(out); } catch (e) {} }
764
+ logLine("已重新拉起 dsh web:pid=" + String(child.pid));
765
+ `;
766
+ try {
767
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
768
+ fs.writeFileSync(helper, src);
769
+ const child = spawn(NODE_BIN, [helper], { detached: true, stdio: "ignore", cwd: os.homedir(), windowsHide: true });
770
+ child.on("error", () => { /* 有 error 监听:绝不能把宿主/安装器打挂 */ });
771
+ child.unref();
772
+ return { ok: true, how: "Windows 重启助手(原命令行自拉起)" };
773
+ } catch (e) {
774
+ return { ok: false, how: "", detail: e.message };
775
+ }
776
+ }
777
+
778
+ /** 读某进程的完整命令行(PowerShell CIM;取不到返回空串)。 */
779
+ function windowsProcessCommandLine(pid) {
780
+ const script = `(Get-CimInstance Win32_Process -Filter "ProcessId=${Number(pid)}").CommandLine`;
781
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
782
+ try {
783
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
784
+ { encoding: "utf8", timeout: 8000, windowsHide: true });
785
+ return String(r.stdout || "").trim();
786
+ } catch { return ""; }
787
+ }
788
+
789
+ /**
790
+ * 把 Windows 命令行拆成 argv(按 CreateProcess/CRT 的引号规则)。
791
+ * 不引第三方依赖;处理 `"a b" c`、`a\b c`、`"a\"b"` 等常见形态即可。
792
+ */
793
+ function parseWindowsCommandLine(cmd) {
794
+ const out = [];
795
+ let cur = "";
796
+ let inQuote = false;
797
+ let started = false;
798
+ for (let i = 0; i < cmd.length; i += 1) {
799
+ const ch = cmd[i];
800
+ if (ch === "\\" && cmd[i + 1] === '"') { cur += '"'; i += 1; started = true; continue; }
801
+ if (ch === '"') { inQuote = !inQuote; started = true; continue; }
802
+ if (!inQuote && /\s/.test(ch)) {
803
+ if (started) { out.push(cur); cur = ""; started = false; }
804
+ continue;
805
+ }
806
+ cur += ch;
807
+ started = true;
808
+ }
809
+ if (started) out.push(cur);
810
+ return out;
811
+ }
812
+
543
813
  function installAutostart() {
544
814
  const svcPath = writeAutostartFile();
545
815
  const r = restartBridgeService();
@@ -565,6 +835,15 @@ dsh-remote:独立的本地设置页已移除。
565
835
 
566
836
  // ---------- run:前台跑 bridge(带配置 + 自启动 watcher) ----------
567
837
  async function runBridge() {
838
+ // Windows 去重:登录任务(Task Scheduler)与插件半的自愈都可能拉起 watcher,
839
+ // 两个 bridge 会抢同一个设备登记 → 这里以 pid 文件为准,已有守护在跑就直接退出。
840
+ if (IS_WIN) {
841
+ const other = readPidFile(WATCHER_PID_FILE);
842
+ if (other && other !== process.pid && pidAlive(other)) {
843
+ console.log(`[dsh-remote] 已有一个 bridge 守护在运行(pid=${other}),本进程直接退出,避免重复实例。`);
844
+ return;
845
+ }
846
+ }
568
847
  let cfg = loadConfig();
569
848
  let warnedNoLogin = false;
570
849
 
@@ -629,7 +908,13 @@ async function runBridge() {
629
908
  bridgeProc = spawn(NODE_BIN,
630
909
  [path.join(THIS_DIR, "clients/dsh-remote/dsh-bridge.mjs")],
631
910
  { env: childEnv, stdio: "inherit" });
632
- bridgeProc.on("exit", () => { console.log("[dsh-remote] bridge 退出,等待重启..."); });
911
+ bridgeProc.on("exit", () => {
912
+ removePidFile(BRIDGE_PID_FILE);
913
+ console.log("[dsh-remote] bridge 退出,等待重启...");
914
+ });
915
+ // Windows:落 pid 文件,供插件半发现进程(Windows 没有 pgrep/ps)。
916
+ // 插件半启动 bridge 走的就是本函数,所以这里写一次两边都覆盖到。
917
+ if (IS_WIN) writePidFile(BRIDGE_PID_FILE, bridgeProc.pid);
633
918
  setTimeout(() => { starting = false; }, 5000);
634
919
  } else if (!alive && bridgeProc && bridgeProc.exitCode === null) {
635
920
  console.log("[dsh-remote] dsh web 离线,停止 bridge...");
@@ -637,6 +922,16 @@ async function runBridge() {
637
922
  }
638
923
  };
639
924
 
925
+ // Windows:watcher 自身的 pid 也要落盘(插件半据此判断"守护在跑"并避免重复拉起)
926
+ if (IS_WIN) {
927
+ writePidFile(WATCHER_PID_FILE, process.pid);
928
+ const cleanup = () => { removePidFile(WATCHER_PID_FILE); removePidFile(BRIDGE_PID_FILE); };
929
+ process.on("exit", cleanup);
930
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"]) {
931
+ try { process.on(sig, () => { cleanup(); process.exit(0); }); } catch { /* 该信号在本平台不可注册 */ }
932
+ }
933
+ }
934
+
640
935
  await ensureBridge();
641
936
  setInterval(ensureBridge, 10000); // 每 10s 检查
642
937
  console.log("[dsh-remote] 守护运行中(Ctrl-C 退出)");
@@ -792,13 +1087,23 @@ async function setup(argv) {
792
1087
  const svcSkipped = st.status === "skipped" || st.status === "unsupported" || !svc.path;
793
1088
  // 第三态:起来了、但本机 launchd 不保证崩溃自愈(on-demand-only 的 gui domain 只能靠 kickstart 拉起),
794
1089
  // 必须与"完全正常"分开说 —— 否则用户以为有自启,进程一崩就永久掉线且找不到原因。
795
- const svcFragile = !svcSkipped && st.ok && st.selfHealing === false;
1090
+ // Windows 没有 launchd,这条判断对它不成立(否则会把 win32 误报成"本机不支持崩溃自愈")。
1091
+ const svcFragile = !IS_WIN && !svcSkipped && st.ok && st.selfHealing === false;
796
1092
  const svcDegraded = st.status === "degraded-detached";
797
- const svcState = svcSkipped
798
- ? (st.status === "skipped" ? "未安装(本次显式跳过)" : "未安装(当前平台不支持自启动)")
799
- : (st.ok
800
- ? `✅ 运行中${st.pid ? ` (pid=${st.pid})` : ""}${svcFragile ? ",但本机不支持崩溃自愈" : ""}`
801
- : (svcDegraded ? "⚠️ 未被系统托管(已用后台进程兜底)" : `未运行 (${st.detail || st.status})`));
1093
+ // Windows:自启动 = 任务计划程序里的登录任务。口径与 macOS/Linux 不同
1094
+ // ("任务已注册" ≠ "进程此刻在跑"),所以单独给文案,避免复用"未被系统托管"这种会误导的措辞。
1095
+ const winTask = IS_WIN && winTaskOk();
1096
+ const svcState = IS_WIN
1097
+ ? (st.status === "skipped"
1098
+ ? "未安装(本次显式跳过)"
1099
+ : (!winTask
1100
+ ? "未注册(登录任务创建失败,可用 `dsh-remote run` 手动运行 bridge)"
1101
+ : (st.ok ? `✅ 已注册,守护进程在运行${st.pid ? ` (pid=${st.pid})` : ""}` : "✅ 已注册(登录后自动运行;dsh web 打开时会立刻接上)")))
1102
+ : (svcSkipped
1103
+ ? (st.status === "skipped" ? "未安装(本次显式跳过)" : "未安装(当前平台不支持自启动)")
1104
+ : (st.ok
1105
+ ? `✅ 运行中${st.pid ? ` (pid=${st.pid})` : ""}${svcFragile ? ",但本机不支持崩溃自愈" : ""}`
1106
+ : (svcDegraded ? "⚠️ 未被系统托管(已用后台进程兜底)" : `未运行 (${st.detail || st.status})`)));
802
1107
  const L = [];
803
1108
  L.push("✅ 安装完成");
804
1109
  if (selfHosted) {
@@ -823,7 +1128,13 @@ async function setup(argv) {
823
1128
  L.push(" 如果 dsh web 已经开着但看不到本机,先重启 dsh web 让插件生效。");
824
1129
  } else if (!st.ok) {
825
1130
  L.push("");
826
- if (svcDegraded) {
1131
+ if (IS_WIN) {
1132
+ // Windows:登录任务已注册,只是此刻 bridge 守护还没就绪(dsh web 刚起、或还没登录账号)
1133
+ L.push(`ℹ bridge 守护还没就绪: ${st.detail || st.status}`);
1134
+ L.push(` 已注册登录任务「${WIN_TASK_NAME}」,下次登录会自动运行;`);
1135
+ L.push(" 也可以现在执行 `dsh-remote run` 前台运行 bridge(Ctrl-C 退出)。");
1136
+ L.push(` 日志: ${CONFIG_DIR}\\.dsh-bridge.log`);
1137
+ } else if (svcDegraded) {
827
1138
  // launchd 托管失败但进程活着:能立刻用,只是没有自启/自愈。如实说明,别让用户以为有自启。
828
1139
  L.push(`⚠️ ${st.detail}`);
829
1140
  L.push(` bridge 已在后台运行${st.pid ? `(pid=${st.pid})` : ""},现在就能用;`);
@@ -1163,24 +1474,143 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
1163
1474
  }
1164
1475
 
1165
1476
  /** 把插件挂到 <profile>/node_modules/<PLUGIN_ID>(裸包名解析需要),指向拷贝目录;缺失/指错时重建。 */
1477
+ /**
1478
+ * 判定 `node_modules/<PLUGIN_ID>` 是否**真的可用**。
1479
+ *
1480
+ * 判据绝不是"函数有没有抛错" —— 这是 0.6.7-beta.1 在 Windows 上翻车的根因:
1481
+ * 非特权进程 + 开发者模式关闭时,`fs.symlinkSync(target, path, "dir")` 会**既不抛错也产不出可用链接**
1482
+ * (实测:用户目录下留下一个空目录;%TEMP% 下什么都不留;同一位置两次运行一次报成功一次抛 UNKNOWN)。
1483
+ * 于是"用 try/catch 探测能力"这个前提在 Windows 上根本不成立,写在其 catch 里的 junction 兜底永不执行。
1484
+ *
1485
+ * 这里只认 dsh 自己那条解析路径能不能走通:
1486
+ * ① 读得到 package.json 且 name 正确(cordis 加载器 + @deepseek-ai/dsh-client-modules 靠它读 dsh.client);
1487
+ * ② 读得到 lib/index.js(节点半入口);
1488
+ * ③ 要么 realpath 指回本地拷贝目录(链接形态),要么入口内容与本地拷贝逐字节一致(整目录拷贝形态)。
1489
+ */
1490
+ function pluginLinkUsable(nmPlugin, pluginLocalDir) {
1491
+ try {
1492
+ const pkg = JSON.parse(fs.readFileSync(path.join(nmPlugin, "package.json"), "utf8"));
1493
+ if (pkg.name !== PLUGIN_ID) return false;
1494
+ if (!fs.existsSync(path.join(nmPlugin, "lib", "index.js"))) return false;
1495
+ } catch { return false; }
1496
+
1497
+ let real = "";
1498
+ try { real = fs.realpathSync(nmPlugin); } catch { /* 悬空链接 */ }
1499
+ if (real) {
1500
+ try { if (real === fs.realpathSync(pluginLocalDir)) return true; } catch { /* 本地目录不在 → 看内容 */ }
1501
+ }
1502
+ try {
1503
+ return fs.readFileSync(path.join(nmPlugin, "lib", "index.js"), "utf8")
1504
+ === fs.readFileSync(path.join(pluginLocalDir, "lib", "index.js"), "utf8");
1505
+ } catch { return false; }
1506
+ }
1507
+
1508
+ /**
1509
+ * 把插件挂到 `<profile>/node_modules/<PLUGIN_ID>`。
1510
+ *
1511
+ * 规格:已就绪返回 false(幂等)、重建成功返回 true、**任何情况下都没能挂好就抛错**。
1512
+ * 抛错是刻意的 —— 调用方必须据此中止安装:写进 patch 的激活行会让 dsh web 按裸包名解析这个目录,
1513
+ * 解析不到就 `Cannot find package '...\node_modules\dsh-remote-web\index.js'`,
1514
+ * 而 cordis 的插件树加载失败 = **整个 dsh web 起不来**(用户连面板都打不开,没有自助修复入口)。
1515
+ * 宁可不装,也不能写出一个起不来的 profile。
1516
+ */
1166
1517
  function ensurePluginLinked(profileDir, pluginLocalDir) {
1167
1518
  const nmDir = path.join(profileDir, "node_modules");
1168
1519
  const nmPlugin = path.join(nmDir, PLUGIN_ID);
1520
+ if (!fs.existsSync(path.join(pluginLocalDir, "package.json"))) {
1521
+ throw new Error(`本地插件拷贝缺失或不完整:${pluginLocalDir}(应含 package.json)`);
1522
+ }
1169
1523
  fs.mkdirSync(nmDir, { recursive: true });
1170
1524
  // 清理历史名链接(node_modules/dsh-remote-ui),避免解析到旧拷贝
1171
1525
  for (const id of PLUGIN_LEGACY_IDS) {
1172
1526
  try { fs.rmSync(path.join(nmDir, id), { recursive: true, force: true }); } catch { /* ignore */ }
1173
1527
  }
1528
+ if (pluginLinkUsable(nmPlugin, pluginLocalDir)) return false; // 已就绪(拷贝形态也算就绪)
1529
+
1530
+ // ⚠️ Windows 上**不要用 symlinkSync(...,'dir')**:非特权进程下它产不出可用链接,
1531
+ // 而且抛不抛错不确定、失败时还会留下空目录残留 —— 兜底链会因此整条失效(实测)。
1532
+ // junction(目录联接)免特权、realpath 正确、require.resolve 与 ESM 裸包名解析都正常。
1533
+ const attempts = process.platform === "win32"
1534
+ ? [
1535
+ ["junction", () => fs.symlinkSync(path.resolve(pluginLocalDir), nmPlugin, "junction")], // junction 只接受绝对路径
1536
+ ["copy", () => fs.cpSync(pluginLocalDir, nmPlugin, { recursive: true })],
1537
+ ]
1538
+ : [
1539
+ ["dir", () => fs.symlinkSync(pluginLocalDir, nmPlugin, "dir")],
1540
+ ["copy", () => fs.cpSync(pluginLocalDir, nmPlugin, { recursive: true })],
1541
+ ];
1542
+
1543
+ const failures = [];
1544
+ for (const [how, run] of attempts) {
1545
+ // 每支开始前先清残留:上一支"没抛错但留了个空目录"时,不清就会让下一支撞 EEXIST 而失败
1546
+ //(这正是原实现三级兜底退化成一级的原因)。
1547
+ try { fs.rmSync(nmPlugin, { recursive: true, force: true }); } catch { /* ignore */ }
1548
+ let err = null;
1549
+ try { run(); } catch (e) { err = e; }
1550
+ if (pluginLinkUsable(nmPlugin, pluginLocalDir)) return true;
1551
+ failures.push(`${how}: ${err ? err.message : "未抛错但落盘结果不可用(读不到 package.json / lib/index.js)"}`);
1552
+ }
1553
+ throw new Error(`插件挂载失败:${nmPlugin}\n ${failures.join("\n ")}`);
1554
+ }
1555
+
1556
+
1557
+ /**
1558
+ * 独立验收:复刻 dsh 客户端模块系统的真实动作 —— 从 profile 目录按**裸包名**解析到本插件。
1559
+ *
1560
+ * 与 pluginLinkUsable 是两道独立的闸门:前者看文件在不在、是不是真指向目标;
1561
+ * 这条走 Node 的解析器(exports 字段、symlink 跟随、pnpm 布局),是"面板能不能被注入"的前提
1562
+ * (@deepseek-ai/dsh-client-modules 就是 require.resolve('<name>/package.json') 读 dsh.client)。
1563
+ */
1564
+ function probePluginResolve(profileDir) {
1565
+ const base = path.join(profileDir, "__dsh_resolve_probe.cjs");
1566
+ // 必须**另起一个 node 进程**做这次解析,不能在本进程里 require.resolve:
1567
+ // 实测(Node 25 / macOS):本进程里只要先失败过一次裸包名解析(例如"修复前诊断"那次),
1568
+ // 链接随后修好了,同一个进程里的裸包名解析**仍会持续失败**(子路径却正常)——
1569
+ // 进程内的解析缓存会把假阴性一直带下去,让"修复后验收"误判成没修好、拒绝恢复激活。
1570
+ // 另起进程同时也更忠实:dsh 启动时就是在一个全新进程里解析这些裸包名的。
1571
+ const script = [
1572
+ 'const { createRequire } = require("node:module");',
1573
+ `const req = createRequire(${JSON.stringify(base)});`,
1574
+ "process.stdout.write(JSON.stringify({",
1575
+ ` pkgPath: req.resolve(${JSON.stringify(`${PLUGIN_ID}/package.json`)}),`,
1576
+ ` entry: req.resolve(${JSON.stringify(PLUGIN_ID)})`,
1577
+ "}));",
1578
+ ].join("\n");
1174
1579
  try {
1175
- if (fs.realpathSync(nmPlugin) === pluginLocalDir) return false;
1176
- } catch { /* 缺失/悬空 → 重建 */ }
1177
- try { fs.rmSync(nmPlugin, { recursive: true, force: true }); } catch { /* ignore */ }
1178
- try {
1179
- fs.symlinkSync(pluginLocalDir, nmPlugin, "dir");
1180
- } catch {
1181
- fs.cpSync(pluginLocalDir, nmPlugin, { recursive: true });
1580
+ const r = spawnSync(process.execPath, ["-e", script], { encoding: "utf8", timeout: 30_000, windowsHide: true });
1581
+ if (r.error) return { ok: false, reason: `无法启动解析探测进程:${r.error.message}` };
1582
+ if (r.status !== 0) {
1583
+ const line = String(r.stderr || "").split("\n").find((l) => /MODULE_NOT_FOUND|ERR_|Error/.test(l)) || "解析失败";
1584
+ return { ok: false, reason: line.trim().slice(0, 200) };
1585
+ }
1586
+ const out = JSON.parse(String(r.stdout || "{}"));
1587
+ const pkg = JSON.parse(fs.readFileSync(out.pkgPath, "utf8"));
1588
+ return {
1589
+ ok: true,
1590
+ pkgPath: out.pkgPath,
1591
+ entry: out.entry,
1592
+ name: pkg.name,
1593
+ clientPlatform: (pkg.dsh && pkg.dsh.client && pkg.dsh.client.platform) || "",
1594
+ };
1595
+ } catch (e) {
1596
+ return { ok: false, reason: `${e.code || e.name}: ${e.message}` };
1182
1597
  }
1183
- try { return fs.realpathSync(nmPlugin) === pluginLocalDir; } catch { return false; }
1598
+ }
1599
+
1600
+ /**
1601
+ * 描述插件在 profile 里的挂载形态(供 status / repair 输出)。
1602
+ * 用户报障时这三行就能区分"链接坏了"与"别的毛病"——不必再靠猜。
1603
+ * @returns {{localExists:boolean, exists:boolean, usable:boolean, kind:"missing"|"broken"|"linked"|"copy"|"no-local-copy"}}
1604
+ */
1605
+ function describePluginLink(profileDir) {
1606
+ const nmPlugin = path.join(profileDir, "node_modules", PLUGIN_ID);
1607
+ const localDir = path.join(profileDir, PLUGIN_LOCAL_DIR);
1608
+ const localExists = fs.existsSync(path.join(localDir, "package.json"));
1609
+ if (!fs.existsSync(nmPlugin)) return { localExists, exists: false, usable: false, kind: localExists ? "missing" : "no-local-copy" };
1610
+ const usable = pluginLinkUsable(nmPlugin, localDir);
1611
+ let isLink = false;
1612
+ try { isLink = fs.lstatSync(nmPlugin).isSymbolicLink(); } catch { /* ignore */ }
1613
+ return { localExists, exists: true, usable, kind: usable ? (isLink ? "linked" : "copy") : "broken" };
1184
1614
  }
1185
1615
 
1186
1616
  /** package.json 写入 file: 依赖(让后续任何 pnpm/npm install 也认可该插件,不会删链接)。 */
@@ -1244,8 +1674,26 @@ function activateLocalCopy(profileDir, pkgFile, patchFile, pluginDir, patch, pkg
1244
1674
  console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
1245
1675
  process.exit(1);
1246
1676
  }
1677
+ // ⚠️ 顺序与"失败即中止"都是刻意的(0.6.7-beta.1 Windows 实测事故):
1678
+ // 老代码把 ensurePluginLinked 的返回值丢掉、patch 照写、还打印「✅ 插件已就绪」,
1679
+ // 于是用户拿到的是**起不来的 dsh web**(cordis 按裸包名解析到空目录 → plugin tree failed to load,
1680
+ // 整个 dsh web 启动失败,而面板/插件市场都在那个进程里 → 用户没有任何自助修复入口)。
1681
+ // 现在:先挂载 → 独立验收 → 才写依赖与激活行;任何一步不过就中止,绝不留下坏 profile。
1682
+ try {
1683
+ ensurePluginLinked(profileDir, pluginLocalDir); // node_modules 链接(name 才能被解析)
1684
+ } catch (e) {
1685
+ console.error(`❌ ${e.message}`);
1686
+ console.error(" 已中止安装:继续写 patch 会让 dsh web 因插件树加载失败而完全无法启动。");
1687
+ console.error(" 请把上面的错误整段反馈给作者(含平台 / 是否管理员 / 开发者模式是否开启)。");
1688
+ process.exit(1);
1689
+ }
1690
+ const probe = probePluginResolve(profileDir);
1691
+ if (!probe.ok) {
1692
+ console.error(`❌ 验收失败:dsh 无法从 profile 按裸包名解析 ${PLUGIN_ID}(${probe.reason})`);
1693
+ console.error(" 已中止安装(未写 patch):否则 dsh web 会因插件树加载失败而完全无法启动。");
1694
+ process.exit(1);
1695
+ }
1247
1696
  declarePluginDep(pkgFile); // file: 依赖(包管理器 install 不误删)
1248
- ensurePluginLinked(profileDir, pluginLocalDir); // node_modules 链接(name 才能被解析)
1249
1697
  const r = ensurePatchActivation(patchFile, pluginLocalDir, pkgFile, { forcePatch: !!opts.forcePatch });
1250
1698
  if (r.ok) {
1251
1699
  console.log(`✅ 插件已就绪: ${pluginLocalDir}${opts.forcePatch ? "(激活点已转为 patch 行,装完即热加载)" : ""}`);
@@ -1282,6 +1730,34 @@ function readInstalledPluginVersion(profileDir, name) {
1282
1730
  } catch { return null; }
1283
1731
  }
1284
1732
 
1733
+ /**
1734
+ * 识别 profile 用哪个包管理器(市场装法「一键更新」要用它改源码)。
1735
+ *
1736
+ * ⚠️ 不能用 `sh("command -v pnpm && echo pnpm || echo npm")`:`sh()` 走 execSync,
1737
+ * Windows 下是 cmd.exe,而 `command` 是 POSIX 内建 → 永远命中 `||` 分支,
1738
+ * **Windows 上永远选 npm**。而 dsh profile 是 pnpm 管理的(pnpm-workspace.yaml + 虚拟 store),
1739
+ * 用 npm 去改同一个 node_modules 会写出与 pnpm 不同的布局(污染/冲突风险)。
1740
+ * 现在:先用**声明文件**判断(最可靠、零子进程),再用 where/which 探测可执行文件;
1741
+ * 兜底选 pnpm 而不是 npm —— dsh profile 默认就是 pnpm,选错方向的代价不对称。
1742
+ */
1743
+ function detectPackageManager(profileDir) {
1744
+ try {
1745
+ if (fs.existsSync(path.join(profileDir, "pnpm-lock.yaml"))
1746
+ || fs.existsSync(path.join(profileDir, "pnpm-workspace.yaml"))) return "pnpm";
1747
+ if (fs.existsSync(path.join(profileDir, "package-lock.json"))) return "npm";
1748
+ } catch { /* 读不到就往下探测 */ }
1749
+ const has = (bin) => {
1750
+ try {
1751
+ return process.platform === "win32"
1752
+ ? spawnSync("where", [bin], { stdio: "ignore", windowsHide: true }).status === 0
1753
+ : spawnSync("sh", ["-c", `command -v ${bin}`], { stdio: "ignore" }).status === 0;
1754
+ } catch { return false; }
1755
+ };
1756
+ if (has("pnpm")) return "pnpm";
1757
+ if (has("npm")) return "npm";
1758
+ return "pnpm";
1759
+ }
1760
+
1285
1761
  /**
1286
1762
  * 市场装法下把插件包升到最新。
1287
1763
  *
@@ -1295,7 +1771,7 @@ function readInstalledPluginVersion(profileDir, name) {
1295
1771
  function upgradeMarketManagedPlugin(profileDir, name, dep) {
1296
1772
  const spec = String(dep ?? "").trim();
1297
1773
  const isRegistryRange = /^[\^~><=v\d]/.test(spec);
1298
- const pm = sh("command -v pnpm >/dev/null 2>&1 && echo pnpm || echo npm").stdout.trim() || "npm";
1774
+ const pm = detectPackageManager(profileDir);
1299
1775
  const args = isRegistryRange ? `add ${name}@latest` : `update ${name}`;
1300
1776
  const cmd = `${pm} ${args}`;
1301
1777
  const r = sh(cmd, 180000, profileDir);
@@ -1414,8 +1890,90 @@ async function pluginCmd(argv) {
1414
1890
  // 引导语只在 setup 汇总里打印一次(这里不再重复;单独跑 `dsh-remote plugin` 也无需引导)
1415
1891
  }
1416
1892
 
1893
+ /**
1894
+ * 打印插件挂载诊断三行(供 status / repair 共用)。
1895
+ * 用户报障时这三行就能区分「链接坏了」与「别的毛病」,不必靠猜:
1896
+ * link = 有没有真挂上(junction / 符号链接 / 整目录拷贝 / BROKEN)
1897
+ * resolve = dsh 的裸包名解析能不能走通(浏览器半注入面板就靠它)
1898
+ * client = dsh.client.platform 声明在不在
1899
+ */
1900
+ function printPluginDiagnostics(profileDir) {
1901
+ const nmPlugin = path.join(profileDir, "node_modules", PLUGIN_ID);
1902
+ const link = describePluginLink(profileDir);
1903
+ const probe = probePluginResolve(profileDir);
1904
+ const kindText = { linked: "已挂载(链接)", copy: "已挂载(整目录拷贝)", broken: "BROKEN(存在但读不到内容)", missing: "缺失", "no-local-copy": "缺失(本地插件目录也没有)" }[link.kind] || link.kind;
1905
+ console.log(` [link] ${nmPlugin}\n → ${kindText}`);
1906
+ console.log(` [resolve] ${PLUGIN_ID}/package.json → ${probe.ok ? probe.pkgPath : `失败(${probe.reason})`}`);
1907
+ console.log(` [client] dsh.client.platform = ${probe.ok ? (probe.clientPlatform || "缺失") : "(解析失败,无法确认)"}`);
1908
+ return { link, probe };
1909
+ }
1910
+
1911
+ /**
1912
+ * `dsh-remote repair` —— 只修 profile 里的插件挂载,不碰运行环境 / 不联网 / 不碰自启动。
1913
+ *
1914
+ * 为什么需要这个独立入口:插件挂载坏掉时 dsh web **整个起不来**(cordis 插件树加载失败),
1915
+ * 而设置面板与插件市场都在那个进程里 —— 用户没有任何自助修复入口,只剩命令行。
1916
+ * 执行顺序刻意是"先保命、再修复、最后恢复激活":
1917
+ * ① patch 里引用了本插件但挂载不可用 → **先把 include 摘掉**(profile 立刻恢复可启动);
1918
+ * ② 再重建挂载(失败会抛错,但①的结果保留 → 用户至少能打开 dsh web);
1919
+ * ③ 只有①②都成功,才把激活行写回去。
1920
+ */
1921
+ function repairProfile(argv = []) {
1922
+ const profileDir = resolveProfileDir(argv);
1923
+ const pkgFile = path.join(profileDir, "package.json");
1924
+ const patchFile = path.join(profileDir, "cordis.patch.yml");
1925
+ const localDir = path.join(profileDir, PLUGIN_LOCAL_DIR);
1926
+ if (!fs.existsSync(pkgFile) || !fs.existsSync(patchFile)) {
1927
+ console.error(`❌ 未找到 dsh web profile(${profileDir})。`);
1928
+ console.error(" 先确认 dsh web 装好并初始化过 profile,或用 --profile <目录> 指定。");
1929
+ process.exit(1);
1930
+ }
1931
+ console.log(`dsh-remote repair — ${profileDir}`);
1932
+ const before = printPluginDiagnostics(profileDir);
1933
+ if (!before.link.localExists) {
1934
+ console.error(`❌ 本地插件目录缺失(${localDir}):无法就地修复。`);
1935
+ console.error(" 请重新安装:npx @mrrisega/dsh-remote@latest");
1936
+ process.exit(1);
1937
+ }
1938
+
1939
+ const patch = fs.readFileSync(patchFile, "utf8");
1940
+ const referenced = parsePatchInsertIds(patch).ids.some((id) => PLUGIN_ALL_IDS.includes(id));
1941
+ if (referenced && !before.link.usable) {
1942
+ // 保命动作:先摘掉激活行,让 dsh web 至少能启动(此时插件不可用,但用户进得去、能重装)
1943
+ stripIncludeEntries(patchFile, patch, "挂载不可用,先摘掉 include 保命");
1944
+ try { removeBundleEntry(pkgFile); } catch { /* ignore */ }
1945
+ console.log("🛟 挂载不可用 → 已先摘掉插件激活行(dsh web 现在应该能启动了)。");
1946
+ }
1947
+
1948
+ try {
1949
+ ensurePluginLinked(profileDir, localDir);
1950
+ } catch (e) {
1951
+ console.error(`❌ 重建挂载失败:${e.message}`);
1952
+ console.error(" profile 已处于「能启动、插件未激活」状态;请把上面错误反馈给作者。");
1953
+ process.exit(1);
1954
+ }
1955
+ const after = printPluginDiagnostics(profileDir);
1956
+ if (!after.probe.ok) {
1957
+ console.error("❌ 挂载已重建,但 dsh 仍解析不到本插件 —— 请把上面的输出反馈给作者。");
1958
+ process.exit(1);
1959
+ }
1960
+ const srcPluginDir = path.join(THIS_DIR, "packages", PLUGIN_ID);
1961
+ if (!fs.existsSync(srcPluginDir)) {
1962
+ console.error(`❌ 本包缺少 packages/${PLUGIN_ID},无法就地恢复激活行。`);
1963
+ console.error(" 请重新安装:npx @mrrisega/dsh-remote@latest");
1964
+ process.exit(1);
1965
+ }
1966
+ convergePluginActivation(profileDir, pkgFile, patchFile, srcPluginDir, fs.readFileSync(patchFile, "utf8"));
1967
+ console.log("✅ 挂载与激活已修复 —— 重启一次 dsh web 生效。");
1968
+ }
1969
+
1417
1970
  // ---------- main ----------
1418
- // 无命令名(或首个参数以 - 开头)时默认执行 setup
1971
+ // 无命令名(或首个参数以 - 开头)时默认执行 setup —— 这样 `dsh-remote --no-autostart`
1972
+ // 这类「只给 flag」的用法才能装上去。
1973
+ //
1974
+ // 但有两个例外必须挡在前面:
1975
+ // ① -h/--help:用户想先看用法,绝不该顺手把东西装上(此前会走 setup,直接安装);
1976
+ // ② 不认识的 flag:拼错参数时静默安装比报错更糟,宁可停下来说清楚。
1419
1977
  const raw = process.argv[2];
1420
1978
  const cmd = raw && !raw.startsWith("-") ? raw : "setup";
1421
1979
  const args = raw && !raw.startsWith("-") ? process.argv.slice(3) : process.argv.slice(2);
@@ -1423,6 +1981,7 @@ if (cmd === "setup" || cmd === "install") await setup(args);
1423
1981
  else if (cmd === "settings") settingsHint();
1424
1982
  else if (cmd === "run") await runBridge();
1425
1983
  else if (cmd === "plugin") await pluginCmd(process.argv.slice(3));
1984
+ else if (cmd === "repair") repairProfile(process.argv.slice(3));
1426
1985
  else if (cmd === "status") {
1427
1986
  const cfg = loadConfig();
1428
1987
  const local = Boolean(cfg.local_key);
@@ -1430,22 +1989,15 @@ else if (cmd === "status") {
1430
1989
  console.log("连接模式:", local ? `自建服务(${cfg.tunnel_url || "未设置服务器地址"})` : `SaaS 云端服务(${cfg.phone || "未配置账号"})`);
1431
1990
  console.log("API:", cfg.api_url || (local ? "(自建模式无需账号 API)" : DEFAULT_API));
1432
1991
  console.log("远程地址/登录: 打开 dsh web → 设置 → 「远程控制」查看与操作");
1992
+ // 插件挂载诊断:报障时一眼看出是"链接坏了"还是"别的毛病"
1993
+ const profileDir = resolveProfileDir([]);
1994
+ if (fs.existsSync(path.join(profileDir, "package.json"))) {
1995
+ console.log("插件挂载:");
1996
+ printPluginDiagnostics(profileDir);
1997
+ } else {
1998
+ console.log(`插件挂载: 未找到 profile(${profileDir})`);
1999
+ }
1433
2000
  } else {
1434
- console.log(`dsh-remote — 手机远程控制 dsh web(隧道模式)
1435
-
1436
- 用法:
1437
- dsh-remote 一键安装(默认命令,无需任何参数;含插件与自启动)
1438
- dsh-remote settings 显示登录/连接配置指引(独立设置页已移除)
1439
- dsh-remote run 前台运行 bridge(调试)
1440
- dsh-remote status 查看配置与服务状态
1441
- dsh-remote plugin 手动安装 dsh web 远程控制插件(--uninstall 卸载)
1442
-
1443
- 自建模式(可选):
1444
- dsh-remote setup --server wss://你的域名:端口 --key 访问密钥
1445
-
1446
- 登录/连接配置: 打开 dsh web → 设置 → 「远程控制」→ 注册或登录手机号即可
1447
- (自建用户切「自建服务」标签或直接用上方 setup 命令,无需另开页面)。
1448
- 文档: ${REPO_URL}
1449
- `);
1450
- process.exit(cmd === "help" ? 0 : 1);
2001
+ printHelp();
2002
+ process.exit(1);
1451
2003
  }