@mrrisega/dsh-remote 0.7.12-beta.6 → 0.8.1-beta.1

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.
Files changed (25) hide show
  1. package/clients/dsh-remote/apply-pending-plugin.mjs +171 -0
  2. package/clients/dsh-remote/dingtalk-bot.mjs +34 -31
  3. package/clients/dsh-remote/feishu-bot.mjs +26 -1
  4. package/clients/dsh-remote/im-format.mjs +105 -0
  5. package/clients/dsh-remote/test/dingtalk-bot.test.mjs +79 -0
  6. package/clients/dsh-remote/test/feishu-bot.test.mjs +75 -2
  7. package/clients/dsh-remote/test/im-format.test.mjs +60 -0
  8. package/clients/dsh-remote/test/wecom-bot.test.mjs +67 -89
  9. package/clients/dsh-remote/wecom-bot.mjs +54 -100
  10. package/dsh-setup.mjs +1 -1
  11. package/package.json +2 -2
  12. package/packages/dsh-remote-web/lib/client.js +25 -1
  13. package/packages/dsh-remote-web/lib/index.js +130 -4
  14. package/packages/dsh-remote-web/lib/live-update.js +238 -0
  15. package/packages/dsh-remote-web/lib/self-update.js +1204 -0
  16. package/packages/dsh-remote-web/package.json +1 -1
  17. package/packages/dsh-remote-web/runtime/clients/dsh-remote/apply-pending-plugin.mjs +171 -0
  18. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dingtalk-bot.mjs +283 -63
  19. package/packages/dsh-remote-web/runtime/clients/dsh-remote/feishu-bot.mjs +127 -1
  20. package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-commands.mjs +302 -0
  21. package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-format.mjs +105 -0
  22. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +138 -102
  23. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +1 -1
  24. package/packages/dsh-remote-web/test/live-update.test.mjs +114 -0
  25. package/packages/dsh-remote-web/test/self-update.test.mjs +920 -0
@@ -27,6 +27,7 @@ import { randomUUID } from "node:crypto";
27
27
  import { homedir, tmpdir } from "node:os";
28
28
  import { fileURLToPath } from "node:url";
29
29
  import { buildFixPrompt, REPORT_TITLE, REPORT_FORMAT, REPORT_KEYS } from "./fix-prompt.js";
30
+ import { prepareLiveUpdate, reconcilePluginLive, readPluginPackage as readLivePluginPackage, restoreFromBackup } from "./live-update.js";
30
31
 
31
32
  /** 本插件在 host 侧的服务依赖。 */
32
33
  export const inject = ["webServer"];
@@ -3162,11 +3163,36 @@ function writeRestartState(relayDir, state) {
3162
3163
  * 标记「需要用户动一下」的状态。kind = refresh(插件被运行时改写,刷新页面即可)。
3163
3164
  * 历史 kind(first-install / update)已废弃:热加载落地后,装插件与在线更新都不再需要重启 harness。
3164
3165
  */
3165
- /** 桌面端「暫存插件更新」marker(dsh-setup 在应用运行时暂存;bridge 在应用退出后自动安装)。 */
3166
+ /** 当前已装插件半的版本(读 profile 里那份 package.json;读不到返回空串)。 */
3167
+ function installedPluginVersion() {
3168
+ try {
3169
+ const pkg = readLivePluginPackage(join(pluginProfileDir(), "dsh-remote-web-plugin"));
3170
+ return pkg ? String(pkg.version || "") : "";
3171
+ } catch {
3172
+ return "";
3173
+ }
3174
+ }
3175
+
3176
+ /**
3177
+ * 桌面端「暂存插件更新」marker(dsh-setup 在应用运行时暂存;bridge 在应用退出后自动安装)。
3178
+ *
3179
+ * 0.8.1 修正(2026-11-11 真机事故):marker 从不与已装版本比较,导致
3180
+ * ① 0.8.0 已经装好了,面板仍永久提示「新版本 0.8.0 已下载,退出后生效」;
3181
+ * ② 陈旧 marker 还可能让 bridge 把已升到的版本再"应用"一次(无版本比较 → 静默降级)。
3182
+ * 现在:marker 版本 ≤ 已装版本 → 视为陈旧,删 marker 并返回 null。
3183
+ */
3166
3184
  function readDesktopUpdatePending(relayDir) {
3185
+ const markerPath = join(relayDir, ".plugin-update-pending.json");
3167
3186
  try {
3168
- const m = JSON.parse(readFileSync(join(relayDir, ".plugin-update-pending.json"), "utf8"));
3169
- return { version: String(m.version || ""), stagedAt: Number(m.stagedAt) || 0 };
3187
+ const m = JSON.parse(readFileSync(markerPath, "utf8"));
3188
+ const version = String(m.version || "");
3189
+ const installed = installedPluginVersion();
3190
+ const cmp = version && installed ? compareVersions(version, installed) : null;
3191
+ if (cmp !== null && cmp <= 0) {
3192
+ try { rmSync(markerPath, { force: true }); } catch { /* ignore */ }
3193
+ return null;
3194
+ }
3195
+ return { version, stagedAt: Number(m.stagedAt) || 0 };
3170
3196
  } catch {
3171
3197
  return null;
3172
3198
  }
@@ -4638,6 +4664,74 @@ async function resolveUpdateTarget() {
4638
4664
  }
4639
4665
  }
4640
4666
 
4667
+ /* ── 0.8.1:插件半「立即升级」(换文件 + 宿主 HMR 重新 import,不再等"下次打开") ──────────────
4668
+ *
4669
+ * 为什么必须新增这条路(2026-11-11 真机审计,见 dsh-relay-internal/.../09-升级失败根因与改造点.md):
4670
+ * 旧链路在桌面端只会把新插件暂存到 `~/.dsh-remote/pending-plugin`,唯一落地者是 bridge 的 15s
4671
+ * watcher,而它要求「应用已经关掉 ≥30s」;应用一直开着 → marker 永远不清、用户只能退出后再 npx。
4672
+ * 宿主本身有活体通道:`hmr.reconcileProfile()` 会按 ino:size:mtime 比对已加载模块、驱逐 ESM 缓存
4673
+ * 并用磁盘新文件重新 import;所以正确做法是「逐文件原子换版 + reconcile」,而不是整目录 rename
4674
+ * (整目录会被判成 package-moved → 要求重启应用),更不能原地写半成品(HMR 重新 import 时炸掉
4675
+ * 宿主子进程,Electron 弹「启动失败」=用户看到的闪退)。
4676
+ */
4677
+
4678
+ /** 准备插件半升级:下载(镜像优先)→ 备份 → 逐文件原子替换。不触发 HMR。 */
4679
+ async function runLivePluginUpdate(relayDir) {
4680
+ const target = await resolveUpdateTarget();
4681
+ const profileDir = pluginProfileDir();
4682
+ const prep = await prepareLiveUpdate({
4683
+ relayDir,
4684
+ profileDir,
4685
+ tag: target.tag,
4686
+ version: target.version,
4687
+ });
4688
+ if (!prep.ok) {
4689
+ appendLogLine(relayDir, UPDATE_LOG,
4690
+ `[update] 插件半立即升级未执行:${prep.code || "UNKNOWN"} ${prep.detail || ""}`.trim());
4691
+ return prep;
4692
+ }
4693
+ appendLogLine(relayDir, UPDATE_LOG,
4694
+ `[update] 插件半已原子换版 ${prep.fromVersion} → ${prep.toVersion}(源 ${prep.registry || "?"};${prep.files} 文件;${prep.ms}ms;备份 ${prep.backupDir})`);
4695
+ return prep;
4696
+ }
4697
+
4698
+ /**
4699
+ * 响应之后异步触发 HMR 重新加载;成功则无需重启应用(页面刷新即可看到新版本)。
4700
+ * 失败时回滚文件并尽量把旧版重新挂上(保证"升不上去也不会把插件弄挂")。
4701
+ */
4702
+ function scheduleLiveReconcile(ctx, relayDir, prep) {
4703
+ const timer = setTimeout(async () => {
4704
+ const r = await reconcilePluginLive(ctx);
4705
+ if (r.applied) {
4706
+ appendLogLine(relayDir, UPDATE_LOG, `[update] 宿主 HMR 已重新加载插件 ${prep.toVersion}(无需重启应用)`);
4707
+ telemetryRecord(relayDir, "update_applied_live");
4708
+ try { settleRestartState(relayDir); } catch { /* ignore */ }
4709
+ // 运行环境/桥半仍按原链路更新(独立进程,不阻塞面板;失败不影响已生效的插件半)
4710
+ try { runOnlineUpdate(relayDir).catch(() => {}); } catch { /* ignore */ }
4711
+ return;
4712
+ }
4713
+ const installed = installedPluginVersion();
4714
+ if (r.restartReason) {
4715
+ const reason = `插件 ${prep.toVersion} 已就位,需要重启 DeepSeek Harness 生效(${r.restartReason})`;
4716
+ markRestartPending(relayDir, "restart-app", reason);
4717
+ appendLogLine(relayDir, UPDATE_LOG, `[update] 文件已更新,但宿主要求重启:${r.restartReason}`);
4718
+ return;
4719
+ }
4720
+ // 兜底:回滚文件 → 再 reconcile 一次,让旧版回到运行态
4721
+ try {
4722
+ restoreFromBackup(prep.backupDir, join(pluginProfileDir(), "dsh-remote-web-plugin"));
4723
+ appendLogLine(relayDir, UPDATE_LOG, `[update] 热加载失败,已回滚 ${prep.toVersion} → ${installed || prep.fromVersion}`);
4724
+ } catch (e) {
4725
+ appendLogLine(relayDir, UPDATE_LOG, `[update] 热加载失败且回滚失败:${e.message}`);
4726
+ }
4727
+ noteUpdateFailure(relayDir, `新版本已下载但热加载失败:${r.error || "未知"}(可重试;必要时重启应用)`, "hmr_reconcile_failed");
4728
+ telemetryRecord(relayDir, "update_failed", { fail_code: "hmr_reconcile_failed" });
4729
+ try { await reconcilePluginLive(ctx); } catch { /* ignore */ }
4730
+ }, 250);
4731
+ if (timer && typeof timer.unref === "function") timer.unref();
4732
+ return timer;
4733
+ }
4734
+
4641
4735
  /**
4642
4736
  * 语义化版本比较(只处理本仓库用到的形态:`X.Y.Z` 与 `X.Y.Z-<pre>.<n>`)。
4643
4737
  * 为什么需要它:面板判断"有没有新版本"不能只用 `latest !== current` ——
@@ -8349,7 +8443,39 @@ function registerRoutes(ctx, relayDir, opts = {}) {
8349
8443
  method: "POST",
8350
8444
  path: "/dsh-remote/self/update",
8351
8445
  handler: async (_req, res) => {
8352
- sendJson(res, 200, { ok: true, ...(await runOnlineUpdate(relayDir)) });
8446
+ // 0.8.1:先走「立即升级」——下载(镜像优先)+ 逐文件原子换版,再让宿主 HMR 重新 import。
8447
+ // 成功:不需要退出应用、不需要 npx;页面轮询到新版本后自动刷新。
8448
+ const live = await runLivePluginUpdate(relayDir)
8449
+ .catch((e) => ({ ok: false, code: "LIVE_UPDATE_ERROR", detail: String((e && e.message) || e) }));
8450
+ if (live.ok) {
8451
+ telemetryRecord(relayDir, "update_started");
8452
+ scheduleLiveReconcile(ctx, relayDir, live);
8453
+ sendJson(res, 200, {
8454
+ ok: true,
8455
+ mode: "live",
8456
+ fromVersion: live.fromVersion,
8457
+ toVersion: live.toVersion,
8458
+ requiresReload: true,
8459
+ registry: live.registry || "",
8460
+ detail: `已换版 ${live.fromVersion} → ${live.toVersion}(${live.registry || "registry"}),宿主正在热加载`,
8461
+ });
8462
+ return;
8463
+ }
8464
+ // 立即升级走不通(已是最新 / 网络失败 / 无 HMR / 拒绝降级)→ 记录后回退原 npx 链路,行为与旧版一致
8465
+ appendLogLine(relayDir, UPDATE_LOG,
8466
+ `[update] 立即升级未成功(${live.code || "UNKNOWN"}: ${live.detail || ""}),回退在线安装链路`.trim());
8467
+ // 已是最新(或要装的是更低版本):不要为了"做事"再去跑一遍 npx —— 那会把同版本重装一次,还可能触发暂存。
8468
+ if (live.code === "VERSION_UNCHANGED" || live.code === "DOWNGRADE_REFUSED") {
8469
+ sendJson(res, 200, { ok: true, mode: "current", detail: live.detail || "已是最新版本", liveCode: live.code });
8470
+ return;
8471
+ }
8472
+ sendJson(res, 200, {
8473
+ ok: true,
8474
+ ...(await runOnlineUpdate(relayDir)),
8475
+ liveFallback: true,
8476
+ liveCode: live.code || "",
8477
+ liveDetail: live.detail || "",
8478
+ });
8353
8479
  },
8354
8480
  },
8355
8481
  {
@@ -0,0 +1,238 @@
1
+ /**
2
+ * 插件半「立即升级」:下载新版本 → 原子换文件 → 让宿主 HMR 重新 import。
3
+ *
4
+ * 为什么需要这个模块(2026-11-11 真机审计结论):
5
+ * · 现有「一键更新」在桌面端只会把新插件**暂存**到 `~/.dsh-remote/pending-plugin`,而唯一
6
+ * 落地者(bridge 的 15s watcher)要求「应用已经关掉 ≥30s」——应用一直开着就永远不落地,
7
+ * 用户只能退出应用再自己 `npx`。
8
+ * · 宿主其实提供了活体升级通道:`hmr.reconcileProfile()` 会按 `ino:size:mtime` 比对本插件
9
+ * 已加载的模块文件,驱逐 ESM 缓存并用磁盘上的新代码重新 import(见 @deepseek-ai/dsh-hmr
10
+ * lib/index.js 的 reconcileProfile 注释)。所以正确做法是「把新版本文件原地换掉 + 触发 reconcile」,
11
+ * 而不是「整目录 rename」——整目录会被判成 package-moved,要求重启应用。
12
+ * · 换文件必须逐文件 temp+rename(不能原地写半成品):HMR 在重新 import 时读到半个文件会让
13
+ * 宿主插件抛出、宿主子进程退出,Electron 随即弹「启动失败」= 用户看到的闪退。
14
+ *
15
+ * 这个模块只做文件与备份,不依赖宿主服务;reconcile 由调用方用 ctx.get('hmr') 执行。
16
+ */
17
+
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { fetchTarballToStaging, resolveRegistryOrder } from "./self-update.js";
21
+
22
+ /** 插件目录名(profile 内)。历史名 dsh-remote-ui-plugin 也认。 */
23
+ export const PLUGIN_DIR_NAMES = Object.freeze(["dsh-remote-web-plugin", "dsh-remote-ui-plugin"]);
24
+ /** npm tarball 里插件半相对路径(解包时已剥掉 tarball 顶层 package/ 前缀)。 */
25
+ export const TARBALL_PLUGIN_SUBDIR = path.posix.join("packages", "dsh-remote-web");
26
+ /** 必需文件(缺任何一个都判为坏包,不落盘)。 */
27
+ export const REQUIRED_FILES = Object.freeze(["package.json", "lib/index.js", "lib/client.js", "lib/tarball-update.js"]);
28
+
29
+ /** semver 比较(与 index.js 的 compareVersions 同义,独立一份避免循环依赖)。 */
30
+ export function compareVersions(a, b) {
31
+ const parse = (v) => {
32
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(v || "").trim());
33
+ if (!m) return null;
34
+ return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ? m[4].split(".") : null };
35
+ };
36
+ const x = parse(a);
37
+ const y = parse(b);
38
+ if (!x || !y) return null;
39
+ for (let i = 0; i < 3; i += 1) if (x.nums[i] !== y.nums[i]) return x.nums[i] > y.nums[i] ? 1 : -1;
40
+ if (!x.pre && !y.pre) return 0;
41
+ if (!x.pre) return 1;
42
+ if (!y.pre) return -1;
43
+ const len = Math.max(x.pre.length, y.pre.length);
44
+ for (let i = 0; i < len; i += 1) {
45
+ const p1 = x.pre[i];
46
+ const p2 = y.pre[i];
47
+ if (p1 === undefined) return -1;
48
+ if (p2 === undefined) return 1;
49
+ if (p1 === p2) continue;
50
+ const n1 = /^\d+$/.test(p1);
51
+ const n2 = /^\d+$/.test(p2);
52
+ if (n1 && n2) return Number(p1) > Number(p2) ? 1 : -1;
53
+ if (n1) return -1;
54
+ if (n2) return 1;
55
+ return p1 > p2 ? 1 : -1;
56
+ }
57
+ return 0;
58
+ }
59
+
60
+ export function readPluginPackage(dir) {
61
+ try {
62
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
63
+ return { name: String(raw.name || ""), version: String(raw.version || "") };
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /** 递归列出目录下所有普通文件(相对路径,POSIX 分隔)。符号链接跳过(安全:不跟随到目录外)。 */
70
+ export function listFiles(root) {
71
+ const out = [];
72
+ const walk = (dir, rel) => {
73
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
74
+ for (const e of entries) {
75
+ const abs = path.join(dir, e.name);
76
+ const r = rel ? `${rel}/${e.name}` : e.name;
77
+ if (e.isSymbolicLink()) continue;
78
+ if (e.isDirectory()) walk(abs, r);
79
+ else if (e.isFile()) out.push(r);
80
+ }
81
+ };
82
+ walk(root, "");
83
+ return out;
84
+ }
85
+
86
+ /** 把目录完整复制成一份备份(保留权限位;不跟随符号链接)。 */
87
+ export function copyTree(srcDir, dstDir) {
88
+ fs.mkdirSync(path.dirname(dstDir), { recursive: true });
89
+ fs.cpSync(srcDir, dstDir, { recursive: true, dereference: false, force: true, errorOnExist: false });
90
+ }
91
+
92
+ /**
93
+ * 逐文件原子替换:新文件先写进目标同目录的 `.tmp-*`,再逐个 rename 覆盖。
94
+ * rename 在同一文件系统内是原子的,HMR 重新 import 时不会看到半个文件。
95
+ * @returns {{files:number, dirs:number}}
96
+ */
97
+ export function replaceFilesInPlace(srcDir, targetDir, { fsImpl = fs } = {}) {
98
+ const files = listFiles(srcDir);
99
+ if (files.length === 0) throw new Error("新版本里没有任何文件");
100
+ let dirs = 0;
101
+ const pending = [];
102
+ for (const rel of files) {
103
+ const src = path.join(srcDir, rel);
104
+ const dst = path.join(targetDir, rel);
105
+ const dstDir = path.dirname(dst);
106
+ if (!fsImpl.existsSync(dstDir)) { fsImpl.mkdirSync(dstDir, { recursive: true }); dirs += 1; }
107
+ const tmp = path.join(dstDir, `.tmp-${path.basename(dst)}-${process.pid}-${Math.random().toString(16).slice(2, 10)}`);
108
+ fsImpl.copyFileSync(src, tmp);
109
+ try { fsImpl.chmodSync(tmp, fsImpl.statSync(src).mode & 0o777); } catch { /* 权限位失败不致命 */ }
110
+ pending.push([tmp, dst]);
111
+ }
112
+ // 先写完全部临时文件,再连续 rename(窗口尽可能短)
113
+ for (const [tmp, dst] of pending) fsImpl.renameSync(tmp, dst);
114
+ return { files: files.length, dirs };
115
+ }
116
+
117
+ /** 旧版本备份目录(profile 内、插件目录外;不影响 HMR 的模块比对)。 */
118
+ export function backupDirFor(profileDir) {
119
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
120
+ return path.join(profileDir, ".dsh-remote-backups", stamp);
121
+ }
122
+
123
+ /** 备份数量上限(保留最近 3 份)。 */
124
+ export function pruneBackups(profileDir, keep = 3) {
125
+ const root = path.join(profileDir, ".dsh-remote-backups");
126
+ let names = [];
127
+ try { names = fs.readdirSync(root).filter((n) => !n.startsWith(".")); } catch { return 0; }
128
+ names.sort();
129
+ let removed = 0;
130
+ for (const n of names.slice(0, Math.max(0, names.length - keep))) {
131
+ try { fs.rmSync(path.join(root, n), { recursive: true, force: true }); removed += 1; } catch { /* ignore */ }
132
+ }
133
+ return removed;
134
+ }
135
+
136
+ /**
137
+ * 准备一次插件半升级:下载 → 校验 → 备份 → 逐文件替换。
138
+ * 不触发 HMR;调用方拿到结果后再决定何时 reconcile(必须在响应发出之后)。
139
+ * @returns {Promise<{ok:boolean, code?:string, detail?:string, fromVersion?:string, toVersion?:string, backupDir?:string, stageRoot?:string, files?:number, registry?:string, registryAttempts?:object[], ms?:number}>}
140
+ */
141
+ export async function prepareLiveUpdate({
142
+ relayDir,
143
+ profileDir,
144
+ targetDir,
145
+ tag = "latest",
146
+ version = "",
147
+ stageDir = path.join(relayDir, "live-update-stage"),
148
+ registries = resolveRegistryOrder(),
149
+ timeoutMs = 20000,
150
+ } = {}) {
151
+ const startedAt = Date.now();
152
+ const dir = targetDir || path.join(profileDir, PLUGIN_DIR_NAMES[0]);
153
+ const before = readPluginPackage(dir);
154
+ if (!before) return { ok: false, code: "TARGET_NOT_FOUND", detail: `没有找到已安装的插件目录:${dir}` };
155
+
156
+ const fetched = await fetchTarballToStaging({ tag, version, stageDir, registries, timeoutMs })
157
+ .catch((e) => ({
158
+ ok: false,
159
+ code: (e && e.code) || "FETCH_FAILED",
160
+ detail: String(e && e.message ? e.message : e),
161
+ attempts: e && e.attempts,
162
+ }));
163
+ if (!fetched || !fetched.ok) {
164
+ return { ok: false, code: (fetched && fetched.code) || "FETCH_FAILED", detail: (fetched && fetched.detail) || "下载新版本失败", attempts: fetched && fetched.attempts, ms: Date.now() - startedAt };
165
+ }
166
+ // self-update 会在 stageRoot 里给出根包(@mrrisega/dsh-remote),其中插件半在 packages/dsh-remote-web;
167
+ // 优先用它的 payloadRoot(若返回),否则按约定路径取。
168
+ const payloadRoot = fetched.payloadRoot || path.join(fetched.stageRoot, TARBALL_PLUGIN_SUBDIR);
169
+ const pkgDir = payloadRoot;
170
+ const next = readPluginPackage(pkgDir);
171
+ if (!next) return { ok: false, code: "BAD_TARBALL", detail: `新版本包里没有插件半:${TARBALL_PLUGIN_SUBDIR}` };
172
+ if (next.name && next.name !== "dsh-remote-web") return { ok: false, code: "NAME_MISMATCH", detail: `包内插件名不是 dsh-remote-web:${next.name}` };
173
+ for (const rel of REQUIRED_FILES) {
174
+ if (!fs.existsSync(path.join(pkgDir, rel))) return { ok: false, code: "BAD_TARBALL", detail: `新版本缺文件:${rel}` };
175
+ }
176
+ const cmp = compareVersions(next.version, before.version);
177
+ if (cmp === 0) return { ok: false, code: "VERSION_UNCHANGED", detail: `已经是最新版本 ${before.version}`, fromVersion: before.version, toVersion: next.version };
178
+ if (cmp !== null && cmp < 0) return { ok: false, code: "DOWNGRADE_REFUSED", detail: `拒绝降级:${before.version} → ${next.version}`, fromVersion: before.version, toVersion: next.version };
179
+
180
+ const backupDir = backupDirFor(profileDir);
181
+ try {
182
+ copyTree(dir, backupDir);
183
+ } catch (e) {
184
+ return { ok: false, code: "BACKUP_FAILED", detail: `备份旧版本失败:${e.message}` };
185
+ }
186
+ try {
187
+ const r = replaceFilesInPlace(pkgDir, dir);
188
+ const installed = readPluginPackage(dir);
189
+ if (!installed || installed.version !== next.version) {
190
+ // 读回来的版本不对 → 立即回滚,避免留下半套
191
+ try { restoreFromBackup(backupDir, dir); } catch { /* ignore */ }
192
+ return { ok: false, code: "VERIFY_FAILED", detail: `替换后版本校验失败:期望 ${next.version},实际 ${installed && installed.version}` };
193
+ }
194
+ pruneBackups(profileDir);
195
+ return {
196
+ ok: true,
197
+ fromVersion: before.version,
198
+ toVersion: installed.version,
199
+ backupDir,
200
+ stageRoot: fetched.stageRoot,
201
+ files: r.files,
202
+ registry: fetched.registry,
203
+ registryAttempts: fetched.attempts,
204
+ ms: Date.now() - startedAt,
205
+ };
206
+ } catch (e) {
207
+ try { restoreFromBackup(backupDir, dir); } catch { /* ignore */ }
208
+ return { ok: false, code: "APPLY_FAILED", detail: `替换文件失败(已回滚):${e.message}` };
209
+ }
210
+ }
211
+
212
+ /** 用备份恢复目标目录(失败路径用;目录整体替换,随后必须重新 reconcile)。 */
213
+ export function restoreFromBackup(backupDir, targetDir) {
214
+ if (!fs.existsSync(backupDir)) throw new Error(`备份不存在:${backupDir}`);
215
+ fs.rmSync(targetDir, { recursive: true, force: true });
216
+ fs.cpSync(backupDir, targetDir, { recursive: true, dereference: false, force: true });
217
+ }
218
+
219
+ /**
220
+ * 让宿主 HMR 重新 import 插件(免重启的核心一步)。
221
+ * 调用方必须在 HTTP 响应已经发出之后调用,避免旧实例被停止时把响应截断。
222
+ * @returns {Promise<{applied:boolean, restartReason?:string, warnings?:string[], error?:string}>}
223
+ */
224
+ export async function reconcilePluginLive(ctx, { entryIds = [] } = {}) {
225
+ let hmr = null;
226
+ try { hmr = typeof ctx.get === "function" ? ctx.get("hmr") : null; } catch { hmr = null; }
227
+ if (!hmr || typeof hmr.runExclusive !== "function" || typeof hmr.reconcileProfile !== "function") {
228
+ return { applied: false, error: "宿主没有 hmr 服务,无法热加载(需要重启应用)" };
229
+ }
230
+ try {
231
+ const result = await hmr.runExclusive(() => hmr.reconcileProfile(entryIds));
232
+ const restartReason = result && result.restartReason;
233
+ const warnings = (result && result.warnings) || [];
234
+ return restartReason ? { applied: false, restartReason, warnings } : { applied: true, warnings };
235
+ } catch (e) {
236
+ return { applied: false, error: String(e && e.message ? e.message : e) };
237
+ }
238
+ }