@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 +107 -13
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/index.js +54 -3
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/harness-restart.test.mjs +23 -28
- package/packages/dsh-remote-web/test/patch-activation.test.mjs +39 -0
- package/packages/dsh-remote-web/test/setup-output.test.mjs +25 -0
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
|
-
//
|
|
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/
|
|
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) {
|
|
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
|
-
|
|
792
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1194
|
+
// 插件市场安装形态:源码归市场/包管理器管 → 只清历史 include,但**必须主动升级到最新**,
|
|
1195
|
+
// 否则「一键更新」会报成功却什么都没做(2026-09-13 实测)。
|
|
1119
1196
|
stripIncludeEntries(patchFile, patch, "插件市场安装形态无需用户 include");
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
185
|
-
//
|
|
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:
|
|
204
|
+
at: Date.now() - 3000, bootId: "999999-1" // 一定不是本进程
|
|
200
205
|
}));
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
216
|
-
assert.equal(
|
|
217
|
-
assert.
|
|
218
|
-
} finally {
|
|
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
|
+
});
|