@mrrisega/dsh-remote 0.6.4-beta.6 → 0.6.4-beta.8

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
@@ -32,8 +32,13 @@ import { childStopped } from "./clients/dsh-remote/src/lifecycle.mjs";
32
32
 
33
33
  const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); // 本包目录(仓库或 node_modules)
34
34
  const IS_NPM_INSTALL = THIS_DIR.includes(`${path.sep}node_modules${path.sep}`);
35
- // 配置目录:npm 安装时放在用户目录(node_modules 内不可写);仓库开发时放在仓库根。
36
- const CONFIG_DIR = process.env.DSH_RELAY_DIR || (IS_NPM_INSTALL ? path.join(os.homedir(), ".dsh-remote") : THIS_DIR);
35
+ // 配置目录:**两种形态统一放 ~/.dsh-remote**(npx/npm 安装时 node_modules 内不可写,故放用户目录;
36
+ // 仓库开发时也放同一处)。为什么不按形态分叉:插件半的默认配置目录本来就是 ~/.dsh-remote
37
+ // (见 packages/dsh-remote-web/lib/index.js 的 DEFAULT_RELAY_DIR),而激活块会把这里写进 relayDir。
38
+ // 两边不一致会出现「安装器把 .dsh-config.json 写进仓库根,插件却去 ~/.dsh-remote 找配置」→
39
+ // 面板显示成未登录/空配置,且安装器的状态文件散落进仓库工作区(实测踩到)。
40
+ // 需要隔离(测试 / 多账号 / 多实例)时用 DSH_RELAY_DIR 显式覆盖。
41
+ const CONFIG_DIR = process.env.DSH_RELAY_DIR || path.join(os.homedir(), ".dsh-remote");
37
42
  const CONFIG_PATH = path.join(CONFIG_DIR, ".dsh-config.json");
38
43
  // 默认云端服务地址(服务商 SaaS 入口;自建用户用 --server/--key 指向自己的 router)
39
44
  const DEFAULT_API = "https://n.risegao.cn:13443/relay-api";
@@ -414,6 +419,82 @@ async function isDshWebUp(timeoutMs = 1200) {
414
419
  } catch { return false; }
415
420
  }
416
421
 
422
+ /**
423
+ * 解析 dsh web profile 目录(安装与插件管理共用同一口径)。
424
+ */
425
+ function resolveProfileDir(argv = []) {
426
+ const i = argv.indexOf("--profile");
427
+ return i > -1 && argv[i + 1]
428
+ ? argv[i + 1]
429
+ : process.env.DSH_PROFILE_DIR || path.join(os.homedir(), ".dsh", "profiles", "web");
430
+ }
431
+
432
+ /** 监听 127.0.0.1:3080 的进程 pid(= dsh web)。lsof 不可用时回退 pgrep。 */
433
+ function dshWebPid() {
434
+ const l = sh("lsof -nP -iTCP:3080 -sTCP:LISTEN -t 2>/dev/null || true");
435
+ const pid = String(l.stdout || "").trim().split(/\s+/).filter(Boolean)[0];
436
+ if (pid && /^\d+$/.test(pid)) return Number(pid);
437
+ const p = sh("pgrep -f 'dsh web' 2>/dev/null | head -1");
438
+ const p2 = String(p.stdout || "").trim();
439
+ return /^\d+$/.test(p2) ? Number(p2) : null;
440
+ }
441
+
442
+ /**
443
+ * 进程启动时刻(毫秒)。
444
+ * 用 `ps -o lstart=`(如 "Sun Sep 13 13:54:43 2026")——它**不含年份以外的 TZ 差异**,
445
+ * 比 `ps -o etimes=` 稳(后者在部分平台不可用),比 /proc 通用。
446
+ */
447
+ function processStartMs(pid) {
448
+ try {
449
+ const r = sh(`ps -o lstart= -p ${Number(pid)}`);
450
+ const t = Date.parse(String(r.stdout || "").trim());
451
+ return Number.isFinite(t) ? t : 0;
452
+ } catch { return 0; }
453
+ }
454
+
455
+ /**
456
+ * 重启 dsh web。
457
+ * dsh web 由**用户手工或 launchd** 跑着,插件是进程启动时装载的,所以更新插件后必须重启它才有面板。
458
+ * 阶梯:launchd job(macOS)→ systemd user unit(Linux)→ 原命令行自拉起 → 交给用户手动。
459
+ */
460
+ function dshWebLaunchdJob() {
461
+ const l = sh("launchctl list 2>/dev/null | grep -i dsh | grep -v dshremote || true");
462
+ for (const line of String(l.stdout || "").split("\n")) {
463
+ const label = line.trim().split(/\s+/).pop();
464
+ if (label && /dsh/i.test(label)) return label;
465
+ }
466
+ return null;
467
+ }
468
+
469
+ function restartDshWeb() {
470
+ const uid = process.getuid();
471
+ if (process.platform === "darwin") {
472
+ const job = dshWebLaunchdJob();
473
+ if (job) {
474
+ const r = sh(`launchctl kickstart -k gui/${uid}/${job}`);
475
+ if (r.ok) return { ok: true, how: `launchctl kickstart ${job}` };
476
+ }
477
+ }
478
+ if (process.platform === "linux") {
479
+ const r = sh("systemctl --user restart dsh-web 2>/dev/null || systemctl --user restart dsh 2>/dev/null");
480
+ if (r.ok) return { ok: true, how: "systemctl --user restart dsh-web" };
481
+ }
482
+ // 兜底:沿用原命令行重启(只在能拿到真实 node 入口时做,npx 缓存路径不可靠)
483
+ const pid = dshWebPid();
484
+ if (!pid) return { ok: false, how: "", detail: "未找到 dsh web 进程" };
485
+ const cmd = String(sh(`ps -o command= -p ${pid}`).stdout || "").trim();
486
+ if (!cmd || /node_modules\/\.bin|_npx/.test(cmd)) {
487
+ return { ok: false, how: "", detail: "无法获取可复用的启动命令(可能是经 npx 启动),请手动重启" };
488
+ }
489
+ try {
490
+ const child = spawn("/bin/sh", ["-c", cmd], { detached: true, stdio: "ignore", cwd: os.homedir() });
491
+ child.unref();
492
+ return { ok: true, how: "原命令行重新拉起" };
493
+ } catch (e) {
494
+ return { ok: false, how: "", detail: e.message };
495
+ }
496
+ }
497
+
417
498
  function installAutostart() {
418
499
  const svcPath = writeAutostartFile();
419
500
  const r = restartBridgeService();
@@ -523,6 +604,7 @@ async function setup(argv) {
523
604
  const key = argValue(argv, "--key");
524
605
  const noAutostart = hasFlag(argv, "--no-autostart");
525
606
  const noPlugin = hasFlag(argv, "--no-plugin");
607
+ const noRestart = hasFlag(argv, "--no-restart");
526
608
  const selfHosted = Boolean(server || key);
527
609
 
528
610
  let cfg = loadConfig();
@@ -580,16 +662,65 @@ async function setup(argv) {
580
662
  const st = svc.status;
581
663
 
582
664
  // 自动安装 dsh web 插件(非致命:失败只提示,不阻断安装)
665
+ let pluginResult = null;
583
666
  if (!noPlugin) {
584
667
  try {
585
- await pluginCmd([]);
668
+ pluginResult = await pluginCmd([]);
586
669
  } catch (e) {
587
670
  console.warn(`⚠️ 插件安装未完成:${e.message}`);
588
671
  }
589
672
  }
590
673
 
591
- const pub = await fetchPublicConfig(cfg.api_url || DEFAULT_API);
674
+ // 插件是**进程启动时**装载的:刚装进 profile 的插件,运行中的 dsh web 里并没有
675
+ // (既没有 /dsh-remote/* 路由,也没有「设置 → 远程控制」面板项)。
676
+ // 这里做一个确定性判断:进程启动时间早于插件落盘时间 → 必须重启 dsh web 才生效。
677
+ const pluginDir = path.join(resolveProfileDir(argv), PLUGIN_LOCAL_DIR);
678
+ const pluginMtime = fs.existsSync(path.join(pluginDir, "lib", "index.js"))
679
+ ? fs.statSync(path.join(pluginDir, "lib", "index.js")).mtimeMs
680
+ : 0;
681
+ let webPid = null;
682
+ let needRestart = false;
683
+ let hotMounted = false;
592
684
  const webUp = await isDshWebUp();
685
+ if (webUp) {
686
+ webPid = dshWebPid();
687
+ const startedAt = webPid ? processStartMs(webPid) : 0;
688
+ needRestart = Boolean(pluginMtime && startedAt && startedAt < pluginMtime);
689
+ // patch 激活形态:harness 的 HMR 监听 profile patch 文件,存盘后约 1 秒重新 compose。
690
+ // 所以这里**先等热挂载**,等到了就完全不需要重启(用户只需刷新页面拿浏览器半)。
691
+ if (needRestart && pluginResult && pluginResult.hotPatch) {
692
+ // HMR 通常 1~3 秒完成,但插件节点半还要起服务、注册路由,机器繁忙时会到十几秒;
693
+ // 给足 ~30 秒窗口,避免"其实已经热挂载成功、却报成需要重启"的误报(实测踩到)。
694
+ for (let i = 0; i < 45; i += 1) {
695
+ const r = await fetch("http://127.0.0.1:3080/dsh-remote/status", { signal: AbortSignal.timeout(900) })
696
+ .catch(() => null);
697
+ if (r && r.ok) { hotMounted = true; break; }
698
+ sleepSync(700);
699
+ }
700
+ if (hotMounted) needRestart = false;
701
+ }
702
+ }
703
+ // 需要就用**最省事的方式**替用户重启:插件装完还要用户自己琢磨怎么重启,是这一步唯一的手工负担
704
+ let restartResult = null;
705
+ if (needRestart && !noRestart && webUp) {
706
+ console.log("");
707
+ console.log("ℹ 检测到运行中的 dsh web 早于本次插件安装 —— 插件只在进程启动时装载,");
708
+ console.log(" 所以「设置 → 远程控制」面板暂时还没出现。正在为你重启 dsh web(2 秒后执行,Ctrl-C 可跳过)…");
709
+ sleepSync(2000);
710
+ restartResult = restartDshWeb();
711
+ if (restartResult.ok) {
712
+ // 等它回来再确认端口确实在听(避免"重启完其实没起来"还要用户自己发现)
713
+ for (let i = 0; i < 20; i += 1) {
714
+ if (await isDshWebUp(800)) break;
715
+ sleepSync(700);
716
+ }
717
+ }
718
+ console.log(restartResult.ok
719
+ ? `✅ 已重启 dsh web(${restartResult.how})`
720
+ : `⚠️ 自动重启失败: ${restartResult.detail}`);
721
+ }
722
+
723
+ const pub = await fetchPublicConfig(cfg.api_url || DEFAULT_API);
593
724
  // 服务状态字符串是给插件面板解析的稳定契约(running / 未运行),不要改口径。
594
725
  // 三分支:running / 未运行(有原因) / 未安装(跳过或平台不支持)——最后一种既不能显示"运行中",
595
726
  // 也不能显示"未运行(原因)",它压根没装(此前会打出自相矛盾的"(当前平台不支持) — ✅ 运行中")。
@@ -646,6 +777,27 @@ async function setup(argv) {
646
777
  if (selfHosted) {
647
778
  L.push("");
648
779
  L.push(" 下一步: 手机端用访问密钥登录即可(bridge 登录后自动启动)。");
780
+ } else if (hotMounted) {
781
+ L.push("");
782
+ L.push(" 下一步: 插件已热加载(无需重启 dsh web)——刷新一下浏览器页面,");
783
+ L.push(" 再打开 设置 → 「远程控制」→ 注册/登录手机号即可。");
784
+ } else if (needRestart && restartResult && restartResult.ok) {
785
+ L.push("");
786
+ L.push(" 下一步: dsh web 已重启,直接打开 http://127.0.0.1:3080 → 设置 → 「远程控制」→ 注册/登录手机号。");
787
+ } else if (needRestart && pluginResult && pluginResult.hotPatch && pluginResult.changed) {
788
+ // patch 已写入但探测窗口内没等到面板接口:热加载很可能还在进行(插件节点半启动+注册路由)。
789
+ // 这种情况**不能**直接让用户重启 —— 先让他等几秒刷新,再给兜底方案。
790
+ L.push("");
791
+ L.push("ℹ 插件已按热加载方式登记(patch 已写入),若「设置 → 远程控制」还没出现:");
792
+ L.push(" 等几秒后刷新页面即可;仍未出现再重启一次 dsh web(命令见 README)。");
793
+ } else if (needRestart) {
794
+ // 需要重启但没做成功:必须给出**可照抄**的命令,而不是只说"请重启"
795
+ L.push("");
796
+ L.push("⚠️ 还需要重启一次 dsh web,插件面板才会出现(插件只在进程启动时装载)。");
797
+ L.push(" 请手动执行(任选其一):");
798
+ L.push(" launchctl kickstart -k gui/$(id -u)/com.dshweb.dev # 用 launchd 托管时");
799
+ L.push(" kill $(lsof -ti tcp:3080 -sTCP:LISTEN) && dsh web --no-open # 手动启动时");
800
+ L.push(" 重启后:打开 http://127.0.0.1:3080 → 设置 → 「远程控制」→ 注册/登录手机号。");
649
801
  } else {
650
802
  L.push("");
651
803
  L.push(" 下一步: 打开 dsh web → 设置 → 「远程控制」→ 注册/登录手机号即可(无需任何命令)。");
@@ -719,8 +871,121 @@ function stripPluginEntries(patch) {
719
871
  }
720
872
 
721
873
  /**
722
- * 归一化 patch 基座:去掉 dsh 新 profile 默认的空文档占位行 `[]`。
723
- * 默认 cordis.patch.yml 是「顶部注释 + []」,若保留 [] 再往下拼 `- insert:`,
874
+ * 解析 patch 文本里的 insert 行 id(与 dshmarket 同口径的极简解析,不引 YAML 依赖)。
875
+ * 用途有二:① 判断本插件是否已激活(幂等);② **写完校验**——patch 文件形态非法时
876
+ * dsh web 会直接启动失败,所以每次写入后必须重新解析确认结果仍是「一行一条的 insert 列表」。
877
+ * @returns {{ids: string[], badLines: string[]}} badLines 非空 = 文件形态不合法
878
+ */
879
+ function parsePatchInsertIds(text) {
880
+ const ids = [];
881
+ const badLines = [];
882
+ for (const raw of String(text ?? "").split(/\r?\n/)) {
883
+ // 先剥注释与尾部空白;注意 id 行在块内是缩进的(` - id: xxx`),不能要求顶格
884
+ const line = raw.replace(/#.*$/, "").replace(/\s+$/, "");
885
+ const trimmed = line.trim();
886
+ if (trimmed === "") continue;
887
+ if (trimmed === "[]") continue; // 新 profile 的空文档占位
888
+ if (/^-\s*insert:\s*$/.test(trimmed)) continue; // insert 块头(可缩进)
889
+ const m = /^-\s*id:\s*(\S+)\s*$/.exec(trimmed); // 条目行(可缩进)
890
+ if (m) { ids.push(m[1]); continue; }
891
+ // 其余缩进行属于某个条目的字段(name/config/...),合法;顶层非列表行才是异常形态
892
+ if (/^\s+\S/.test(line)) continue;
893
+ badLines.push(trimmed);
894
+ }
895
+ return { ids, badLines };
896
+ }
897
+
898
+ /**
899
+ * 写入 patch 前校验基座形态。
900
+ * 只接受「空/仅占位/现有条目」三种基座;发现异常内容就**拒写**(绝不把一个本来还能用的
901
+ * profile 弄坏 —— 改动前先确认目标文件是可安全追加的形态,而不是先写再看结果)。
902
+ * @returns {{ok: boolean, reason?: string, cleaned: string, insertedIds: string[]}}
903
+ */
904
+ function validatePatchBase(text) {
905
+ const { ids, badLines } = parsePatchInsertIds(text);
906
+ if (badLines.length) {
907
+ return { ok: false, reason: `patch 文件含无法识别的行(未做改动): ${badLines[0].slice(0, 60)}`, cleaned: "", insertedIds: ids };
908
+ }
909
+ // 去掉空文档占位 `[]`(它在条目之前会让整份文档变成「数组套列表」而解析失败)
910
+ const cleaned = String(text ?? "").split(/\r?\n/).filter((l) => l.trim() !== "[]").join("\n").trim();
911
+ return { ok: true, cleaned, insertedIds: ids };
912
+ }
913
+
914
+ /** 安全写回 patch(原子:先写同目录临时文件再 rename,避免中途被打断留下半截文件)。 */
915
+ function writePatchAtomic(patchFile, text) {
916
+ const tmp = `${patchFile}.dsh-remote.tmp`;
917
+ fs.writeFileSync(tmp, text);
918
+ fs.renameSync(tmp, patchFile);
919
+ }
920
+
921
+ /**
922
+ * 原子地写入插件 patch 激活行;写后**重新解析校验**,不合法立即回滚。
923
+ * 返回 {ok, changed, reason?, basePatch}
924
+ */
925
+ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile) {
926
+ const original = fs.readFileSync(patchFile, "utf8");
927
+ // 幂等:已经是唯一激活点就什么都不做(但要确保 bundles 里没有重复激活)
928
+ const base = validatePatchBase(original);
929
+ if (!base.ok) return { ok: false, reason: base.reason, changed: false, basePatch: original };
930
+ const hasOurRow = base.insertedIds.some((id) => PLUGIN_ALL_IDS.includes(id));
931
+ if (hasOurRow) {
932
+ const bundleRemoved = removeBundleEntry(pkgFile);
933
+ if (bundleRemoved) console.log("✅ 已从 dsh.profile.bundles 移除重复激活点(激活统一走 patch 行)");
934
+ // 已有条目也要**自我修正**:配置目录变了(换安装方式 / 之前写错)时 relayDir 必须跟着更新,
935
+ // 否则面板会一直去旧目录找 .dsh-config.json(表现就是"未登录 / 空配置")。
936
+ const want = pluginBlock(CONFIG_DIR);
937
+ const wantDir = (/relayDir: '([^']*)'/.exec(want) || [])[1] || "";
938
+ if (wantDir && !original.includes(`relayDir: '${wantDir}'`)) {
939
+ const base2 = validatePatchBase(stripPluginEntries(original));
940
+ if (base2.ok) {
941
+ const merged2 = `${base2.cleaned ? base2.cleaned + "\n" : ""}\n${want}\n`;
942
+ const chk = parsePatchInsertIds(merged2);
943
+ if (!chk.badLines.length && chk.ids.filter((id) => id === PLUGIN_ID).length === 1) {
944
+ writePatchAtomic(patchFile, merged2);
945
+ console.log(`✅ 已同步激活行的 relayDir → ${wantDir}`);
946
+ return { ok: true, changed: true, basePatch: original };
947
+ }
948
+ }
949
+ console.warn("⚠️ 激活行的 relayDir 与当前配置目录不一致,但未能安全改写(保持原样)");
950
+ }
951
+ return { ok: true, changed: bundleRemoved, basePatch: original };
952
+ }
953
+ // 写前再次校验我们的块本身可解析(id 行必须能被 parsePatchInsertIds 认出来)
954
+ // ⚠️ relayDir 必须传**配置目录**(CONFIG_DIR, 即 ~/.dsh-remote),不是插件安装目录:
955
+ // 面板要读的是该目录下的 .dsh-config.json(账号/中继配置);传成 pluginLocalDir 会让面板
956
+ // 对着插件源码目录找配置 → 界面显示成"未登录 / 空配置"(实测踩到)。
957
+ const block = pluginBlock(CONFIG_DIR);
958
+ if (!parsePatchInsertIds(block).ids.includes(PLUGIN_ID)) {
959
+ return { ok: false, reason: "生成的激活块自身不合法", changed: false, basePatch: original };
960
+ }
961
+ const merged = `${base.cleaned ? base.cleaned + "\n" : ""}\n${block}\n`;
962
+ // 写后校验:整份文件必须仍是干净的 insert 列表,且我们的 id 恰好出现一次
963
+ const check = parsePatchInsertIds(merged);
964
+ if (check.badLines.length || check.ids.filter((id) => id === PLUGIN_ID).length !== 1) {
965
+ return { ok: false, reason: "合并后的 patch 校验未通过(未做改动)", changed: false, basePatch: original };
966
+ }
967
+ writePatchAtomic(patchFile, merged);
968
+ const after = fs.readFileSync(patchFile, "utf8");
969
+ const verify = parsePatchInsertIds(after);
970
+ if (verify.badLines.length || verify.ids.filter((id) => id === PLUGIN_ID).length !== 1) {
971
+ writePatchAtomic(patchFile, original); // 回滚
972
+ return { ok: false, reason: "写入后校验失败,已回滚", changed: false, basePatch: original };
973
+ }
974
+ removeBundleEntry(pkgFile); // 单一激活点:bundles 里不能再有本插件
975
+ return { ok: true, changed: true, basePatch: original };
976
+ }
977
+
978
+ /** 清理 patch 中引用本插件的条目块(含前置注释/收尾标记)。返回是否发生变更。 */
979
+ function stripIncludeEntries(patchFile, patch, reason) {
980
+ const next = stripPluginEntries(patch);
981
+ if (next === patch) return false;
982
+ writePatchAtomic(patchFile, next);
983
+ console.log(`✅ 已移除 ${patchFile} 中的冗余 include(${reason};激活点必须唯一,避免重复 ID 崩溃)`);
984
+ return true;
985
+ }
986
+
987
+ /**
988
+ * 归一化 patch 基座:去掉 dsh 新 profile 默认的空文档占位行 `[]`。 * 默认 cordis.patch.yml 是「顶部注释 + []」,若保留 [] 再往下拼 `- insert:`,
724
989
  * YAML 会报 “end of the stream or a document separator is expected”(dsh web 启动即崩)。
725
990
  * 返回内容不含结尾换行;[] 行只在独立成行时视为占位,不影响真正的条目。
726
991
  */
@@ -828,13 +1093,17 @@ function removeBundleEntry(pkgFile) {
828
1093
 
829
1094
  /**
830
1095
  * 插件安装(pluginCmd 非卸载分支)收敛策略 —— 2026-09-06「重复 ID 崩溃」根治;
831
- * 2026-09 插件由 dsh-remote-ui 更名 dsh-remote-web,本函数同时兼容清理旧名残留:
832
- * dsh-remote-web 是带 dsh.bundle.patch 的 bundle:package.json 的 dsh.profile.bundles
833
- * 声明它后,加载器会自动应用插件自带的 cordis.patch.yml(节点半+浏览器半的唯一激活点)。
834
- * 若用户级 cordis.patch.yml 再手工 insert 同一个 id,dsh web 启动即报“重复 ID”崩溃。
835
- * 因此本命令【绝不写用户 include】,只负责:让插件以 bundle 形态可解析,
836
- * 并清理历史遗留的 include 块与旧名(≤0.4.9 dsh-remote-ui)痕迹。
837
- * 市场形态(github:/npm 依赖)则完全交由市场管理,只清理 include。
1096
+ * 2026-09 插件由 dsh-remote-ui 更名 dsh-remote-web,本函数同时兼容清理旧名残留。
1097
+ *
1098
+ * ⚠️ 2026-09-13 调整:「恰好一处激活」仍然成立,但**激活点二选一**:
1099
+ * · dsh-setup 安装(file: 依赖 + 自建 node_modules 链接)→ 激活点 = 用户级 cordis.patch.yml
1100
+ * 的一行 insert。理由:harness 的 web profile 是 `patchReload: "live"`,会加载
1101
+ * `@deepseek-ai/cordis-plugin-hmr` 监听该文件,**存盘后约 1 秒重新 compose 并动态装载**,
1102
+ * 用户不需要重启 dsh web(只需刷新页面拿到浏览器半)。插件市场自己也是这套机制。
1103
+ * · 插件市场安装(github:/npm: 依赖,源码归市场管)→ 激活点 = dsh.profile.bundles
1104
+ * (插件自带 bundle patch)。bundles 只在启动时读、没有 watcher,所以这一路仍需重启。
1105
+ * 两条路都**只保留一个激活点**:写 patch 就同时从 bundles 移除,反之清掉 include,
1106
+ * 绝不两处并存(那正是历史上 dsh web 启动即报「重复 ID」崩溃的原因)。
838
1107
  */
839
1108
  function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch) {
840
1109
  const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
@@ -845,18 +1114,11 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
845
1114
  const marketManaged = dep && !String(dep).startsWith("file:"); // github:/npm: 等由市场/包管理器管源码
846
1115
  const managedByUs = !dep || String(dep).startsWith("file:"); // 无依赖或 file: 拷贝 → 我们管
847
1116
 
848
- const stripInclude = (reason) => {
849
- const newPatch = stripPluginEntries(patch);
850
- if (newPatch !== patch) {
851
- fs.writeFileSync(patchFile, newPatch);
852
- console.log(`✅ 已移除 ${patchFile} 中的冗余 include(${reason};激活统一走插件自带 bundle patch,避免重复 ID 崩溃)`);
853
- }
854
- };
855
-
856
1117
  if (marketManaged && inBundles) {
857
1118
  // 插件市场安装形态:依赖与源码归市场管,我们只清历史 include(若旧版曾写过)
858
- stripInclude("插件市场安装形态无需用户 include");
1119
+ stripIncludeEntries(patchFile, patch, "插件市场安装形态无需用户 include");
859
1120
  console.log("ℹ 插件市场安装形态(bundles+dependency):已保持市场管理的源码不变。");
1121
+ console.log(" (该形态经 dsh.profile.bundles 激活,只在启动时读取 —— 装完需重启一次 dsh web)");
860
1122
  return;
861
1123
  }
862
1124
 
@@ -867,14 +1129,19 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
867
1129
  console.error(`❌ 插件入口缺失:${entryFile}(本包不完整?请用官方源重装:npx --registry=https://registry.npmjs.org @mrrisega/dsh-remote@latest)`);
868
1130
  process.exit(1);
869
1131
  }
870
- declarePluginDep(pkgFile); // file: 依赖(包管理器 install 不误删)
871
- const addedBundle = ensureBundleEntry(pkgFile); // bundles 声明 → 插件自带 patch 自动激活
872
- ensurePluginLinked(profileDir, pluginLocalDir); // 自建 node_modules 链接
873
- stripInclude("bundle patch 已是唯一激活点"); // 清历史 include
874
- // 只报结果不报过程:bundle 是否新加、node_modules 是否新建链接都属于实现细节
875
- // (失败会各自抛错/提示),用户只需要知道"插件装好了、装在哪"。
876
- console.log(`✅ 插件已就绪: ${pluginLocalDir}${addedBundle ? "" : "(此前已激活)"}`);
877
- return;
1132
+ declarePluginDep(pkgFile); // file: 依赖(包管理器 install 不误删)
1133
+ ensurePluginLinked(profileDir, pluginLocalDir); // node_modules 链接(name 才能被解析)
1134
+ // 唯一激活点:patch 行(+ 顺带把 bundles 里的本插件条目移除,避免两处并存)
1135
+ const r = ensurePatchActivation(patchFile, pluginLocalDir, pkgFile);
1136
+ if (r.ok) {
1137
+ console.log(`✅ 插件已就绪: ${pluginLocalDir}`);
1138
+ return { hotPatch: true, changed: r.changed, basePatch: r.basePatch };
1139
+ }
1140
+ // patch 行写不进去(文件形态异常)→ 回退 bundles(需重启),保证功能可用
1141
+ console.warn(`⚠️ 无法写入 patch 激活行(${r.reason}),改为 bundles 形态(需重启 dsh web 生效)`);
1142
+ ensureBundleEntry(pkgFile);
1143
+ console.log(`✅ 插件已就绪: ${pluginLocalDir}(bundles 形态)`);
1144
+ return { hotPatch: false, changed: true, basePatch: patch };
878
1145
  }
879
1146
 
880
1147
  // 异常形态:有非 file: 依赖但不在 bundles(无法靠 bundle patch 激活)
@@ -884,10 +1151,7 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
884
1151
 
885
1152
  async function pluginCmd(argv) {
886
1153
  const uninstall = hasFlag(argv, "--uninstall");
887
- const profileIdx = argv.indexOf("--profile");
888
- const profileDir = profileIdx > -1 && argv[profileIdx + 1]
889
- ? argv[profileIdx + 1]
890
- : process.env.DSH_PROFILE_DIR || path.join(os.homedir(), ".dsh", "profiles", "web");
1154
+ const profileDir = resolveProfileDir(argv);
891
1155
  const pkgFile = path.join(profileDir, "package.json");
892
1156
  const patchFile = path.join(profileDir, "cordis.patch.yml");
893
1157
  const pluginDir = path.join(THIS_DIR, "packages", PLUGIN_ID);
@@ -906,11 +1170,8 @@ async function pluginCmd(argv) {
906
1170
 
907
1171
  if (uninstall) {
908
1172
  // 移除 patch 中的插件条目(兼容旧版无标记条目;旧名 dsh-remote-ui 一并清理)
909
- const newPatch = stripPluginEntries(patch);
910
- if (newPatch !== patch) {
911
- fs.writeFileSync(patchFile, newPatch);
912
- console.log(`✅ 已从 ${patchFile} 移除插件条目`);
913
- } else {
1173
+ // 用原子写回:卸载同样不能在 profile 配置上留下半截文件
1174
+ if (!stripIncludeEntries(patchFile, patch, "卸载清理")) {
914
1175
  console.log(`ℹ patch 中未发现 ${PLUGIN_ID}(或历史名)条目。`);
915
1176
  }
916
1177
  // 清理本地目录与链接:当前名 + 历史名(dsh-remote-ui-plugin / node_modules/dsh-remote-ui)
@@ -934,11 +1195,13 @@ async function pluginCmd(argv) {
934
1195
  return;
935
1196
  }
936
1197
 
937
- // 安装:收敛到“恰好一处激活”(bundle patch 唯一激活点),绝不与市场/历史 include 并存
938
- convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch);
1198
+ // 安装:收敛到"恰好一处激活"(热挂载 patch 行 或 bundles,二选一),绝不与市场/历史 include 并存
1199
+ //
1200
+ // ⚠️ 必须 **return 结果**:setup() 依赖它判断激活形态(false/undefined = 走 bundles,需重启),
1201
+ // 少了这个 return,安装后就不会去验证热挂载,白白多报一次"需要重启"(实测踩到)。
1202
+ return convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch);
939
1203
 
940
- console.log(" 打开 dsh web → 设置 → 「远程控制」,注册/登录手机号即可(无需任何命令)。");
941
- console.log(" 若从 DeepSeek App/插件市场 安装:请完全退出并重开 App 让插件生效。");
1204
+ // 引导语只在 setup 汇总里打印一次(这里不再重复;单独跑 `dsh-remote plugin` 也无需引导)
942
1205
  }
943
1206
 
944
1207
  // ---------- main ----------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.4-beta.6",
3
+ "version": "0.6.4-beta.8",
4
4
  "description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -1739,7 +1739,7 @@ const PLUGIN_ID = "dsh-remote-web";
1739
1739
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
1740
1740
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
1741
1741
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
1742
- const PLUGIN_VERSION = "0.6.4-beta.6";
1742
+ const PLUGIN_VERSION = "0.6.4-beta.8";
1743
1743
  const UPDATE_LOG = ".dsh-update.log";
1744
1744
  const UPDATE_MARKER = ".dsh-update-running";
1745
1745
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.4-beta.6",
3
+ "version": "0.6.4-beta.8",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -0,0 +1,183 @@
1
+ /**
2
+ * 「patch 热加载激活」契约测试(node:test,零框架)。
3
+ *
4
+ * 背景:harness 的 web profile 是 `patchReload: "live"`,启动时加载 `@deepseek-ai/cordis-plugin-hmr`
5
+ * 并监听 profile 的 `cordis.patch.yml` —— 存盘后约 1 秒重新 compose 并**动态装载**插件,
6
+ * 不需要重启 dsh web(插件市场自己就用这套机制)。而 `dsh.profile.bundles` 只在启动时读、
7
+ * 没有 watcher,所以「bundles 激活」必须重启 —— 这正是用户装完看不到「远程控制」面板的原因。
8
+ *
9
+ * 因此安装器改为:**激活点 = patch 行**(热加载),bundles 那条同时移除(单一激活点,
10
+ * 否则 dsh web 启动即报「重复 ID」崩溃)。
11
+ *
12
+ * 这些用例在**临时 profile**(mkdtemp)里跑真实的 `dsh-setup.mjs plugin` 子进程,
13
+ * 断言生成的文件产物;绝不触碰开发者真实的 ~/.dsh/profiles/**。
14
+ */
15
+
16
+ import { test } from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { execFileSync, spawnSync } from "node:child_process";
19
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync, cpSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
25
+ const SETUP = join(ROOT, "dsh-setup.mjs");
26
+
27
+ const PLUGIN_BUNDLES = ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"];
28
+
29
+ /** 造一个临时 profile(默认 patch 是新 profile 的空文档占位 `[]`)。 */
30
+ function makeProfile({ patch = "# dsh profile patch\n[]\n", bundles = PLUGIN_BUNDLES } = {}) {
31
+ const dir = mkdtempSync(join(tmpdir(), "dshac-"));
32
+ writeFileSync(join(dir, "package.json"), JSON.stringify({
33
+ name: "dsh-profile-web", private: true, dependencies: {},
34
+ dsh: { profile: { bundles: [...bundles], patchReload: "live" } }
35
+ }, null, 2) + "\n");
36
+ writeFileSync(join(dir, "cordis.patch.yml"), patch);
37
+ return dir;
38
+ }
39
+
40
+ /** 跑真实的 plugin 安装(子进程,指向临时 profile 与临时配置目录)。stdout+stderr 一并返回。 */
41
+ function installPluginWithStderr(profileDir, relayDir) {
42
+ const r = spawnSync(process.execPath, [SETUP, "plugin", "--profile", profileDir], {
43
+ encoding: "utf8",
44
+ env: { ...process.env, DSH_RELAY_DIR: relayDir, DSH_RELAY_SKIP_SERVICE: "1" }
45
+ });
46
+ return { out: String(r.stdout || ""), err: String(r.stderr || ""), code: r.status };
47
+ }
48
+
49
+ /** 只要 stdout(正常路径够用)。 */
50
+ function installPlugin(profileDir, relayDir) {
51
+ const r = installPluginWithStderr(profileDir, relayDir);
52
+ assert.equal(r.code, 0, `安装子进程应成功退出,stderr=${r.err.slice(0, 200)}`);
53
+ return r.out;
54
+ }
55
+
56
+ function cleanup(...dirs) {
57
+ for (const d of dirs) { try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } }
58
+ }
59
+
60
+ const readBundles = (dir) => JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).dsh.profile.bundles;
61
+ const readPatch = (dir) => readFileSync(join(dir, "cordis.patch.yml"), "utf8");
62
+
63
+ test("patch 激活:写入 insert 行(热加载形态),并从 bundles 移除该插件(单一激活点)", () => {
64
+ const profile = makeProfile();
65
+ const relay = join(profile, "relay");
66
+ try {
67
+ installPlugin(profile, relay);
68
+ const patch = readPatch(profile);
69
+ assert.match(patch, /- insert:/, "应写入 insert 块");
70
+ assert.match(patch, /- id: dsh-remote-web/, "应写入本插件的 id 行");
71
+ assert.match(patch, /name: 'dsh-remote-web'/, "name 必须是裸包名(加载器据此 import)");
72
+ assert.ok(!patch.includes("[]"), "空文档占位 `[]` 必须被清掉(否则整份 patch 解析失败)");
73
+ assert.deepEqual(readBundles(profile), PLUGIN_BUNDLES, "bundles 里不能再有本插件(两处激活=重复 ID 崩溃)");
74
+ } finally { cleanup(profile); }
75
+ });
76
+
77
+ test("patch 激活:relayDir 写的是**配置目录**而不是插件安装目录", () => {
78
+ const profile = makeProfile();
79
+ const relay = join(profile, "relay-config");
80
+ mkdirSync(relay, { recursive: true });
81
+ try {
82
+ installPlugin(profile, relay);
83
+ const patch = readPatch(profile);
84
+ const m = /relayDir: '([^']+)'/.exec(patch);
85
+ assert.ok(m, "应带 relayDir 配置");
86
+ // 面板要读的是该目录下的 .dsh-config.json;写成插件目录会让面板显示成"未登录/空配置"
87
+ assert.equal(m[1], relay, "relayDir 必须是配置目录");
88
+ assert.ok(!m[1].includes("dsh-remote-web-plugin"), "relayDir 不得指向插件源码目录");
89
+ } finally { cleanup(profile); }
90
+ });
91
+
92
+ test("patch 激活:幂等 —— 重复安装不会写出第二个 id(否则重复 ID 崩溃)", () => {
93
+ const profile = makeProfile();
94
+ const relay = join(profile, "relay");
95
+ try {
96
+ installPlugin(profile, relay);
97
+ const first = readPatch(profile);
98
+ installPlugin(profile, relay);
99
+ const second = readPatch(profile);
100
+ assert.equal(second, first, "第二次安装不应再改 patch");
101
+ const ids = second.match(/- id: dsh-remote-web/g) || [];
102
+ assert.equal(ids.length, 1, `id 行必须恰好一条,实际 ${ids.length}`);
103
+ } finally { cleanup(profile); }
104
+ });
105
+
106
+ test("patch 激活:既有其它插件条目必须原样保留(不能吃掉别人的配置)", () => {
107
+ const other = [
108
+ "# dsh profile patch",
109
+ "[]",
110
+ "",
111
+ "# --- mba-feishu managed block ---",
112
+ "- insert:",
113
+ " - id: mba-feishu",
114
+ " name: 'mba-feishu'",
115
+ " config:",
116
+ " transport: stdio",
117
+ " args:",
118
+ " - 'mcp'",
119
+ "# --- end mba-feishu managed block ---",
120
+ ""
121
+ ].join("\n");
122
+ const profile = makeProfile({ patch: other });
123
+ const relay = join(profile, "relay");
124
+ try {
125
+ installPlugin(profile, relay);
126
+ const patch = readPatch(profile);
127
+ assert.match(patch, /id: mba-feishu/, "其它插件的条目必须保留");
128
+ assert.match(patch, /transport: stdio/, "其它插件的字段必须保留");
129
+ assert.match(patch, /- 'mcp'/, "其它插件的嵌套列表必须保留");
130
+ assert.match(patch, /# --- end mba-feishu managed block ---/, "注释块必须保留");
131
+ assert.match(patch, /- id: dsh-remote-web/, "同时写入我们的条目");
132
+ assert.equal((patch.match(/^- insert:/gm) || []).length, 2, "应有两个独立的 insert 块");
133
+ } finally { cleanup(profile); }
134
+ });
135
+
136
+ test("安全护栏:patch 形态异常时**拒写**并回退 bundles(绝不把能用的 profile 弄坏)", () => {
137
+ const broken = "# 坏 patch:顶层塞了非列表内容\nthis is not a list ===\n- insert:\n - id: other\n name: 'other'\n";
138
+ const profile = makeProfile({ patch: broken });
139
+ const relay = join(profile, "relay");
140
+ try {
141
+ const { out, err } = installPluginWithStderr(profile, relay);
142
+ const all = out + err; // 警告走 stderr,必须一并检查
143
+ assert.match(all, /无法写入 patch 激活行/, "应明确报告拒写");
144
+ assert.match(all, /bundles 形态/, "应回退 bundles 保证功能可用");
145
+ assert.equal(readPatch(profile), broken, "坏文件必须一字未改(拒写,不是先写再看)");
146
+ assert.ok(readBundles(profile).includes("dsh-remote-web"), "回退时应把插件加进 bundles");
147
+ } finally { cleanup(profile); }
148
+ });
149
+
150
+ test("安全护栏:基座含空文档占位 `[]` 时也要先清掉(否则整份 patch 解析失败)", () => {
151
+ const profile = makeProfile({ patch: "[]\n" });
152
+ const relay = join(profile, "relay");
153
+ try {
154
+ installPlugin(profile, relay);
155
+ const patch = readPatch(profile);
156
+ assert.ok(!/^\[\]$/m.test(patch), "占位 [] 必须被移除");
157
+ assert.match(patch, /- id: dsh-remote-web/, "并写入我们的条目");
158
+ } finally { cleanup(profile); }
159
+ });
160
+
161
+ test("安全护栏:原子写回 —— 不留 .tmp 残留,且末尾换行规范", () => {
162
+ const profile = makeProfile();
163
+ const relay = join(profile, "relay");
164
+ try {
165
+ installPlugin(profile, relay);
166
+ assert.ok(!readPatch(profile).includes(".dsh-remote.tmp"), "不应把临时文件名写进内容");
167
+ const leftovers = execFileSync("ls", ["-a", profile], { encoding: "utf8" })
168
+ .split("\n").filter((f) => f.includes(".tmp"));
169
+ assert.deepEqual(leftovers, [], `不应留下临时文件: ${leftovers.join(",")}`);
170
+ assert.ok(readPatch(profile).endsWith("\n"), "文件应以换行结尾");
171
+ } finally { cleanup(profile); }
172
+ });
173
+
174
+ test("回归护栏:plugin 子命令必须把激活结果 return 给 setup(否则热挂载验证被跳过)", () => {
175
+ // 事故复盘:convergePluginActivation 已经返回 {hotPatch},但 pluginCmd 没把它 return 出去,
176
+ // 于是 setup() 拿不到激活形态 → 跳过热挂载验证 → 明明热挂载成功却仍报「需要重启」。
177
+ const src = readFileSync(SETUP, "utf8");
178
+ const call = src.match(/return convergePluginActivation\(profileDir[^\n]*/);
179
+ assert.ok(call, "pluginCmd 必须 return convergePluginActivation(...) 的结果");
180
+ // setup() 必须真的消费这个结果
181
+ assert.match(src, /pluginResult = await pluginCmd\(\[\]\)/, "setup 应保存 pluginCmd 的返回值");
182
+ assert.match(src, /pluginResult\.hotPatch/, "应据 hotPatch 判断是否走热挂载形态");
183
+ });
@@ -44,15 +44,21 @@ test("测试隔离:unset 系统操作开关的用例必须同时伪造 HOME(
44
44
  });
45
45
 
46
46
  test("安装输出:拼接汇总时「下一步」引导只有一处文案源(不再多处重复打印)", () => {
47
- // 引导语只由汇总的 L.push 产出:selfHosted 与非自建各一条(互斥分支,用户只会看到一条)
47
+ // 引导语只由汇总的 L.push 产出:自建 / 热挂载 / 需重启 / 普通 四个**互斥**分支,
48
+ // 运行时只可能走其中一条 → 用户始终只看到一条引导。
48
49
  const pushes = src.match(/L\.push\(" 下一步[::]/g) || [];
49
- assert.equal(pushes.length, 2, `「下一步」应只由汇总的 2 个互斥分支产出(实际 ${pushes.length} 处)`);
50
+ assert.equal(pushes.length, 4, `「下一步」应只由汇总的 4 个互斥分支产出(实际 ${pushes.length} 处)`);
50
51
  // 源码里不得再有其它直接打印引导的地方
51
52
  const direct = src.match(/console\.log\((?!.*L\.join)[^)]*下一步/g) || [];
52
53
  assert.equal(direct.length, 0, "除汇总外不得再直接打印引导");
53
54
  // 插件阶段不得再打印用户引导
54
55
  const converge = src.slice(src.indexOf("function convergePluginActivation"), src.indexOf("async function pluginCmd"));
55
56
  assert.ok(!/下一步|打开 dsh web → 设置/.test(converge), "插件阶段不应再重复打印引导");
57
+ // 互斥性:这些分支必须挂在同一条 if/else-if 链上,否则可能同时打出两条引导
58
+ const chain = src.slice(src.indexOf("} else if (hotMounted) {"), src.indexOf('L.push("\\n" + L.join'));
59
+ assert.ok(chain.length > 0, "应能定位到引导分支链");
60
+ assert.ok(/else if \(needRestart && restartResult/.test(chain), "热挂载与需重启必须在同一条 else-if 链上");
61
+ assert.ok(/else if \(needRestart\)/.test(chain), "兜底的手动重启分支也必须在同一条链上");
56
62
  });
57
63
 
58
64
  test("安装输出:「已创建自启动服务」路径只在汇总里打印一次", () => {