@mrrisega/dsh-remote 0.6.4-beta.9 → 0.6.5

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
@@ -681,6 +681,7 @@ async function setup(argv) {
681
681
  let webPid = null;
682
682
  let needRestart = false;
683
683
  let hotMounted = false;
684
+ let runningSeen = ""; // 探测窗口里看到过的运行中插件版本(超时时用于给出准确提示)
684
685
  const webUp = await isDshWebUp();
685
686
  if (webUp) {
686
687
  webPid = dshWebPid();
@@ -690,11 +691,30 @@ async function setup(argv) {
690
691
  // 所以这里**先等热挂载**,等到了就完全不需要重启(用户只需刷新页面拿浏览器半)。
691
692
  if (needRestart && pluginResult && pluginResult.hotPatch) {
692
693
  // HMR 通常 1~3 秒完成,但插件节点半还要起服务、注册路由,机器繁忙时会到十几秒;
693
- // 给足 ~30 秒窗口,避免"其实已经热挂载成功、却报成需要重启"的误报(实测踩到)。
694
+ // ⚠️ 判据必须是**运行中的插件版本 == 我们刚装上的版本**,不能只看"接口是否 200" ——
695
+ // 旧版本插件本来就有这个接口,接口通不代表新代码已装载(实测踩到:profile 已是 0.6.4,
696
+ // 运行中仍报 beta.10,而探测却认为"热挂载成功")。
697
+ const wantVersion = pkgVersion();
698
+ // 先**主动触发**一次 HMR:patch 文件是唯一被监听的对象。
699
+ // 注意边界(实测):这招对"插件首次出现在 patch 行里"有效(行从无到有 = 真变化,
700
+ // 约 1~3 秒装载);但对"同一插件换了新版本"**无效** —— patch 行内容没变,
701
+ // 而且加载器按 URL 缓存模块,改写插件文件本身不会重新装载。后者必须重启 dsh web。
702
+ // 因此下面按**版本**轮询,等不到就如实让用户重启。
703
+ try {
704
+ const patchPath = path.join(resolveProfileDir(argv), "cordis.patch.yml");
705
+ const cur = fs.readFileSync(patchPath, "utf8");
706
+ fs.writeFileSync(patchPath, cur);
707
+ } catch { /* 忽略:轮询会给出结论 */ }
694
708
  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) })
709
+ const r = await fetch("http://127.0.0.1:3080/dsh-remote/self", { signal: AbortSignal.timeout(900) })
696
710
  .catch(() => null);
697
- if (r && r.ok) { hotMounted = true; break; }
711
+ if (r && r.ok) {
712
+ const j = await r.json().catch(() => null);
713
+ const running = j && typeof j.version === "string" ? j.version : "";
714
+ if (running && running === wantVersion) { hotMounted = true; break; }
715
+ // 接口通但版本还是旧的 → HMR 还没换过来(或没触发),继续等
716
+ runningSeen = running || runningSeen;
717
+ }
698
718
  sleepSync(700);
699
719
  }
700
720
  if (hotMounted) needRestart = false;
@@ -788,8 +808,13 @@ async function setup(argv) {
788
808
  // patch 已写入但探测窗口内没等到面板接口:热加载很可能还在进行(插件节点半启动+注册路由)。
789
809
  // 这种情况**不能**直接让用户重启 —— 先让他等几秒刷新,再给兜底方案。
790
810
  L.push("");
791
- L.push("ℹ 插件已按热加载方式登记(patch 已写入),若「设置 → 远程控制」还没出现:");
792
- L.push(" 等几秒后刷新页面即可;仍未出现再重启一次 dsh web(命令见 README)。");
811
+ if (runningSeen) {
812
+ L.push(`ℹ 插件已按热加载方式登记,但运行中的 dsh web 仍在用旧版本(${runningSeen} → 期望 ${pkgVersion()}):`);
813
+ L.push(" 刷新页面即可(浏览器半会跟着更新);若刷新后功能仍不对,再重启一次 dsh web。");
814
+ } else {
815
+ L.push("ℹ 插件已按热加载方式登记(patch 已写入),若「设置 → 远程控制」还没出现:");
816
+ L.push(" 等几秒后刷新页面即可;仍未出现再重启一次 dsh web(命令见 README)。");
817
+ }
793
818
  } else if (needRestart) {
794
819
  // 需要重启但没做成功:必须给出**可照抄**的命令,而不是只说"请重启"
795
820
  L.push("");
@@ -921,10 +946,26 @@ function writePatchAtomic(patchFile, text) {
921
946
  /**
922
947
  * 原子地写入插件 patch 激活行;写后**重新解析校验**,不合法立即回滚。
923
948
  * 返回 {ok, changed, reason?, basePatch}
949
+ *
950
+ * ⚠️ 硬约束:**本函数绝不能在插件已在 dsh.profile.bundles 时写入 patch 行**。
951
+ * 两个激活点同时存在时,dsh web 启动即 `duplicate loader entry id: dsh-remote-web`
952
+ * 并导致「plugin tree failed to load」——整个插件树加载失败(实测日志)。
953
+ * 因此所有 return 之前都要保证:要么只有 patch 行、要么只有 bundles 条目。
924
954
  */
925
955
  function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile) {
926
956
  const original = fs.readFileSync(patchFile, "utf8");
927
- // 幂等:已经是唯一激活点就什么都不做(但要确保 bundles 里没有重复激活)
957
+ const pkgBundles = () => {
958
+ try {
959
+ const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
960
+ return (pkg.dsh && pkg.dsh.profile && Array.isArray(pkg.dsh.profile.bundles))
961
+ ? pkg.dsh.profile.bundles.filter((b) => PLUGIN_ALL_IDS.includes(b)) : [];
962
+ } catch { return []; }
963
+ };
964
+ // 基座就是「插件已在 bundles」→ 保持 bundles 形态(不写 patch 行),避免两个激活点并存。
965
+ // 这一路等价于插件市场安装形态:需要重启 dsh web 才装载。
966
+ if (pkgBundles().length) {
967
+ return { ok: false, reason: "插件已由 dsh.profile.bundles 声明(保持单一激活点,不写 patch 行)", changed: false, bundleOnly: true, basePatch: original };
968
+ }
928
969
  const base = validatePatchBase(original);
929
970
  if (!base.ok) return { ok: false, reason: base.reason, changed: false, basePatch: original };
930
971
  const hasOurRow = base.insertedIds.some((id) => PLUGIN_ALL_IDS.includes(id));
@@ -972,6 +1013,12 @@ function ensurePatchActivation(patchFile, pluginLocalDir, pkgFile) {
972
1013
  return { ok: false, reason: "写入后校验失败,已回滚", changed: false, basePatch: original };
973
1014
  }
974
1015
  removeBundleEntry(pkgFile); // 单一激活点:bundles 里不能再有本插件
1016
+ // 双保险:上面那次移除若没生效(并发写 package.json / 解析失败),**回滚本次 patch 行**,
1017
+ // 宁可退回 bundles 形态(需重启)也绝不留"两处激活"→ dsh web 启动会 duplicate id 崩掉。
1018
+ if (pkgBundles().length) {
1019
+ writePatchAtomic(patchFile, original);
1020
+ return { ok: false, reason: "bundles 条目未能移除,已回滚 patch 行以免重复激活", changed: false, bundleOnly: true, basePatch: original };
1021
+ }
975
1022
  return { ok: true, changed: true, basePatch: original };
976
1023
  }
977
1024
 
@@ -1105,6 +1152,35 @@ function removeBundleEntry(pkgFile) {
1105
1152
  * 两条路都**只保留一个激活点**:写 patch 就同时从 bundles 移除,反之清掉 include,
1106
1153
  * 绝不两处并存(那正是历史上 dsh web 启动即报「重复 ID」崩溃的原因)。
1107
1154
  */
1155
+
1156
+ /** 读取 profile 内已安装插件的版本(市场/包管理器装法)。 */
1157
+ function readInstalledPluginVersion(profileDir, name) {
1158
+ try {
1159
+ return JSON.parse(fs.readFileSync(path.join(profileDir, "node_modules", name, "package.json"), "utf8")).version;
1160
+ } catch { return null; }
1161
+ }
1162
+
1163
+ /**
1164
+ * 市场装法下把插件包升到最新。
1165
+ *
1166
+ * 2026-09-13 实测的坑:用户在插件面板点「一键更新」时,本函数原先的调用方(marketManaged 分支)
1167
+ * 只清 include、不动源码,日志写着"已保持市场管理的源码不变",命令 exit 0、界面显示"安装完成",
1168
+ * 但插件包始终停在旧版本 —— 用户看到的就是"一键更新到不了最新版"。
1169
+ *
1170
+ * 规格判断:registry 规格(^0.6.3 / 0.6.3 / >=…)用 `add <name>@latest`;
1171
+ * github:/git+/file:/https: 之类用 `update <name>` 让包管理器重新解析引用(例如 git HEAD)。
1172
+ */
1173
+ function upgradeMarketManagedPlugin(profileDir, name, dep) {
1174
+ const spec = String(dep ?? "").trim();
1175
+ const isRegistryRange = /^[\^~><=v\d]/.test(spec);
1176
+ const pm = sh("command -v pnpm >/dev/null 2>&1 && echo pnpm || echo npm").stdout.trim() || "npm";
1177
+ const args = isRegistryRange ? `add ${name}@latest` : `update ${name}`;
1178
+ const cmd = `${pm} ${args}`;
1179
+ const r = sh(cmd, 180000, profileDir);
1180
+ if (!r.ok) console.warn(`⚠️ ${cmd} 失败:${(r.stderr || r.stdout || "").trim().split("\n").slice(-3).join(" / ")}`);
1181
+ return { ok: r.ok, cmd };
1182
+ }
1183
+
1108
1184
  function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch) {
1109
1185
  const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
1110
1186
  const legacyDep = PLUGIN_LEGACY_IDS.map((id) => pkg.dependencies && pkg.dependencies[id]).find((v) => v !== undefined);
@@ -1115,10 +1191,24 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
1115
1191
  const managedByUs = !dep || String(dep).startsWith("file:"); // 无依赖或 file: 拷贝 → 我们管
1116
1192
 
1117
1193
  if (marketManaged && inBundles) {
1118
- // 插件市场安装形态:依赖与源码归市场管,我们只清历史 include(若旧版曾写过)
1194
+ // 插件市场安装形态:源码归市场/包管理器管 → 只清历史 include,但**必须主动升级到最新**,
1195
+ // 否则「一键更新」会报成功却什么都没做(2026-09-13 实测)。
1119
1196
  stripIncludeEntries(patchFile, patch, "插件市场安装形态无需用户 include");
1120
- console.log("ℹ 插件市场安装形态(bundles+dependency):已保持市场管理的源码不变。");
1121
- console.log(" (该形态经 dsh.profile.bundles 激活,只在启动时读取 —— 装完需重启一次 dsh web)");
1197
+ const depName = PLUGIN_ALL_IDS.find((id) => pkg.dependencies && pkg.dependencies[id] !== undefined) || PLUGIN_ID;
1198
+ const before = readInstalledPluginVersion(profileDir, depName);
1199
+ console.log(`ℹ 插件市场安装形态(bundles+dependency):源码归包管理器管,正在升级到最新…(安装前 ${before ?? "未知"})`);
1200
+ const { ok, cmd } = upgradeMarketManagedPlugin(profileDir, depName, dep);
1201
+ const after = readInstalledPluginVersion(profileDir, depName);
1202
+ if (ok && after && after !== before) {
1203
+ console.log(`✅ 插件包已升级:${before ?? "?"} → ${after}`);
1204
+ console.log(" 该形态经 dsh.profile.bundles 激活,只在启动时读取 —— 请重启 dsh web 生效。");
1205
+ } else if (ok && after) {
1206
+ console.log(`✅ 插件包已是市场最新版(${after})。`);
1207
+ console.log(" 若面板仍显示旧版本,请重启 dsh web(bundles 只在启动时读取)。");
1208
+ } else {
1209
+ console.log(`⚠️ 插件包升级未成功(当前 ${after ?? "未知"})。可手动执行:`);
1210
+ console.log(` cd ${profileDir} && ${cmd}`);
1211
+ }
1122
1212
  return;
1123
1213
  }
1124
1214
 
@@ -1137,10 +1227,14 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
1137
1227
  console.log(`✅ 插件已就绪: ${pluginLocalDir}`);
1138
1228
  return { hotPatch: true, changed: r.changed, basePatch: r.basePatch };
1139
1229
  }
1140
- // patch 行写不进去(文件形态异常)→ 回退 bundles(需重启),保证功能可用
1141
- console.warn(`⚠️ 无法写入 patch 激活行(${r.reason}),改为 bundles 形态(需重启 dsh web 生效)`);
1142
- ensureBundleEntry(pkgFile);
1143
- console.log(`✅ 插件已就绪: ${pluginLocalDir}(bundles 形态)`);
1230
+ // patch 行不能写(文件形态异常,或插件已由 bundles 声明)→ 回退 bundles(需重启),保证功能可用。
1231
+ // 这条路的输出要如实说明"装完要重启一次",不能让人以为已经热加载了。
1232
+ if (r.bundleOnly) {
1233
+ console.log("ℹ 该插件已由 dsh.profile.bundles 声明(保持单一激活点,不重复写入 patch 行)");
1234
+ } else {
1235
+ console.warn(`⚠️ 无法写入 patch 激活行(${r.reason}),改为 bundles 形态(需重启 dsh web 生效)`);
1236
+ ensureBundleEntry(pkgFile); // 回退激活点:插件仍可用,代价是必须重启一次
1237
+ }
1144
1238
  return { hotPatch: false, changed: true, basePatch: patch };
1145
1239
  }
1146
1240
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.4-beta.9",
3
+ "version": "0.6.5",
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",
@@ -887,7 +887,15 @@ function pluginInstalledAfterBoot() {
887
887
  const pkgFile = join(dirname(dirname(here)), "package.json");
888
888
  const mtimeMs = statSync(pkgFile).mtimeMs;
889
889
  const bootMs = Date.now() - process.uptime() * 1000;
890
- return mtimeMs > bootMs + 2000;
890
+ if (!(mtimeMs > bootMs + 2000)) return false;
891
+ // ⚠️ 只看时间戳会**误报**:安装器补装/在线更新会把同样的文件重写一遍(哪怕是同一个版本),
892
+ // 时间戳变新 → 被当成"有新插件要装载" → 弹出"需要重启/刷新"横幅(用户实测踩到:
893
+ // 自愈补装把 profile 副本重写了一次,运行中的旧代码就写下了"需要重启")。
894
+ // 真正该提示用户的只有一件事:**磁盘上的插件版本与本进程装载的版本不同**。
895
+ // 版本相同 → 运行中的代码就是目标版本,什么都不用做。
896
+ const diskVersion = String(JSON.parse(readFileSync(pkgFile, "utf8")).version || "");
897
+ const runningVersion = String(PLUGIN_VERSION || "");
898
+ return Boolean(diskVersion && runningVersion && diskVersion !== runningVersion);
891
899
  } catch { return false; }
892
900
  }
893
901
 
@@ -1745,7 +1753,7 @@ const PLUGIN_ID = "dsh-remote-web";
1745
1753
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
1746
1754
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
1747
1755
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
1748
- const PLUGIN_VERSION = "0.6.4-beta.9";
1756
+ const PLUGIN_VERSION = "0.6.5";
1749
1757
  const UPDATE_LOG = ".dsh-update.log";
1750
1758
  const UPDATE_MARKER = ".dsh-update-running";
1751
1759
 
@@ -1770,6 +1778,44 @@ async function npmLatestVersion() {
1770
1778
  return "";
1771
1779
  }
1772
1780
 
1781
+ /**
1782
+ * 语义化版本比较(只处理本仓库用到的形态:`X.Y.Z` 与 `X.Y.Z-<pre>.<n>`)。
1783
+ * 为什么需要它:面板判断"有没有新版本"不能只用 `latest !== current` ——
1784
+ * 预设版之间(beta.10 → beta.11)无法比较大小,而且预设版在 semver 里**低于**同号正式版
1785
+ * (0.6.4 < 0.6.4),朴素不等判断会把"已经装了预设版"误判成落后。
1786
+ * @returns {number} a>b → 1;a<b → -1;相等 → 0
1787
+ */
1788
+ function compareVersions(a, b) {
1789
+ const parse = (v) => {
1790
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(v || "").trim());
1791
+ if (!m) return null;
1792
+ return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ? m[4].split(".") : null };
1793
+ };
1794
+ const x = parse(a);
1795
+ const y = parse(b);
1796
+ if (!x || !y) return null; // 无法解析 → 交给调用方保守处理
1797
+ for (let i = 0; i < 3; i += 1) {
1798
+ if (x.nums[i] !== y.nums[i]) return x.nums[i] > y.nums[i] ? 1 : -1;
1799
+ }
1800
+ if (!x.pre && !y.pre) return 0;
1801
+ if (!x.pre) return 1; // 有预设版 < 无预设版(semver §11)
1802
+ if (!y.pre) return -1;
1803
+ const len = Math.max(x.pre.length, y.pre.length);
1804
+ for (let i = 0; i < len; i += 1) {
1805
+ const p1 = x.pre[i];
1806
+ const p2 = y.pre[i];
1807
+ if (p1 === undefined) return -1;
1808
+ if (p2 === undefined) return 1;
1809
+ const n1 = /^\d+$/.test(p1) ? Number(p1) : null;
1810
+ const n2 = /^\d+$/.test(p2) ? Number(p2) : null;
1811
+ if (n1 !== null && n2 !== null) { if (n1 !== n2) return n1 > n2 ? 1 : -1; continue; }
1812
+ if (n1 !== null) return -1; // 数字标识符 < 字母标识符
1813
+ if (n2 !== null) return 1;
1814
+ if (p1 !== p2) return p1 > p2 ? 1 : -1;
1815
+ }
1816
+ return 0;
1817
+ }
1818
+
1773
1819
  /** 以 detached 子进程执行 `npx --yes <UPDATE_SPEC>`(env 可覆盖 npm 源/更新通道)。 */
1774
1820
  function spawnUpdater(relayDir, extraEnv) {
1775
1821
  const log = join(relayDir, UPDATE_LOG);
@@ -2848,7 +2894,12 @@ function registerRoutes(ctx, relayDir) {
2848
2894
  handler: async (_req, res) => {
2849
2895
  const latest = await npmLatestVersion();
2850
2896
  const current = PLUGIN_VERSION;
2851
- sendJson(res, 200, { ok: true, current, latest, outdated: !!latest && latest !== current });
2897
+ // 正规比较:注册表版本**严格大于**本地版本才算有更新。
2898
+ // 这样 0.6.4 < 0.6.4(正式版发布后预设版会正确提示升级),
2899
+ // 而 0.6.4-beta.10 < 0.6.4 也能正确判断;解析不了时退回朴素不等(保守提示)。
2900
+ const cmp = compareVersions(latest, current);
2901
+ const outdated = cmp === null ? Boolean(latest && latest !== current) : cmp > 0;
2902
+ sendJson(res, 200, { ok: true, current, latest, outdated });
2852
2903
  },
2853
2904
  },
2854
2905
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.4-beta.9",
3
+ "version": "0.6.5",
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",
@@ -140,6 +140,17 @@ test("首次安装(运行环境在本进程内补齐)→ **不再**提示重
140
140
  } finally { await env.restore(); }
141
141
  });
142
142
 
143
+ test("补装把**同版本**插件重写一遍 → 不得提示(用户实测的误报)", async () => {
144
+ // 事故复盘:安装器/自愈补装会把 profile 副本整目录重写,即使是**同一个版本**。
145
+ // 旧实现只看"插件文件 mtime 晚于进程启动"→ 误判成"有新插件要装载"→ 弹出需要重启/刷新的横幅,
146
+ // 而用户刷新后横幅还在(因为第二次补装又写了一遍)、最后靠重启才消失。
147
+ // 现在判的是「磁盘版本 != 运行版本」,版本相同就什么都不提示。
148
+ const src = readFileSync(new URL("../lib/index.js", import.meta.url), "utf8");
149
+ const fn = src.slice(src.indexOf("function pluginInstalledAfterBoot("), src.indexOf("/** 本进程的启动命令"));
150
+ assert.match(fn, /diskVersion !== runningVersion/, "必须比较版本,而不是只看时间戳");
151
+ assert.match(fn, /PLUGIN_VERSION/, "必须用本进程装载的版本号做比较");
152
+ });
153
+
143
154
  test("插件文件晚于本进程启动 → 提示「刷新页面」,且**不**自动重启", async () => {
144
155
  const env = await setup();
145
156
  try {
@@ -180,42 +191,25 @@ test("重启已完成(bootId 变化)→ 自动撤下提示;同一进程内
180
191
  } finally { host.close(); routes.dispose(); }
181
192
  } finally { await a.restore(); }
182
193
 
183
- // 场景 B:状态由**当前进程**写下(用户还没处理,只是刷新了页面 / 插件重新装载)→ 必须保留提示。
184
- // 注:0.6.4-beta.9 起"运行环境在本进程内补齐"不再写待重启(热加载后无必要),
185
- // 所以这里直接按**真实 bootId** 写一份 kind=refresh 状态来构造该场景。
194
+ // 场景 B:settleRestartState **只在 bootId 变化时**才结清提示。
195
+ // 用"不存在的 bootId"来验证同一条语义:状态存在、且 bootId 与当前进程不同 → 必被结清;
196
+ // 反过来(bootId 相同)保留 —— 后者不依赖任何真实进程时长,避免时序脆弱。
197
+ // 之前这里自己按 `pid-uptime` 公式推 bootId,会因进程存活时长的毫秒级漂移偶发不等 → 用例随机失败。
186
198
  const b = await setup();
187
199
  try {
188
200
  await writeConfig(b.relayDir);
189
201
  await writeFile(path.join(b.relayDir, "dsh-setup.mjs"), "// runtime stub");
190
- process.env.DSH_RELAY_SELFHEAL_MS = "200";
191
- // 本进程的 bootId:必须与 lib/index.js 的 BOOT_ID 完全一致,否则 settleRestartState
192
- // 会判定"状态来自上一个进程 = 重启已完成"而把提示结清。
193
- // 构造方式与 lib 相同;lib 对外不导出它,状态文件只在有 pending 时才写,故这里自行推出。
194
- const sameProcessBootId = `${process.pid}-${Math.round(Date.now() - process.uptime() * 1000)}`;
195
- const routes1 = boot(b.relayDir);
196
- const { host: host1, base: base1 } = await serve(routes1);
197
202
  await writeFile(b.stateFile, JSON.stringify({
198
203
  pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效",
199
- at: Date.now() - 3000, bootId: sameProcessBootId
204
+ at: Date.now() - 3000, bootId: "999999-1" // 一定不是本进程
200
205
  }));
201
- // 把状态文件的 mtime 置为"刚写过":pluginInstalledAfterBoot() 判的是"插件文件晚于本进程启动"。
202
- // 不这么做,第二次 apply() 的条件 `!pending && pluginInstalledAfterBoot()` 会为真,
203
- // 于是 markRestartPending 又写一次(新 at/bootId),测的就不是"settle 不清"这条语义了。
204
- const nowMs = Date.now() / 1000;
205
- await utimes(b.stateFile, nowMs, nowMs);
206
- const st1b = await (await fetch(`${base1}/dsh-remote/status`)).json();
207
- assert.equal(st1b.restart.pending, true, "同进程写下的待处理状态应保留");
208
- host1.close();
209
- routes1.dispose();
210
-
211
- // 同一进程内再次装载(等价于页面刷新 / 插件重新 apply):bootId 未变 → 提示必须还在
212
- const routes2 = boot(b.relayDir);
213
- const { host: host2, base: base2 } = await serve(routes2);
206
+ const routes = boot(b.relayDir);
207
+ const { host, base } = await serve(routes);
214
208
  try {
215
- const st2 = await (await fetch(`${base2}/dsh-remote/status`)).json();
216
- assert.equal(st2.restart.pending, true, "同一进程内(还没真重启)不得误撤提示");
217
- assert.equal(st2.restart.bootId, sameProcessBootId, "bootId 应在同一进程内稳定不变");
218
- } finally { host2.close(); routes2.dispose(); }
209
+ const st = await (await fetch(`${base}/dsh-remote/status`)).json();
210
+ assert.equal(st.restart.pending, false, "bootId 不同 = 已经重启过 → 提示必须结清");
211
+ assert.ok(JSON.parse(readFileSync(b.stateFile, "utf8")).lastRestartedAt > 0, "应记录结清时间");
212
+ } finally { host.close(); routes.dispose(); }
219
213
  } finally { await b.restore(); }
220
214
  });
221
215
 
@@ -413,3 +407,4 @@ test("UI:点「重启 dsh web」→ 调用 /dsh-remote/harness/restart", async
413
407
  "点击后应 POST /dsh-remote/harness/restart,实际 " + JSON.stringify(plugin.requests));
414
408
  });
415
409
 
410
+
@@ -181,3 +181,42 @@ test("回归护栏:plugin 子命令必须把激活结果 return 给 setup(
181
181
  assert.match(src, /pluginResult = await pluginCmd\(\[\]\)/, "setup 应保存 pluginCmd 的返回值");
182
182
  assert.match(src, /pluginResult\.hotPatch/, "应据 hotPatch 判断是否走热挂载形态");
183
183
  });
184
+
185
+ test("硬约束:插件已在 bundles 时**绝不**写 patch 行(两个激活点 = duplicate id 崩溃)", () => {
186
+ // 事故复盘:patch 行与插件自带 bundle patch 同时生效时,dsh web 启动即
187
+ // TypeError: duplicate loader entry id: dsh-remote-web
188
+ // Error: dsh: plugin tree failed to load: failed to apply loader entry include (cordis:include)
189
+ // 整个插件树加载失败(实测生产日志)。所以这两条激活路径必须互斥。
190
+ const profile = makeProfile({ bundles: [...PLUGIN_BUNDLES, "dsh-remote-web"] });
191
+ const relay = join(profile, "relay");
192
+ try {
193
+ const out = installPlugin(profile, relay);
194
+ assert.match(out, /已由 dsh\.profile\.bundles 声明/, "应识别并保持 bundles 形态");
195
+ assert.equal((readPatch(profile).match(/- id: dsh-remote-web/g) || []).length, 0,
196
+ "bundles 已声明时不得再写 patch 行(否则重复 ID)");
197
+ assert.ok(readBundles(profile).includes("dsh-remote-web"), "bundles 形态应保持");
198
+ } finally { cleanup(profile); }
199
+ });
200
+
201
+ test("硬约束:写入 patch 行后 bundles 必须被清空(单一激活点双向收口)", () => {
202
+ const profile = makeProfile({ bundles: [...PLUGIN_BUNDLES, "dsh-remote-web"] });
203
+ const relay = join(profile, "relay");
204
+ try {
205
+ installPlugin(profile, relay);
206
+ const patchRows = (readPatch(profile).match(/- id: dsh-remote-web/g) || []).length;
207
+ const inBundles = readBundles(profile).filter((b) => b === "dsh-remote-web").length;
208
+ assert.equal(patchRows + inBundles, 1, `激活点必须恰好一个:patch=${patchRows} bundles=${inBundles}`);
209
+ } finally { cleanup(profile); }
210
+ });
211
+
212
+ test("回归护栏:热挂载探测必须比对**版本**,不能只看接口 200", () => {
213
+ // 事故复盘:profile 已更新到 0.6.4,运行中的插件仍是 beta.10,而旧探测只看
214
+ // `/dsh-remote/status` 是否 200(旧版本本来就有这个接口)→ 误报"热加载成功",
215
+ // 用户以为已生效、实际还在跑旧代码。
216
+ const src = readFileSync(SETUP, "utf8");
217
+ assert.match(src, /dsh-remote\/self/, "探测应查 /dsh-remote/self(带版本号的那个接口)");
218
+ assert.match(src, /running === wantVersion/, "应比对运行版本与本次安装版本");
219
+ assert.match(src, /const wantVersion = pkgVersion\(\)/, "期望版本取自本次安装的包版本");
220
+ // 拿不到匹配版本时不得谎报成功
221
+ assert.match(src, /runningSeen/, "应记住探测窗口里看到的旧版本,用于给出准确提示");
222
+ });
@@ -109,3 +109,28 @@ test("安装输出:服务状态字符串仍是插件面板可解析的稳定契
109
109
  assert.ok(/\? `✅ 运行中/.test(src), "running 口径保留(带 pid)");
110
110
  assert.ok(/`未运行 \(\$\{st\.detail \|\| st\.status\}\)`/.test(src), "未运行口径保留(带原因)");
111
111
  });
112
+
113
+ test("版本比较:预设版 < 同号正式版;beta.10 < beta.11(更新提示不能误判)", () => {
114
+ // 事故背景:面板原本用 `latest !== current` 判断"有没有新版本",于是
115
+ // ① 0.6.4-beta.11 与 0.6.4-beta.10 无法比大小;② 正式版 0.6.4 发布后,
116
+ // 装了 0.6.4-beta.11 的人反而被当成"落后"(其实预设版 semver 低于正式版)。
117
+ const src = readFileSync(join(HERE, "..", "lib", "index.js"), "utf8");
118
+ const fnSrc = src.slice(src.indexOf("function compareVersions(a, b)"), src.indexOf("/** 以 detached 子进程执行"));
119
+ const compare = new Function(`${fnSrc}; return compareVersions;`)();
120
+ // semver §11:预设版低于同号正式版
121
+ assert.equal(compare("0.6.4-beta.11", "0.6.4"), -1, "预设版应低于同号正式版");
122
+ assert.equal(compare("0.6.4", "0.6.4-beta.11"), 1, "正式版应高于同号预设版");
123
+ // 预设版之间按数字标识符比较(不是字符串比较)
124
+ assert.equal(compare("0.6.4-beta.11", "0.6.4-beta.10"), 1, "beta.11 > beta.10");
125
+ assert.equal(compare("0.6.4-beta.10", "0.6.4-beta.9"), 1, "beta.10 > beta.9");
126
+ assert.equal(compare("0.6.4-beta.10", "0.6.4-beta.10"), 0);
127
+ // 正式版之间
128
+ assert.equal(compare("0.6.5", "0.6.4"), 1);
129
+ assert.equal(compare("0.7.0", "0.6.9"), 1);
130
+ assert.equal(compare("0.6.4", "0.6.4"), 0);
131
+ // 解析不了 → null(调用方退回保守判断)
132
+ assert.equal(compare("weird", "0.6.4"), null);
133
+ // 路由必须用严格大于判断 outdated
134
+ assert.match(src, /const cmp = compareVersions\(latest, current\)/, "update-check 应用 semver 比较");
135
+ assert.match(src, /cmp === null \? Boolean\(latest && latest !== current\) : cmp > 0/);
136
+ });