dsh-plugin-upgrader 1.6.3 → 1.7.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.0 — 2026-11-03
4
+
5
+ - **Every version is judged against the DSH that is actually running, before it is
6
+ offered.** The host refuses an install whose declared DSH range does not admit this
7
+ runtime, and on the older replacement path it refuses *after* the bundle has been
8
+ unloaded — switching a real skin plugin to `0.1.5` (`>=0.1.7-rc.1 <0.1.8-0`) left the
9
+ package removed from the profile with a dangling patch id. Both directions are covered
10
+ now. Going *down* the history on a newer DSH marks the releases that no longer admit
11
+ this runtime and disables their switch button. Going *up* on an older DSH stops
12
+ offering `latest` as an upgrade when that release does not admit this runtime, and the
13
+ row says which range it wants instead.
14
+ - **The runtime version comes from the host's own entry point.** A profile's
15
+ `@deepseek-ai/dsh-app-boot` is a link into an npx cache and measured `0.1.2-rc.1`
16
+ against a runtime that reports `0.2.1-rc.5`; reading the profile would have marked
17
+ installable releases uninstallable. When the version cannot be read, nothing is marked
18
+ — the host stays the judge, and the page only loses a hint.
19
+ - **The rules are the gate's rules.** `engines.dsh` decides alone when present;
20
+ otherwise every `peerDependencies` range on `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*`
21
+ must hold, with `workspace:^`/`~`/`*` meaning "this runtime", prereleases included.
22
+ `src/host/version.ts` implements the matching, checked against `node-semver` over 49
23
+ ranges × 41 versions (2035 pairs, all agreeing). Three inequalities were wrong before
24
+ that check — most visibly `~0.1.7-rc.1` admitting `0.1.6`.
25
+ - **A batch no longer disappears when the plugin replaces itself.** Upgrading another
26
+ plugin is what makes the host re-stage this plugin's own module in a `live` profile, so
27
+ the install a user was watching could evict the code tracking it: the replacement
28
+ instance started with an empty job map, answered `unknown-job` for the id the page was
29
+ polling, and the page reported "lost contact" over an install that finished fine. The
30
+ batch store now lives on `globalThis` under a stable symbol, so whichever module
31
+ instance answers knew the batch. Cleanup only clears the run that owns it.
32
+ - **A version the host would refuse is never installed, and a failed replacement puts the
33
+ old one back.** The page already marked those releases, but a client-side mark cannot
34
+ stop a request, and the host's gate fires after pnpm has rewritten the lockfile — which
35
+ is how a refused switch used to end with the package gone. The step now reads the
36
+ target's declared range *before* touching anything and answers
37
+ `incompatible-version` with the range it wanted, leaving the profile untouched. When a
38
+ failure slips past that (the gate is still the final word), the recovery distinguishes
39
+ the two damages instead of guessing: a package that is still on disk gets re-selected, a
40
+ package that is gone gets the previous version installed again. The outcome carries
41
+ `rolledBack` so the log can say what came back, and a rollback that is itself refused
42
+ says so rather than reporting a clean failure over a missing plugin.
43
+ - **The two ways a restart wait ends badly now say different things.** A pid that never
44
+ changes means the old process is still answering; a pid that *did* change but a page that
45
+ cannot reload onto the new host means another restart. Both used to print the first
46
+ sentence, which sent people looking for a process that was already gone.
47
+ - **One unanswered poll no longer reports a healthy upgrade as lost.** Polling a batch used
48
+ to stop at the first failed request, and the tab reads "polling stopped" as "the host
49
+ restarted" — so an install that went on to succeed could still finish with a "lost
50
+ contact" banner. The host answers from the same thread that runs the install, so a
51
+ single poll timing out or coming back 5xx during pnpm is ordinary, not fatal. `fetch`
52
+ rejecting (a keep-alive socket closed under a long install) leaked out of the loop
53
+ entirely for the same reason. Polls now retry up to four times in a row, and a good poll
54
+ resets the count, so it takes ~3 s of real silence to give up.
55
+
3
56
  ## 1.6.3 — 2026-11-02
4
57
 
5
58
  - **"Restart DSH" is now a thing the page can walk you through, instead of advice it
package/README.en.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/dsh-plugin-upgrader.svg)](https://www.npmjs.com/package/dsh-plugin-upgrader)
4
4
  [![license](https://img.shields.io/npm/l/dsh-plugin-upgrader.svg)](./LICENSE)
5
+ [![dsh.so install verified](https://cdn.dsh.so/badge/install/dsh-plugin-upgrader@0.2.1-rc.5.svg)](https://www.dsh.so/artifact/dsh-plugin-upgrader/)
6
+ [![dsh.so risk: low](https://cdn.dsh.so/badge/dsh-plugin-upgrader.svg)](https://www.dsh.so/artifact/dsh-plugin-upgrader/)
7
+ [![DSH Market listed](https://raw.githubusercontent.com/2BingLing/dsh-market/master/assets/readme/badge-listed-en.svg)](https://dsh.market/)
8
+ [![DeepSeek Harness Plugin](https://deepseekharnessplugins.com/api/badge/zuwenyin/dsh-plugin-upgrader)](https://deepseekharnessplugins.com/plugins/zuwenyin/dsh-plugin-upgrader)
5
9
 
6
10
  > One-click upgrades plus version history and rollback for the DeepSeek Harness
7
11
  > **Plugins** page.
@@ -25,7 +29,10 @@ English · [中文](./README.md)
25
29
  runs, and a cancel button that works.
26
30
  - **Multi-select upgrade** — tick any subset of plugins and upgrade only those.
27
31
  - **Version history / rollback** — expand any plugin to see every published version
28
- (with publish date and engine requirement) and switch to it in one click.
32
+ (with publish date and engine requirement) and switch to it in one click. Every
33
+ version is first checked against the **DSH you are actually running**: releases
34
+ this runtime cannot install are marked and their switch button is disabled (the
35
+ host's gate refuses them only after the bundle has been unloaded).
29
36
  - **GitHub links** — the `GH` button opens the plugin's repository; clicking a
30
37
  version number opens that version's GitHub release page.
31
38
  - **A lagging mirror is named** — versions published upstream but not yet mirrored
@@ -309,6 +316,35 @@ acceptance. `dshOperations` (install / start / uninstall / rollback) follows the
309
316
  rule: left out rather than filled with `passed` before there is a per-release run behind
310
317
  it.
311
318
 
319
+ ### Whether each version matches the DSH you run
320
+
321
+ DSH checks a plugin's declared DSH requirement at install time and **refuses** the install
322
+ when it does not hold. That check happens after the version has been taken out on the
323
+ older replacement path, which is how "I clicked switch and the plugin disappeared" really
324
+ happened. The page therefore reaches the verdict first, under exactly the host's rules:
325
+
326
+ - when the manifest declares `engines.dsh`, that range alone decides and
327
+ `peerDependencies` are ignored;
328
+ - otherwise every `peerDependencies` range on `@deepseek-ai/dsh` and
329
+ `@deepseek-ai/dsh-*` must hold, with `workspace:^` / `~` / `*` meaning "this very
330
+ runtime";
331
+ - prereleases take part in matching (`includePrerelease`), as they do at the gate.
332
+
333
+ Both directions are covered: after DSH moves forward, releases in the history that no
334
+ longer admit this runtime are marked "not for this DSH" and their switch button is
335
+ disabled; while DSH stays behind, a `latest` that this runtime cannot install stops
336
+ counting as an upgrade, and the row says which range it wants instead.
337
+
338
+ The runtime version is read from **the host's own entry point**, not from the profile: a
339
+ profile's `@deepseek-ai/dsh-app-boot` is a link into an npx cache and measured
340
+ `0.1.2-rc.1` against a runtime that reports `0.2.1-rc.5`. When it cannot be read, nothing
341
+ is marked — a guessed version would call installable releases uninstallable.
342
+
343
+ The matcher is implemented in `src/host/version.ts` and was checked against `node-semver`
344
+ over 49 ranges × 41 versions (2035 pairs, all agreeing). The `semver` package is not
345
+ pulled in because one more runtime dependency is one more supply-chain review, and the
346
+ only semantics needed here are the `includePrerelease` ones.
347
+
312
348
  ## Project layout
313
349
 
314
350
  ```
package/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/dsh-plugin-upgrader.svg)](https://www.npmjs.com/package/dsh-plugin-upgrader)
4
4
  [![license](https://img.shields.io/npm/l/dsh-plugin-upgrader.svg)](./LICENSE)
5
+ [![dsh.so install verified](https://cdn.dsh.so/badge/install/dsh-plugin-upgrader@0.2.1-rc.5.svg)](https://www.dsh.so/artifact/dsh-plugin-upgrader/)
6
+ [![dsh.so risk: low](https://cdn.dsh.so/badge/dsh-plugin-upgrader.svg)](https://www.dsh.so/artifact/dsh-plugin-upgrader/)
7
+ [![DSH Market 已收录](https://raw.githubusercontent.com/2BingLing/dsh-market/master/assets/readme/badge-listed-zh.svg)](https://dsh.market/)
8
+ [![DeepSeek Harness Plugin](https://deepseekharnessplugins.com/api/badge/zuwenyin/dsh-plugin-upgrader)](https://deepseekharnessplugins.com/plugins/zuwenyin/dsh-plugin-upgrader)
5
9
 
6
10
  > 给 DeepSeek Harness(DSH)的「插件」页补上**一键升级**和**版本历史 / 回滚**。
7
11
 
@@ -20,6 +24,8 @@ DSH 自带的插件页能装、能卸、能看已安装列表,但缺两件事
20
24
  每步都有已用时计时和不确定态进度条,卡住时可以一键取消。
21
25
  - **多选升级** —— 勾选任意几个插件,只升级选中的这些。
22
26
  - **版本历史 / 回滚** —— 展开任意插件即可看到已发布的全部版本(含发布时间与引擎要求),点一下切到那个版本。
27
+ 每个版本都会先跟你**当前跑的 DSH** 比对一次:装不了的版本会被标出来,切换按钮也是禁用的
28
+ (宿主的兼容性闸门是在 bundle 已经卸下之后才拒绝的,不能让它走到那一步)。
23
29
  - **一键跳 GitHub** —— 每行的 `GH` 按钮打开插件仓库主页;点版本号直接进该版本的 GitHub 发布页。
24
30
  - **镜像落后会标出来** —— 配置的镜像还没同步、但上游已经发布的版本会单独标一行,
25
31
  而不是假装已经是最新版。
@@ -258,6 +264,29 @@ atomic-write: timed out waiting for the writer lock at …/package.json.lock
258
264
  `dshOperations`(install / start / uninstall / rollback)同理,没有逐版本的实测记录就留空,
259
265
  不写 `passed`。
260
266
 
267
+ ### 每个版本对不对得上现在的 DSH
268
+
269
+ DSH 在装插件时会检查插件声明的 DSH 要求,不满足就**拒绝安装**。这个检查发生在版本已经被
270
+ 摘掉之后(老路径是先卸再装),所以「点了切换,结果插件没了」是真实发生过的事。因此页面自己
271
+ 先算一遍,规则和宿主完全一致:
272
+
273
+ - 插件声明了 `engines.dsh` 时,**只看它**,`peerDependencies` 被忽略;
274
+ - 否则,`peerDependencies` 里每一个 `@deepseek-ai/dsh`、`@deepseek-ai/dsh-*` 都要满足,
275
+ `workspace:^` / `~` / `*` 视为「就是当前这个运行时」;
276
+ - 预发布版本参与比较(`includePrerelease`),与宿主的闸门相同。
277
+
278
+ 两种方向都会标出来:DSH 升级之后想**降级**插件,历史里那些不再接受当前运行时的版本会
279
+ 被标成「不支持当前 DSH」,切换按钮禁用;DSH 还停在旧版本时想**升级**,如果 `latest`
280
+ 不接受当前运行时,它就不再算「可升级」,行上会写明它要的是哪个范围。
281
+
282
+ 运行时版本是从**宿主自己的入口**读的,不是从 profile 读的:profile 里的
283
+ `@deepseek-ai/dsh-app-boot` 是指向 npx 缓存的链接,实测是 `0.1.2-rc.1`,而宿主真正报的是
284
+ `0.2.1-rc.5`。读不到时**什么都不标**——猜一个版本会把本来能装的版本说成不能装。
285
+
286
+ 判定用的是 `src/host/version.ts` 里自己实现的区间匹配,已经在 49 个 range × 41 个版本共
287
+ 2035 组上跟 `node-semver` 逐一对过(全部一致)。不引入 `semver` 依赖,是因为这条链路上
288
+ 多一个运行时依赖就多一次供应链审阅,而这里需要的语义只有 `includePrerelease` 那一档。
289
+
261
290
  ## 项目结构
262
291
 
263
292
  ```
package/lib/client.js CHANGED
@@ -87,6 +87,11 @@ window.__ModuleLoader__.load({
87
87
  noHistoryWhy: "\u8FD9\u4E2A\u5305\u4E0D\u662F\u4ECE npm \u6CE8\u518C\u8868\u5B89\u88C5\u7684\uFF0C\u6CA1\u6709\u53D1\u5E03\u5386\u53F2\u3002",
88
88
  historyFailed: "\u7248\u672C\u5217\u8868\u8BFB\u53D6\u5931\u8D25\uFF0C\u70B9\u300C\u68C0\u67E5\u66F4\u65B0\u300D\u53EF\u91CD\u8BD5",
89
89
  mirrorBehind: "\u955C\u50CF\u6E90\u5C1A\u672A\u540C\u6B65\u8FD9\u4E9B\u5DF2\u53D1\u5E03\u7248\u672C\uFF0C\u88C5\u4E0D\u4E86",
90
+ incompatible: "\u4E0D\u652F\u6301\u5F53\u524D DSH",
91
+ incompatibleWhy: (range) => `\u8FD9\u4E2A\u7248\u672C\u8981\u6C42 DSH ${range}\uFF0C\u800C\u5F53\u524D\u5BBF\u4E3B\u4E0D\u6EE1\u8DB3\u3002\u88C5\u4E0A\u53BB\u4F1A\u88AB\u5BBF\u4E3B\u62D2\u7EDD\u3002`,
92
+ incompatibleLatest: (version, range) => `\u6700\u65B0\u7248 ${version} \u8981\u6C42 DSH ${range}\uFF0C\u5F53\u524D\u5BBF\u4E3B\u4E0D\u6EE1\u8DB3\uFF0C\u6240\u4EE5\u4E0D\u80FD\u5347\u4E0A\u53BB\u3002\u53EF\u4EE5\u5148\u628A DSH \u5347\u5230\u90A3\u4E2A\u8303\u56F4\uFF0C\u6216\u6362\u4E00\u4E2A\u517C\u5BB9\u7684\u7248\u672C\u3002`,
93
+ runtimeUnknown: "\u8BFB\u4E0D\u5230\u5F53\u524D DSH \u7248\u672C\uFF0C\u65E0\u6CD5\u5224\u65AD\u517C\u5BB9\u6027",
94
+ requiresDsh: (range) => `\u8981\u6C42 DSH ${range}`,
90
95
  versions: (n) => `${n} \u4E2A\u7248\u672C`,
91
96
  showingNewest: (n) => `\u5217\u51FA\u6700\u65B0 ${n} \u4E2A`,
92
97
  progress: (done, total) => `\u6B63\u5728\u5904\u7406 ${done}/${total}`,
@@ -117,6 +122,7 @@ window.__ModuleLoader__.load({
117
122
  restartWaiting: "\u6B63\u5728\u7B49\u5BBF\u4E3B\u56DE\u6765\u2026",
118
123
  restartStillOld: "\u5BBF\u4E3B\u8FD8\u6CA1\u6362\u65B0\u8FDB\u7A0B\u3002\u786E\u8BA4\u65E7\u8FDB\u7A0B\u5DF2\u7ECF\u9000\u51FA\uFF0C\u518D\u8BD5\u4E00\u6B21\u3002",
119
124
  restartReloadBlocked: "\u5BBF\u4E3B\u5DF2\u7ECF\u91CD\u542F\uFF0C\u4F46\u8FD9\u4E00\u9875\u7684\u63D2\u4EF6\u6E05\u5355\u8FC7\u671F\u4E86\uFF0C\u76F4\u63A5\u5237\u65B0\u4F1A\u542F\u52A8\u5931\u8D25\u3002\u8BF7\u518D\u91CD\u542F\u4E00\u6B21 DSH\u3002",
125
+ restartReloadRefused: "\u5BBF\u4E3B\u5DF2\u7ECF\u91CD\u542F\uFF0C\u4F46\u8FD9\u4E00\u9875\u6CA1\u6CD5\u81EA\u5DF1\u5237\u65B0\u3002\u624B\u52A8\u5237\u65B0\u4E00\u4E0B\uFF0C\u6216\u8005\u91CD\u542F DSH \u5B8C\u6210\u5347\u7EA7\u3002",
120
126
  restartLoading: "\u6B63\u5728\u8BFB\u53D6\u5BBF\u4E3B\u4FE1\u606F\u2026",
121
127
  restartLoadFailed: (detail) => `\u8BFB\u4E0D\u5230\u5BBF\u4E3B\u4FE1\u606F\uFF1A${detail}`,
122
128
  reloadForAppearance: "\u4E3B\u9898\u63D2\u4EF6\u5DF2\u66F4\u65B0\uFF0C\u6B63\u5728\u91CD\u65B0\u52A0\u8F7D\u9875\u9762\u4EE5\u6062\u590D\u4E3B\u9898\u5916\u89C2\uFF08\u4E3B\u9898\u914D\u7F6E\u4FDD\u5B58\u5728\u78C1\u76D8\u4E0A\uFF0C\u4E0D\u4F1A\u4E22\u5931\uFF09\u3002",
@@ -220,6 +226,11 @@ window.__ModuleLoader__.load({
220
226
  noHistoryWhy: "This package was not installed from an npm registry, so it has no published history.",
221
227
  historyFailed: 'Could not read the version list; "Check for updates" retries it',
222
228
  mirrorBehind: "Your mirror has not synced these published versions yet, so they cannot be installed",
229
+ incompatible: "Not for this DSH",
230
+ incompatibleWhy: (range) => `This version requires DSH ${range}, which this host does not satisfy. Installing it would be rejected.`,
231
+ incompatibleLatest: (version, range) => `The newest release ${version} requires DSH ${range}, which this host does not satisfy, so the upgrade is not offered. Upgrade DSH into that range, or pick a compatible version.`,
232
+ runtimeUnknown: "Could not read the running DSH version, so compatibility cannot be judged",
233
+ requiresDsh: (range) => `requires DSH ${range}`,
223
234
  versions: (n) => `${n} versions`,
224
235
  showingNewest: (n) => `showing newest ${n}`,
225
236
  progress: (done, total) => `Working on ${done}/${total}`,
@@ -250,6 +261,7 @@ window.__ModuleLoader__.load({
250
261
  restartWaiting: "Waiting for the host to come back\u2026",
251
262
  restartStillOld: "The host has not been replaced yet. Check that the old process exited, then try again.",
252
263
  restartReloadBlocked: "The host restarted, but this page's plugin list is out of date and reloading it would fail to start. Restart DSH once more.",
264
+ restartReloadRefused: "The host restarted, but this page could not reload itself. Reload it by hand, or restart DSH to finish the upgrade.",
253
265
  restartLoading: "Reading host information\u2026",
254
266
  restartLoadFailed: (detail) => `Could not read host information: ${detail}`,
255
267
  reloadForAppearance: "The theme plugin was updated; reloading the page to restore its look (its settings are on disk and are not lost).",
@@ -421,15 +433,23 @@ window.__ModuleLoader__.load({
421
433
  const started = await readJson(response);
422
434
  return started.id;
423
435
  }
436
+ var WATCH_MISS_LIMIT = 4;
424
437
  async function watchJob(id, options) {
438
+ const wait = options.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
439
+ let missed = 0;
425
440
  for (; ; ) {
426
441
  if (!options.isAlive()) return void 0;
427
- const response = await fetch(`${API}/job?id=${encodeURIComponent(id)}`, { credentials: "same-origin" });
428
- const snapshot = await readJson(response).catch(() => void 0);
429
- if (snapshot === void 0) return void 0;
442
+ const snapshot = await fetch(`${API}/job?id=${encodeURIComponent(id)}`, { credentials: "same-origin" }).then((response) => readJson(response)).catch(() => void 0);
443
+ if (snapshot === void 0) {
444
+ missed += 1;
445
+ if (missed >= WATCH_MISS_LIMIT) return void 0;
446
+ await wait(POLL_MS);
447
+ continue;
448
+ }
449
+ missed = 0;
430
450
  if (options.isAlive()) options.onSnapshot(snapshot);
431
451
  if (snapshot.status !== "running" && snapshot.status !== "queued") return snapshot;
432
- await new Promise((resolve) => setTimeout(resolve, POLL_MS));
452
+ await wait(POLL_MS);
433
453
  }
434
454
  }
435
455
  async function submitJob(steps, watch) {
@@ -772,6 +792,7 @@ window.__ModuleLoader__.load({
772
792
  .dsh-pu-tag { font-size: 10px; line-height: 1; padding: 3px 6px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l1); color: var(--dsw-alias-label-secondary); font-weight: 500; white-space: nowrap; }
773
793
  .dsh-pu-tag.is-new { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent); }
774
794
  .dsh-pu-tag.is-err { border-color: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); }
795
+ .dsh-pu-tag.is-foreign { border-color: var(--dsw-alias-state-warn-primary); color: var(--dsw-alias-state-warn-primary); }
775
796
  .dsh-pu-vers { display: flex; align-items: center; gap: 6px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; color: var(--dsw-alias-label-secondary); flex: none; }
776
797
  .dsh-pu-vers b { color: var(--dsw-alias-label-primary); font-weight: 600; }
777
798
  .dsh-pu-vers i { font-style: normal; color: var(--dsw-alias-label-tertiary); }
@@ -784,6 +805,7 @@ window.__ModuleLoader__.load({
784
805
  .dsh-pu-hist-row { display: flex; align-items: center; gap: 10px; padding: 5px 6px; border-radius: 6px; font-size: 11px; }
785
806
  .dsh-pu-hist-row:hover { background: var(--dsw-alias-interactive-bg-hover); }
786
807
  .dsh-pu-hist-row.is-current { background: var(--dsw-alias-interactive-bg-hover); box-shadow: inset 2px 0 0 var(--dsw-alias-brand-primary); }
808
+ .dsh-pu-hist-row.is-foreign .dsh-pu-mono, .dsh-pu-hist-row.is-foreign time { opacity: .6; }
787
809
  .dsh-pu-hist-row .dsh-pu-mono { flex: none; min-width: 108px; color: var(--dsw-alias-label-primary); }
788
810
  .dsh-pu-link { display: inline-flex; align-items: center; gap: 4px; border: 0; padding: 0; margin: 0; background: none; cursor: pointer; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; color: var(--dsw-alias-link); text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--dsw-alias-link) 40%, transparent); text-underline-offset: 2px; }
789
811
  .dsh-pu-link:hover { color: var(--dsw-alias-brand-primary); text-decoration-color: currentColor; }
@@ -905,9 +927,11 @@ window.__ModuleLoader__.load({
905
927
  const link = resolveVersionLink(row, version);
906
928
  const linkLabel = link !== void 0 && /\/releases\/tag\//.test(link) ? t("githubVersion") : t("githubReleases");
907
929
  const title = link === void 0 ? void 0 : `${linkLabel} \u2192${link}`;
930
+ const foreign = version.compatible === false;
931
+ const why = foreign ? t("incompatibleWhy", version.requires ?? "") : title;
908
932
  return h(
909
933
  "div",
910
- { className: `dsh-pu-hist-row${version.current ? " is-current" : ""}` },
934
+ { className: `dsh-pu-hist-row${version.current ? " is-current" : ""}${foreign ? " is-foreign" : ""}` },
911
935
  link !== void 0 ? h(
912
936
  "button",
913
937
  {
@@ -921,6 +945,7 @@ window.__ModuleLoader__.load({
921
945
  h("time", null, formatDate(version.time)),
922
946
  version.latest ? h("span", { className: "dsh-pu-tag is-new" }, t("latest")) : null,
923
947
  version.current ? h("span", { className: "dsh-pu-tag" }, t("current")) : null,
948
+ foreign ? h("span", { className: "dsh-pu-tag is-foreign", title: why }, t("incompatible")) : null,
924
949
  h("span", { className: "dsh-pu-note" }, note),
925
950
  link === void 0 ? null : h(
926
951
  "button",
@@ -938,7 +963,8 @@ window.__ModuleLoader__.load({
938
963
  {
939
964
  type: "button",
940
965
  className: "dsh-pu-btn is-tiny",
941
- disabled: busy || version.current === true,
966
+ disabled: busy || version.current === true || foreign,
967
+ title: why,
942
968
  onClick: () => onSwitch(row, version.version)
943
969
  },
944
970
  /*
@@ -1025,6 +1051,20 @@ window.__ModuleLoader__.load({
1025
1051
  },
1026
1052
  `${t("mirrorBehind")}: ${row.mirrorMissing.slice(0, 3).join(", ")}${row.mirrorMissing.length > 3 ? " \u2026" : ""}`
1027
1053
  ) : null,
1054
+ /*
1055
+ * The newest release refuses to run on this DSH version. The row's
1056
+ * upgrade button points at `latest`, so it is no longer an upgrade:
1057
+ * naming the requirement is the only thing that turns "nothing happens"
1058
+ * into something the person can act on.
1059
+ */
1060
+ row.blocked !== void 0 ? h(
1061
+ "div",
1062
+ {
1063
+ className: "dsh-pu-note is-err",
1064
+ title: t("incompatibleLatest", row.blocked.version, row.blocked.requires)
1065
+ },
1066
+ t("incompatibleLatest", row.blocked.version, row.blocked.requires)
1067
+ ) : null,
1028
1068
  failure !== void 0 && retriable ? h(
1029
1069
  "div",
1030
1070
  { className: "dsh-pu-note is-err", title: failure },
@@ -1139,6 +1179,9 @@ window.__ModuleLoader__.load({
1139
1179
  // src/client/components/RestartDialog.ts
1140
1180
  var PROBE_MS = 1e3;
1141
1181
  var WAIT_LIMIT_MS = 12e4;
1182
+ function stalledMessage(problem, t) {
1183
+ return problem.trim() === "" ? t("restartStillOld") : problem;
1184
+ }
1142
1185
  function RestartDialog(props) {
1143
1186
  const { t, onClose } = props;
1144
1187
  const h = getH();
@@ -1173,6 +1216,7 @@ window.__ModuleLoader__.load({
1173
1216
  const waitForRestart = React.useCallback(() => {
1174
1217
  const before = host?.pid;
1175
1218
  if (before === void 0) return;
1219
+ setProblem("");
1176
1220
  setPhase("waiting");
1177
1221
  const startedAt = Date.now();
1178
1222
  const timer = window.setInterval(() => {
@@ -1193,7 +1237,10 @@ window.__ModuleLoader__.load({
1193
1237
  setPhase("stalled");
1194
1238
  }
1195
1239
  }).then((done) => {
1196
- if (!done && !blocked) setPhase("stalled");
1240
+ if (!done && !blocked) {
1241
+ setProblem(t("restartReloadRefused"));
1242
+ setPhase("stalled");
1243
+ }
1197
1244
  });
1198
1245
  }).catch(() => {
1199
1246
  });
@@ -1257,7 +1304,7 @@ window.__ModuleLoader__.load({
1257
1304
  ),
1258
1305
  host === void 0 ? null : h("p", { className: "dsh-pu-hint" }, t("restartPid", host.pid, host.profileName ?? "\u2014")),
1259
1306
  phase === "waiting" ? h("p", { className: "dsh-pu-hint" }, t("restartWaiting")) : null,
1260
- phase === "stalled" ? h("p", { className: "dsh-pu-hint" }, t("restartStillOld")) : null,
1307
+ phase === "stalled" ? h("p", { className: "dsh-pu-hint" }, stalledMessage(problem, t)) : null,
1261
1308
  h(
1262
1309
  "div",
1263
1310
  { className: "dsh-pu-src-foot" },
@@ -26,6 +26,8 @@ export interface StepOutcome {
26
26
  detail?: string;
27
27
  pendingBuilds?: string[];
28
28
  warnings?: string[];
29
+ /** The version this step put back after destroying the one that was there. */
30
+ rolledBack?: string;
29
31
  packageResult?: {
30
32
  exitCode: number;
31
33
  kind?: string;
@@ -35,6 +37,21 @@ export interface StepOutcome {
35
37
  }
36
38
  /** Allowed per-step actions. */
37
39
  export declare const ACTIONS: Set<string>;
40
+ /**
41
+ * One version's DSH requirement, looked up only when a step is about to run.
42
+ *
43
+ * The lookup is injected rather than imported so this module keeps no registry
44
+ * client: the caller already has the packument cache and the chosen registry,
45
+ * and asking the network here would duplicate both.
46
+ */
47
+ export interface InstallGuard {
48
+ /** The DSH build this process runs, when it could be read. */
49
+ runtimeVersion?: string | undefined;
50
+ /** The requirement `name@version` declares, or `undefined` when unknown. */
51
+ requirementOf: (name: string, version: string) => Promise<{
52
+ range: string;
53
+ } | undefined>;
54
+ }
38
55
  /**
39
56
  * Re-apply the enabled state a step asked for after the package was replaced.
40
57
  *
@@ -78,9 +95,10 @@ export declare function restoreSelection(manager: PluginManager, name: string, l
78
95
  * @param log - progress sink.
79
96
  * @param announce - receives the request id the step installs under.
80
97
  * @param registry - the registry chosen in this plugin's config, if any.
98
+ * @param guard - the compatibility check, when the caller can supply one.
81
99
  * @returns the outcome record.
82
100
  */
83
- export declare function runStep(manager: PluginManager, step: StepRecord, log: LogSink, announce?: RequestIdSink, registry?: string): Promise<StepOutcome>;
101
+ export declare function runStep(manager: PluginManager, step: StepRecord, log: LogSink, announce?: RequestIdSink, registry?: string, guard?: InstallGuard): Promise<StepOutcome>;
84
102
  /**
85
103
  * Whether a step names something this plugin is willing to install.
86
104
  *
@@ -1 +1 @@
1
- {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../../src/host/install.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAc,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAGvF,iCAAiC;AACjC,MAAM,MAAM,OAAO,GAAG,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;AAE/C;;;;;;;GAOG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;AAEvD,yDAAyD;AACzD,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,EAAE,EAAE,OAAO,CAAA;IACX,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,WAAW,CAAC,EAAE,YAAY,CAAC,aAAa,CAAC,CAAA;IACzC,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,KAAK,CAAC,EAAE,SAAS,GAAG,QAAQ,GAAG,QAAQ,CAAA;IACvC,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;IACnB,aAAa,CAAC,EAAE;QACd,QAAQ,EAAE,MAAM,CAAA;QAChB,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,QAAQ,CAAC,EAAE,OAAO,CAAA;QAClB,IAAI,CAAC,EAAE,MAAM,CAAA;KACd,CAAA;CACF;AAED,gCAAgC;AAChC,eAAO,MAAM,OAAO,aAAyC,CAAA;AA2L7D;;;;;;;;;;;GAWG;AACH,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,aAAa,EACtB,IAAI,EAAE,IAAI,CAAC,UAAU,EAAE,MAAM,CAAC,EAC9B,MAAM,EAAE,OAAO,GAAG,SAAS,EAC3B,GAAG,EAAE,OAAO,GACX,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAuB9B;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,aAAa,EACtB,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,OAAO,GACX,OAAO,CAAC,OAAO,CAAC,CAmBlB;AAsGD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,OAAO,CAC3B,OAAO,EAAE,aAAa,EACtB,IAAI,EAAE,UAAU,EAChB,GAAG,EAAE,OAAO,EACZ,QAAQ,CAAC,EAAE,aAAa,EACxB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,WAAW,CAAC,CAqJtB;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAM9D"}
1
+ {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../../src/host/install.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAc,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAIvF,iCAAiC;AACjC,MAAM,MAAM,OAAO,GAAG,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;AAE/C;;;;;;;GAOG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;AAEvD,yDAAyD;AACzD,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,EAAE,EAAE,OAAO,CAAA;IACX,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,WAAW,CAAC,EAAE,YAAY,CAAC,aAAa,CAAC,CAAA;IACzC,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,KAAK,CAAC,EAAE,SAAS,GAAG,QAAQ,GAAG,QAAQ,CAAA;IACvC,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;IACnB,8EAA8E;IAC9E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,aAAa,CAAC,EAAE;QACd,QAAQ,EAAE,MAAM,CAAA;QAChB,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,QAAQ,CAAC,EAAE,OAAO,CAAA;QAClB,IAAI,CAAC,EAAE,MAAM,CAAA;KACd,CAAA;CACF;AAED,gCAAgC;AAChC,eAAO,MAAM,OAAO,aAAyC,CAAA;AA+H7D;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,8DAA8D;IAC9D,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACnC,4EAA4E;IAC5E,aAAa,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC,CAAA;CACzF;AA8GD;;;;;;;;;;;GAWG;AACH,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,aAAa,EACtB,IAAI,EAAE,IAAI,CAAC,UAAU,EAAE,MAAM,CAAC,EAC9B,MAAM,EAAE,OAAO,GAAG,SAAS,EAC3B,GAAG,EAAE,OAAO,GACX,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAuB9B;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,aAAa,EACtB,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,OAAO,GACX,OAAO,CAAC,OAAO,CAAC,CAmBlB;AA0MD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,OAAO,CAC3B,OAAO,EAAE,aAAa,EACtB,IAAI,EAAE,UAAU,EAChB,GAAG,EAAE,OAAO,EACZ,QAAQ,CAAC,EAAE,aAAa,EACxB,QAAQ,CAAC,EAAE,MAAM,EACjB,KAAK,CAAC,EAAE,YAAY,GACnB,OAAO,CAAC,WAAW,CAAC,CAuLtB;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAM9D"}
@@ -9,10 +9,20 @@
9
9
  */
10
10
  import { explainLockTimeout } from './lock.js';
11
11
  import { confirmDependency, dropDependency, profileDirFrom, restoreDependency } from './manifest.js';
12
+ import { satisfiesRange } from './version.js';
12
13
  /** Allowed per-step actions. */
13
14
  export const ACTIONS = new Set(['upgrade', 'switch']);
14
15
  /** The manager's code for "a live loader fiber still owns this bundle". */
15
16
  const IN_USE = 'bundle-in-use';
17
+ /**
18
+ * The manager's code for "this version does not admit the running DSH".
19
+ *
20
+ * Worth naming: it is the one refusal that is *certain* to repeat. Every other
21
+ * failure might be a flaky registry or a busy lock, but a version whose declared
22
+ * range rejects this runtime will be rejected again no matter how many times the
23
+ * replacement is retried — so retrying it by removing first only destroys more.
24
+ */
25
+ const INCOMPATIBLE = 'incompatible-version';
16
26
  /**
17
27
  * Read the manager's failure code off a result or a throw.
18
28
  *
@@ -86,6 +96,43 @@ async function readsAsEnabled(manager, name) {
86
96
  return undefined;
87
97
  }
88
98
  }
99
+ /**
100
+ * One bundle as the manager currently reports it.
101
+ *
102
+ * "Absent" and "present but with no version" have to be told apart: the first
103
+ * means a rollback has to reinstall, the second only means the listing did not
104
+ * carry a version — re-selecting is still the right repair there.
105
+ *
106
+ * @param manager - the plugin manager.
107
+ * @param name - the bundle to read.
108
+ * @returns the entry, or `undefined` when it is not listed at all.
109
+ */
110
+ async function bundleOf(manager, name) {
111
+ try {
112
+ const bundles = await manager.listBundles();
113
+ const list = Array.isArray(bundles) ? bundles : [];
114
+ return list.find((item) => item?.name === name);
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ }
120
+ /**
121
+ * The version a bundle currently has on disk, read before anything is removed.
122
+ *
123
+ * This is the value a failed replacement rolls back to. It has to be read up
124
+ * front: after `removeForReplacement` the package is gone, and the manager has
125
+ * nothing left to report a version for — which is precisely the moment the
126
+ * rollback needs to know what to put back.
127
+ *
128
+ * @param manager - the plugin manager.
129
+ * @param name - the bundle to read.
130
+ * @returns the installed version, or `undefined` when it cannot be read.
131
+ */
132
+ async function installedVersion(manager, name) {
133
+ const version = (await bundleOf(manager, name))?.version;
134
+ return typeof version === 'string' && version.trim() !== '' ? version.trim() : undefined;
135
+ }
89
136
  /**
90
137
  * Call `setBundleEnabled`, reporting a refusal instead of throwing it.
91
138
  *
@@ -267,6 +314,86 @@ export async function restoreSelection(manager, name, log) {
267
314
  }
268
315
  return true;
269
316
  }
317
+ /**
318
+ * Put the previous version back after a replacement destroyed it.
319
+ *
320
+ * The in-place route only knows the install was refused after pnpm has run, and
321
+ * the manager's compatibility gate runs *after* pnpm rewrote the lockfile — so a
322
+ * version the host refuses can leave the package gone while the manifest entry
323
+ * is the only thing that got restored. The plugin then disappears from the page
324
+ * with no way back except reinstalling by hand.
325
+ *
326
+ * Installing the old version again is the repair that actually works: pnpm puts
327
+ * the files back, and the manager activates them exactly as it did before. It is
328
+ * deliberately attempted for every failure that follows a removal, not only the
329
+ * compatibility one — an entry pointing at a version that is not on disk is
330
+ * broken either way.
331
+ *
332
+ * Best effort: the step has already failed for its own reason, and a rollback
333
+ * that also fails must not replace that reason.
334
+ *
335
+ * @param manager - the plugin manager.
336
+ * @param step - the step that failed.
337
+ * @param previous - the version that was installed before the step ran.
338
+ * @param options - the install options the caller already built.
339
+ * @param log - progress sink.
340
+ * @param announce - receives the request id the rollback installs under.
341
+ * @returns whether the old version is back.
342
+ */
343
+ async function rollBack(manager, step, previous, options, log, announce) {
344
+ const spec = `${step.name}@${previous}`;
345
+ const requestId = globalThis.crypto?.randomUUID?.() ?? `pu-${Date.now()}-${Math.random().toString(36).slice(2)}`;
346
+ announce?.(requestId);
347
+ log(`roll back: install ${spec} again (the failed replacement already removed it)`);
348
+ try {
349
+ const result = await manager.installBundle(spec, { ...options, requestId });
350
+ const back = result?.application === 'applied' || result?.application === 'restart-required';
351
+ if (result?.error !== undefined || !back) {
352
+ log(` ! rollback refused (${result?.error?.code ?? 'no change'}); ${step.name}@${previous} is not installed — ` +
353
+ 'install it again from the Installed tab');
354
+ return false;
355
+ }
356
+ log(` ✓ ${step.name}@${previous} is back where it was`);
357
+ return true;
358
+ }
359
+ catch (error) {
360
+ log(` ! rollback failed: ${describeFailure(error instanceof Error ? error.message : String(error))}; ` +
361
+ `${step.name}@${previous} is not installed`);
362
+ return false;
363
+ }
364
+ }
365
+ /**
366
+ * Put the profile back the way the step found it, whatever it managed to break.
367
+ *
368
+ * Two different damages need two different repairs, and picking the wrong one
369
+ * makes things worse:
370
+ *
371
+ * - the package is still on disk but was deselected — re-selecting it is enough,
372
+ * and installing an old version over it would be a pointless pnpm run;
373
+ * - the package is gone — re-selecting nothing, which is what the old code did,
374
+ * leaves an entry in `dependencies` that nothing can load, so the plugin is
375
+ * simply missing from the page.
376
+ *
377
+ * @param manager - the plugin manager.
378
+ * @param step - the step that failed.
379
+ * @param previous - the version installed before the step ran, when known.
380
+ * @param options - the install options the caller already built.
381
+ * @param log - progress sink.
382
+ * @param announce - receives the request id a rollback installs under.
383
+ * @returns whether the profile was put back.
384
+ */
385
+ async function recover(manager, step, previous, options, log, announce) {
386
+ const entry = await bundleOf(manager, step.name);
387
+ if (entry !== undefined && entry.installed !== false) {
388
+ await restoreSelection(manager, step.name, log);
389
+ return false;
390
+ }
391
+ if (previous === undefined) {
392
+ log(` ! ${step.name} is no longer installed, and its previous version was not recorded`);
393
+ return false;
394
+ }
395
+ return rollBack(manager, step, previous, options, log, announce);
396
+ }
270
397
  /**
271
398
  * Replace a version without removing the bundle first.
272
399
  *
@@ -367,9 +494,10 @@ async function replaceWithoutRemoval(manager, step, options, log, announce) {
367
494
  * @param log - progress sink.
368
495
  * @param announce - receives the request id the step installs under.
369
496
  * @param registry - the registry chosen in this plugin's config, if any.
497
+ * @param guard - the compatibility check, when the caller can supply one.
370
498
  * @returns the outcome record.
371
499
  */
372
- export async function runStep(manager, step, log, announce, registry) {
500
+ export async function runStep(manager, step, log, announce, registry, guard) {
373
501
  const spec = `${step.name}@${step.version}`;
374
502
  const requestId = globalThis.crypto?.randomUUID?.() ?? `pu-${Date.now()}-${Math.random().toString(36).slice(2)}`;
375
503
  announce?.(requestId);
@@ -379,6 +507,29 @@ export async function runStep(manager, step, log, announce, registry) {
379
507
  const base = { name: step.name, version: step.version, spec, requestId };
380
508
  const restoreEnabled = typeof step.enabled === 'boolean' ? step.enabled : undefined;
381
509
  log(`install ${spec}`);
510
+ /*
511
+ * Ask the version itself before touching anything.
512
+ *
513
+ * The host's own gate is the final word and it runs inside pnpm's wake-up, so
514
+ * without this check a refusal arrives after the manager has already deselected
515
+ * — or removed — the bundle. Reading the published requirement first turns
516
+ * "your plugin is gone" into "this version was never going to work", which is
517
+ * the difference between a repair and a loss.
518
+ */
519
+ const requirement = guard === undefined ? undefined : await guard.requirementOf(step.name, step.version);
520
+ if (requirement !== undefined && guard?.runtimeVersion !== undefined) {
521
+ if (!satisfiesRange(guard.runtimeVersion, requirement.range)) {
522
+ log(` ! ${spec} declares ${requirement.range}, which excludes DSH ${guard.runtimeVersion}; nothing was changed`);
523
+ return {
524
+ ...base,
525
+ ok: false,
526
+ application: 'failed',
527
+ stage: 'install',
528
+ message: INCOMPATIBLE,
529
+ detail: requirement.range,
530
+ };
531
+ }
532
+ }
382
533
  const options = {
383
534
  saveExact: true,
384
535
  ...(restoreEnabled === undefined ? {} : { enabled: restoreEnabled }),
@@ -392,6 +543,12 @@ export async function runStep(manager, step, log, announce, registry) {
392
543
  ...(typeof registry === 'string' && registry.trim() !== '' ? { registry: registry.trim() } : {}),
393
544
  };
394
545
  let result;
546
+ /*
547
+ * Read the version on disk *before* the manager can take it away. This is the
548
+ * value a failed replacement rolls back to, and once `removeForReplacement`
549
+ * has run there is nothing left to ask.
550
+ */
551
+ const previous = await installedVersion(manager, step.name);
395
552
  try {
396
553
  result = await manager.installBundle(spec, { ...options, requestId });
397
554
  }
@@ -462,12 +619,12 @@ export async function runStep(manager, step, log, announce, registry) {
462
619
  }
463
620
  catch (error) {
464
621
  /*
465
- * The removal above already deselected the bundle, so a throw here leaves
466
- * it installed-but-unloaded: still in `dependencies`, still on disk, no
467
- * longer selected, and therefore invisible on the page. Put the selection
468
- * back before reporting, or the operator has to repair it by hand.
622
+ * The removal above happened for real, so a throw here can leave the
623
+ * plugin gone. `recover` tells the two damages apart: still-on-disk means
624
+ * re-select it, gone means install the previous version again. Confusing
625
+ * them is what made "upgrade failed" also mean "plugin disappeared".
469
626
  */
470
- await restoreSelection(manager, step.name, log);
627
+ const back = await recover(manager, step, previous, options, log, announce);
471
628
  return {
472
629
  ...base,
473
630
  requestId: retryId,
@@ -475,6 +632,7 @@ export async function runStep(manager, step, log, announce, registry) {
475
632
  stage: 'install',
476
633
  application: 'failed',
477
634
  message: `install after removal failed: ${describeFailure(error instanceof Error ? error.message : String(error))}`,
635
+ ...(back ? { rolledBack: previous } : {}),
478
636
  };
479
637
  }
480
638
  }
@@ -483,8 +641,7 @@ export async function runStep(manager, step, log, announce, registry) {
483
641
  * removal already done. Same hazard, same repair; the reported reason stays
484
642
  * whatever the manager said.
485
643
  */
486
- if (result?.error !== undefined)
487
- await restoreSelection(manager, step.name, log);
644
+ const restored = result?.error === undefined ? false : await recover(manager, step, previous, options, log, announce);
488
645
  // Only a change that landed has an enabled state to restore: a failed install
489
646
  // left nothing behind, and asking to toggle it would report a second, louder
490
647
  // error (`operation-error`) for a package that was never added.
@@ -494,6 +651,7 @@ export async function runStep(manager, step, log, announce, registry) {
494
651
  ...base,
495
652
  ...(enabled === undefined ? {} : { enabled }),
496
653
  ok: installed,
654
+ ...(restored ? { rolledBack: previous } : {}),
497
655
  application: result?.application,
498
656
  changed: result?.changed === true,
499
657
  stage: result?.stage,