@mrrisega/dsh-remote 0.6.4 → 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("");
@@ -1127,6 +1152,35 @@ function removeBundleEntry(pkgFile) {
1127
1152
  * 两条路都**只保留一个激活点**:写 patch 就同时从 bundles 移除,反之清掉 include,
1128
1153
  * 绝不两处并存(那正是历史上 dsh web 启动即报「重复 ID」崩溃的原因)。
1129
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
+
1130
1184
  function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, patch) {
1131
1185
  const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
1132
1186
  const legacyDep = PLUGIN_LEGACY_IDS.map((id) => pkg.dependencies && pkg.dependencies[id]).find((v) => v !== undefined);
@@ -1137,10 +1191,24 @@ function convergePluginActivation(profileDir, pkgFile, patchFile, pluginDir, pat
1137
1191
  const managedByUs = !dep || String(dep).startsWith("file:"); // 无依赖或 file: 拷贝 → 我们管
1138
1192
 
1139
1193
  if (marketManaged && inBundles) {
1140
- // 插件市场安装形态:依赖与源码归市场管,我们只清历史 include(若旧版曾写过)
1194
+ // 插件市场安装形态:源码归市场/包管理器管 → 只清历史 include,但**必须主动升级到最新**,
1195
+ // 否则「一键更新」会报成功却什么都没做(2026-09-13 实测)。
1141
1196
  stripIncludeEntries(patchFile, patch, "插件市场安装形态无需用户 include");
1142
- console.log("ℹ 插件市场安装形态(bundles+dependency):已保持市场管理的源码不变。");
1143
- 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
+ }
1144
1212
  return;
1145
1213
  }
1146
1214
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.4",
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",
@@ -1753,7 +1753,7 @@ const PLUGIN_ID = "dsh-remote-web";
1753
1753
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
1754
1754
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
1755
1755
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
1756
- const PLUGIN_VERSION = "0.6.4";
1756
+ const PLUGIN_VERSION = "0.6.5";
1757
1757
  const UPDATE_LOG = ".dsh-update.log";
1758
1758
  const UPDATE_MARKER = ".dsh-update-running";
1759
1759
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.4",
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",
@@ -191,42 +191,25 @@ test("重启已完成(bootId 变化)→ 自动撤下提示;同一进程内
191
191
  } finally { host.close(); routes.dispose(); }
192
192
  } finally { await a.restore(); }
193
193
 
194
- // 场景 B:状态由**当前进程**写下(用户还没处理,只是刷新了页面 / 插件重新装载)→ 必须保留提示。
195
- // 注:0.6.4-beta.9 起"运行环境在本进程内补齐"不再写待重启(热加载后无必要),
196
- // 所以这里直接按**真实 bootId** 写一份 kind=refresh 状态来构造该场景。
194
+ // 场景 B:settleRestartState **只在 bootId 变化时**才结清提示。
195
+ // 用"不存在的 bootId"来验证同一条语义:状态存在、且 bootId 与当前进程不同 → 必被结清;
196
+ // 反过来(bootId 相同)保留 —— 后者不依赖任何真实进程时长,避免时序脆弱。
197
+ // 之前这里自己按 `pid-uptime` 公式推 bootId,会因进程存活时长的毫秒级漂移偶发不等 → 用例随机失败。
197
198
  const b = await setup();
198
199
  try {
199
200
  await writeConfig(b.relayDir);
200
201
  await writeFile(path.join(b.relayDir, "dsh-setup.mjs"), "// runtime stub");
201
- process.env.DSH_RELAY_SELFHEAL_MS = "200";
202
- // 本进程的 bootId:必须与 lib/index.js 的 BOOT_ID 完全一致,否则 settleRestartState
203
- // 会判定"状态来自上一个进程 = 重启已完成"而把提示结清。
204
- // 构造方式与 lib 相同;lib 对外不导出它,状态文件只在有 pending 时才写,故这里自行推出。
205
- const sameProcessBootId = `${process.pid}-${Math.round(Date.now() - process.uptime() * 1000)}`;
206
- const routes1 = boot(b.relayDir);
207
- const { host: host1, base: base1 } = await serve(routes1);
208
202
  await writeFile(b.stateFile, JSON.stringify({
209
203
  pending: true, kind: "refresh", reason: "插件已更新,刷新页面即可生效",
210
- at: Date.now() - 3000, bootId: sameProcessBootId
204
+ at: Date.now() - 3000, bootId: "999999-1" // 一定不是本进程
211
205
  }));
212
- // 把状态文件的 mtime 置为"刚写过":pluginInstalledAfterBoot() 判的是"插件文件晚于本进程启动"。
213
- // 不这么做,第二次 apply() 的条件 `!pending && pluginInstalledAfterBoot()` 会为真,
214
- // 于是 markRestartPending 又写一次(新 at/bootId),测的就不是"settle 不清"这条语义了。
215
- const nowMs = Date.now() / 1000;
216
- await utimes(b.stateFile, nowMs, nowMs);
217
- const st1b = await (await fetch(`${base1}/dsh-remote/status`)).json();
218
- assert.equal(st1b.restart.pending, true, "同进程写下的待处理状态应保留");
219
- host1.close();
220
- routes1.dispose();
221
-
222
- // 同一进程内再次装载(等价于页面刷新 / 插件重新 apply):bootId 未变 → 提示必须还在
223
- const routes2 = boot(b.relayDir);
224
- const { host: host2, base: base2 } = await serve(routes2);
206
+ const routes = boot(b.relayDir);
207
+ const { host, base } = await serve(routes);
225
208
  try {
226
- const st2 = await (await fetch(`${base2}/dsh-remote/status`)).json();
227
- assert.equal(st2.restart.pending, true, "同一进程内(还没真重启)不得误撤提示");
228
- assert.equal(st2.restart.bootId, sameProcessBootId, "bootId 应在同一进程内稳定不变");
229
- } 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(); }
230
213
  } finally { await b.restore(); }
231
214
  });
232
215
 
@@ -424,3 +407,4 @@ test("UI:点「重启 dsh web」→ 调用 /dsh-remote/harness/restart", async
424
407
  "点击后应 POST /dsh-remote/harness/restart,实际 " + JSON.stringify(plugin.requests));
425
408
  });
426
409
 
410
+
@@ -208,3 +208,15 @@ test("硬约束:写入 patch 行后 bundles 必须被清空(单一激活点
208
208
  assert.equal(patchRows + inBundles, 1, `激活点必须恰好一个:patch=${patchRows} bundles=${inBundles}`);
209
209
  } finally { cleanup(profile); }
210
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
+ });