dsh-m 0.9.94 → 0.10.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
@@ -8,6 +8,12 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
8
8
 
9
9
  ## 中文
10
10
 
11
+ ### 0.10.0 特性+修复:宿主 0.2.1 already-installed 适配——第三方两步升级 + 兜底回装降级恢复 + 自升级专用通道(D3)
12
+
13
+ - **需求(2026-10-17,主人)**:新版 DSH(0.2.1-rc.1/rc.2)中 dsh-m 无法升级与自升级任何包。根因定谳:宿主 `installBundle()` 新增 already-installed 安装守卫——spec 解析出的包名已在 profile manifest 就先于 pnpm 拒绝,官方升级语义改为「先卸载再安装」;tarball/git 形态 spec 包名在 pnpm 前不可知、不受守卫约束(真机 spike 实证:URL 覆盖安装已装 dsh-m 成功、激活形态 restart-required)。影响面:产品内 desktop 升级/自升级全断;卸载、开关、全新安装、web 路径无恙。跳过 0.9.95+ 直接发 0.10.0。
14
+ - **方案(ADR-0022,grill 决议 R1–R15)**:① **install-first 自适应两步**——不探测宿主版本,首腿收到 `already-installed` 错误码才转 remove+重试(至多一次、无循环);install 路径撞守卫同样静默两步化(已装态 install 即升级),全新安装零行为变化(removeBundle 恒零调用);release-age/build-blocked/cancelled 语义先于守卫判定。② **兜底回装**——两步化后安装腿终态失败自动回装旧版:回装成功 = **降级恢复**新结局(成功形态 + degraded/restoredTo;客户端 warn 投影不入「升级成功」臂、操作泵复用 warned 状态落账、设置页红点沿用既有 failed||warned 口径、× 删除照旧);回装再失败 = rollback-failed 缺席硬错误(回装腿自身 build-blocked 时 details.pendingBuilds 如实透出);cancelled 抛「已恢复旧版/未做升级」不冒充降级,build-blocked 回装兜住后仍抛审批请求。③ **通道分治**——第三方走官方两步;dsh-m 自升级独享 tarball 直覆盖专用通道(精确版本 → 生效 registry `dist.tarball` → intent 预写 → `installBundle(tarballUrl)` 直覆盖 → 就地收口),core 缝单点改道(SELF_PKG='dsh-m'),host-api/Agent 工具/客户端零特判,自升级绝不落入 remove+add;入口全面保留:头部角标新增确认框(不经过卸载、失败时当前版本不受影响、完成后需重启 Desktop 生效),needsRestart 忠实透传宿主 application(applied→无横幅、restart-required→提示重启)。④ **开机对账**——自升级 intent ledger(`$DSH_HOME/dshm/self-upgrade-intent.json`;ADR-0018 面板通道零外部日志不变量不破)作崩溃兜底网:正常路径就地收口,进程死于升级途中时由下一次 self-check 每进程一次比对实际版本收口。⑤ WRITE_GUIDANCE 自升级失配文案修复(不再让用户「回 DSH Web」,改指官方 Settings → Plugins remove + install 精确版本)。GLOSSARY 七词条入册(两步升级/宿主安装守卫/install-first 自适应/兜底回装/降级恢复/自升级专用通道/开机对账)。
15
+ - **验证**:typecheck 零错误;全量 1585 测试(028 本机环境既有失败 18 例零新增——干净树对照为同一集合:symlink EPERM 家族 + client-lightbox ESM scheme + CRLF 源锚 + 网络 fetch);新增钉例覆盖计划 T1–T17(intent ledger 存储五态 / npmTarballUrl 三钉 / 两步化-兜底-降级边界九钉(含 remove-failed 短路、cancelled 文案、回装 build-blocked 透 pendingBuilds)/ D3 与 SELF_PKG 缝改道五钉 / host-api 开机对账与 upgrade(dsh-m) 改道端到端 / client 降级投影-两站点接线-确认框结构序-操作泵落账);ADR-0022 立项、分层 commit(feat core/client + docs adr/glossary + chore release)。
16
+
11
17
  ### 0.9.94 重构+修复:变更结局描述客户端单源化(op-outcome)+ 补丁映射 YAML 引号宽容与重复键根治
12
18
 
13
19
  - **需求(2026-10-17 架构巡查 + grill 九决议)**:变更结局的呈现无 owning module——守卫拦截文案装配 main.jsx 三处逐字重复(0.9.93 toWire 收敛了线格式、client 侧呈现是已知残尾,`repairBasis` 后加时的多点位手补复发已实证)、升级成功文案两处逐字、builds 后缀四处拼装、superseded 五处;另:事务补偿对 workspace `patchedDependencies` **非双引号在场形态**(pnpm 的单引号/无引号书写)逐字判重失明——逐字判重可自造重复键文件、复读检查可假 committed(同包链经 strip 先摘在生产不可达,本项属防御面收敛与 fail-closed 补洞)。
@@ -661,6 +667,12 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
661
667
 
662
668
  ## English
663
669
 
670
+ ### Feature + fix in 0.10.0 — host 0.2.1 already-installed adaptation: third-party two-step upgrade + rollback-reinstall degraded restore + dedicated self-upgrade channel (D3)
671
+
672
+ - **Need (2026-10-17, the owner)**: on the new DSH (0.2.1-rc.1/rc.2) dsh-m could no longer upgrade or self-upgrade any package. Root cause: the host's `installBundle()` gained an already-installed guard — when the package name resolved from a registry spec already exists in the profile manifest it is rejected before pnpm runs, and the official upgrade semantics became "uninstall first, then install"; tarball/git specs are exempt because their package name is unknowable before pnpm (verified on a real machine: installing a tarball URL over the installed dsh-m succeeded, activation shape restart-required). Scope: in-product desktop upgrade/self-upgrade fully broken; uninstall, toggle, fresh install, and the web path unaffected. Version 0.9.95+ skipped straight to 0.10.0.
673
+ - **Approach (ADR-0022, grilled decisions R1–R15)**: ① **install-first adaptive two-step** — no host version probing; only when the first leg returns `already-installed` does it convert to remove + retry (at most once, no loops); the install path silently two-steps on the guard too (installing an installed package is an upgrade), while fresh installs keep zero `removeBundle` calls; release-age/build-blocked/cancelled shapes take precedence over the guard check. ② **Rollback reinstall** — when the install leg terminates in failure the old version is reinstalled automatically: success yields the new **degraded restore** outcome (success shape + degraded/restoredTo; the client projects a warn that never enters the "upgraded vX→vY" arm, the operation pump reuses the existing `warned` state, the settings red dot keeps the existing failed||warned rule, and ✕ dismissal is unchanged); a failed reinstall is the hard `rollback-failed` absence error (with `details.pendingBuilds` surfaced truthfully when the reinstall leg itself is build-blocked); cancellation reports "restored the old version / no upgrade was made" and never masquerades as degraded; a build-blocked install leg still raises the approval request after the rollback has secured the old version. ③ **Channel separation** — third-party plugins use the official two-step; dsh-m's own self-upgrade uses the dedicated tarball direct-install channel (exact version → live-registry `dist.tarball` → intent pre-write → `installBundle(tarballUrl)` → in-place settle), rerouted at a single core seam (`SELF_PKG='dsh-m'`) with zero special-casing in host-api/agent tools/client, and never falling into remove+add; all entries preserved: the header chip gains a confirmation dialog (no uninstall involved, the current version stays intact on failure, a Desktop restart is needed afterwards), and `needsRestart` faithfully passes through the host's `application` (applied → no banner, restart-required → restart prompt). ④ **Boot reconciliation** — a self-upgrade intent ledger (`$DSH_HOME/dshm/self-upgrade-intent.json`; ADR-0018's panel-channel-zero-external-logging invariant intact) serves as a crash safety net: the normal path settles in-process, and only a process that died mid-upgrade is reconciled by the next self-check, once per process, against the actually installed version. ⑤ The mismatched self-upgrade WRITE_GUIDANCE copy no longer sends users "back to DSH Web", pointing to the official Settings → Plugins remove + install (exact version) instead. Seven GLOSSARY terms registered (two-step upgrade / already-installed guard / install-first adaptation / rollback reinstall / degraded restore / dedicated self-upgrade channel / boot reconciliation).
674
+ - **Verification**: typecheck clean; full suite 1585 tests (18 pre-existing environment-bound failures per know-how 028, zero new — the clean-tree comparison yields the same set: symlink-EPERM family + client-lightbox ESM scheme + CRLF source anchor + network fetch); new pins cover plan T1–T17 (intent-ledger storage five states / npmTarballUrl three pins / two-step-rollback-degraded boundary nine pins including remove-failed short-circuit, cancelled copy, and reinstall build-blocked pendingBuilds passthrough / D3 and the SELF_PKG seam rerouting five pins / host-api boot reconciliation plus the upgrade(dsh-m) rerouting end-to-end / client degraded projection, two-site wiring, confirmation-dialog ordering, and pump accounting); ADR-0022 filed, layered commits (feat core/client + docs adr/glossary + chore release).
675
+
664
676
  ### Refactor + fix in 0.9.94 — client-side mutation-outcome copy single-sourced (op-outcome) + patch-mapping YAML quote tolerance and duplicate-key elimination
665
677
 
666
678
  - **Need (2026-10-17 architecture walk + nine grilled decisions)**: the presentation of mutation outcomes had no owning module — the guard-interception copy was assembled verbatim three times in main.jsx (0.9.93's toWire converged the wire format while the client-side presentation remained the known leftover; the multi-point hand-patching recurrence when `repairBasis` was added had already been demonstrated once), the upgrade-success copy twice verbatim, the builds suffix four times, superseded five times; separately: the transaction's compensation was blind to **non-double-quoted in-place forms** of workspace `patchedDependencies` (pnpm's single-quoted/unquoted spellings) via verbatim duplicate checks — the verbatim check could fabricate a duplicate-key file and the read-back check could false-pass (the same-package chain is unreachable in production because strip removes it first; this item is defensive-surface convergence and a fail-closed gap-fill).
package/lib/client.js CHANGED
@@ -1654,10 +1654,12 @@ function createOpsPump(store, getCtx) {
1654
1654
  if (!err) {
1655
1655
  const opMeta = value && typeof value === "object" && value.opMeta && typeof value.opMeta === "object" ? value.opMeta : null;
1656
1656
  const cur = opMeta ? store.list().find((x) => x.id === queued.id) : null;
1657
+ const degraded = value && typeof value === "object" && value.degraded === true ? { degraded: true, restoredTo: value.restoredTo } : null;
1658
+ const mergedMeta = opMeta || degraded ? { ...cur && cur.meta || {}, ...opMeta || {}, ...degraded || {} } : null;
1657
1659
  finalize({
1658
- status: value && value.opWarning ? "warned" : "done",
1660
+ status: value && value.opWarning || degraded ? "warned" : "done",
1659
1661
  warning: value && value.opWarning,
1660
- ...opMeta ? { meta: { ...cur && cur.meta || {}, ...opMeta } } : {}
1662
+ ...mergedMeta ? { meta: mergedMeta } : {}
1661
1663
  });
1662
1664
  if (waitersEntry) waitersEntry.resolve(value);
1663
1665
  } else {
@@ -2030,12 +2032,13 @@ function createOpOutcome(lookup2) {
2030
2032
  }) + buildsSuffix(res) + (note.suffixKey ? lookup2(note.suffixKey) : "")
2031
2033
  };
2032
2034
  };
2035
+ const degradedOutcome = (res) => res && res.degraded === true ? { kind: "warn", text: lookup2("op.degraded", { restoredTo: res.restoredTo || "\u2014" }) } : null;
2033
2036
  const mutationFailed = (e, { verb, target }) => {
2034
2037
  if (e && e.opSuperseded) return { kind: "ok", text: lookup2("op.superseded.note", { target }) };
2035
2038
  if (e && e.guard) return { kind: "err", text: guardBlockedText(e.guard) };
2036
2039
  return { kind: "err", text: lookup2(PLAIN_FAIL_KEY[verb], { err: e && e.message || e }) };
2037
2040
  };
2038
- return { guardBlockedText, buildsSuffix, upgradeOkText, mutationFailed };
2041
+ return { guardBlockedText, buildsSuffix, upgradeOkText, degradedOutcome, mutationFailed };
2039
2042
  }
2040
2043
  var PLAIN_FAIL_KEY;
2041
2044
  var init_op_outcome = __esm({
@@ -2345,8 +2348,12 @@ var ZH = {
2345
2348
  "notify.upgraded.clientonly": "\u3002\u7EAF\u5BA2\u6237\u7AEF\u66F4\u65B0\uFF1A\u5237\u65B0\u9875\u9762\u5373\u53EF\u751F\u6548\uFF0C\u65E0\u9700\u91CD\u542F",
2346
2349
  "notify.upgraded.activationUnknown": "\u3002\u751F\u6548\u5224\u5B9A\u672A\u5B8C\u6210\uFF1A\u4E3A\u786E\u4FDD\u751F\u6548\u8BF7\u91CD\u542F DSH Web",
2347
2350
  "notify.upgradehint": "\uFF08\u6CE8\u610F\uFF1A\u8BE5\u63D2\u4EF6\u6267\u884C\u4E86\u6784\u5EFA\u811A\u672C\uFF09",
2351
+ "op.degraded": "\u5347\u7EA7\u5931\u8D25\uFF1A\u65B0\u7248\u5B89\u88C5\u672A\u6210\u529F\uFF0C\u5DF2\u81EA\u52A8\u6062\u590D\u65E7\u7248 {restoredTo}\u2014\u2014\u63D2\u4EF6\u5F53\u524D\u53EF\u7528\uFF0C\u53EF\u7A0D\u540E\u91CD\u8BD5\u3002",
2348
2352
  "notify.selfupgraded": "dsh-m \u5DF2\u5347\u7EA7\uFF08v{from} \u2192 v{to}\uFF09",
2349
2353
  "notify.selfupgraded.failed": "dsh-m \u5347\u7EA7\u5931\u8D25\uFF1A{err}",
2354
+ "selfupgrade.confirm": "\u5C06\u901A\u8FC7 tarball \u76F4\u88C5\u901A\u9053\u628A dsh-m \u5347\u7EA7\u5230 {version}\uFF1A\u4E0D\u7ECF\u8FC7\u5378\u8F7D\uFF0C\u5931\u8D25\u65F6\u5F53\u524D\u7248\u672C\u4E0D\u53D7\u5F71\u54CD\uFF1B\u5B8C\u6210\u540E\u9700\u91CD\u542F Desktop \u751F\u6548\u3002",
2355
+ "selfupgrade.confirm.noversion": "\u5C06\u901A\u8FC7 tarball \u76F4\u88C5\u901A\u9053\u5347\u7EA7 dsh-m\uFF1A\u4E0D\u7ECF\u8FC7\u5378\u8F7D\uFF0C\u5931\u8D25\u65F6\u5F53\u524D\u7248\u672C\u4E0D\u53D7\u5F71\u54CD\uFF1B\u5B8C\u6210\u540E\u9700\u91CD\u542F Desktop \u751F\u6548\u3002",
2356
+ "selfupgrade.go": "\u5347\u7EA7",
2350
2357
  "failed.install": "\u5B89\u88C5\u5931\u8D25\uFF1A{err}",
2351
2358
  "failed.uninstall": "\u5378\u8F7D\u5931\u8D25\uFF1A{err}",
2352
2359
  "failed.upgrade": "\u5347\u7EA7\u5931\u8D25\uFF1A{err}",
@@ -2645,8 +2652,12 @@ var EN = {
2645
2652
  "notify.upgraded.clientonly": ". Client-only update: refresh the page to take effect \u2014 no restart needed",
2646
2653
  "notify.upgraded.activationUnknown": ". Activation classification unavailable: restart DSH Web to ensure the new version is live",
2647
2654
  "notify.upgradehint": " (note: this plugin ran build scripts)",
2655
+ "op.degraded": "Upgrade failed: the new version did not install \u2014 rolled back to {restoredTo}. The plugin still works; you can retry later.",
2648
2656
  "notify.selfupgraded": "dsh-m upgraded (v{from} \u2192 v{to})",
2649
2657
  "notify.selfupgraded.failed": "dsh-m upgrade failed: {err}",
2658
+ "selfupgrade.confirm": "Upgrade dsh-m to {version} via the tarball direct-install channel: no uninstall involved, the current version stays intact on failure; a Desktop restart is needed afterwards.",
2659
+ "selfupgrade.confirm.noversion": "Upgrade dsh-m via the tarball direct-install channel: no uninstall involved, the current version stays intact on failure; a Desktop restart is needed afterwards.",
2660
+ "selfupgrade.go": "Upgrade",
2650
2661
  "failed.install": "Install failed: {err}",
2651
2662
  "failed.uninstall": "Uninstall failed: {err}",
2652
2663
  "failed.upgrade": "Upgrade failed: {err}",
@@ -4164,9 +4175,15 @@ function MarketTab({ notify, markets, onMutation, ops, favorites, profileKind, o
4164
4175
  const m = opUpgradeMeta2(r);
4165
4176
  return m ? { ...r, opMeta: m } : r;
4166
4177
  });
4167
- const ok = oc.upgradeOkText(res);
4168
- notify({ kind: "ok", needsRestart: ok.needsRestart, text: ok.text });
4169
- setInstallNote({ id: it.id, kind: "ok", text: ok.text });
4178
+ const degraded = oc.degradedOutcome(res);
4179
+ if (degraded) {
4180
+ notify({ kind: "warn", text: degraded.text });
4181
+ setInstallNote({ id: it.id, kind: "warn", text: degraded.text });
4182
+ } else {
4183
+ const ok = oc.upgradeOkText(res);
4184
+ notify({ kind: "ok", needsRestart: ok.needsRestart, text: ok.text });
4185
+ setInstallNote({ id: it.id, kind: "ok", text: ok.text });
4186
+ }
4170
4187
  await (onMutation ? onMutation() : void 0);
4171
4188
  } catch (e) {
4172
4189
  const m = oc.mutationFailed(e, { verb: "upgrade", target: it.installedPkg });
@@ -4560,8 +4577,13 @@ function InstalledTab({ notify, installed, updates, onMutation, ops }) {
4560
4577
  const m = opUpgradeMeta2(r);
4561
4578
  return m ? { ...r, opMeta: m } : r;
4562
4579
  });
4563
- const ok = oc.upgradeOkText(res);
4564
- notify({ kind: "ok", needsRestart: ok.needsRestart, text: ok.text });
4580
+ const degraded = oc.degradedOutcome(res);
4581
+ if (degraded) {
4582
+ notify({ kind: "warn", text: degraded.text });
4583
+ } else {
4584
+ const ok = oc.upgradeOkText(res);
4585
+ notify({ kind: "ok", needsRestart: ok.needsRestart, text: ok.text });
4586
+ }
4565
4587
  await (onMutation ? onMutation() : reload());
4566
4588
  } catch (e) {
4567
4589
  const m = oc.mutationFailed(e, { verb: "upgrade", target: it.pkg });
@@ -5169,11 +5191,12 @@ var TABS = [
5169
5191
  ["installed", "tab.installed", "installed"],
5170
5192
  ["settings", "tab.settings", null]
5171
5193
  ];
5172
- function DshmVersionChip({ version, notify, initialCheck, ops }) {
5194
+ function DshmVersionChip({ version, notify, initialCheck, ops, initialConfirm }) {
5173
5195
  const [check, setCheck] = useState(
5174
5196
  () => initialCheck !== void 0 ? initialCheck : typeof window !== "undefined" && window.localStorage ? readSelfCheckCache2(window.localStorage, { version }) : null
5175
5197
  );
5176
5198
  const [busy, setBusy] = useState(false);
5199
+ const [confirming, setConfirming] = useState(initialConfirm === true);
5177
5200
  useEffect(() => {
5178
5201
  if (initialCheck !== void 0) return;
5179
5202
  let live = true;
@@ -5191,6 +5214,11 @@ function DshmVersionChip({ version, notify, initialCheck, ops }) {
5191
5214
  }, [initialCheck]);
5192
5215
  const doUpgrade = async () => {
5193
5216
  if (busy) return;
5217
+ setConfirming(true);
5218
+ };
5219
+ const performUpgrade = async () => {
5220
+ if (busy) return;
5221
+ setConfirming(false);
5194
5222
  setBusy(true);
5195
5223
  try {
5196
5224
  const res = await (ops ? ops.runOp("upgrade", "dsh-m", async () => {
@@ -5201,7 +5229,9 @@ function DshmVersionChip({ version, notify, initialCheck, ops }) {
5201
5229
  setCheck({ current: version, latest: null, outdated: false, ahead: false });
5202
5230
  notify({
5203
5231
  kind: "ok",
5204
- needsRestart: true,
5232
+ // 0.10.0 R10:生效语义忠实透传宿主 application——applied/overridden → false(不弹重启横幅)、
5233
+ // restart-required → true;响应缺席时保守 true(旧行为兜底)
5234
+ needsRestart: res.needsRestart ?? true,
5205
5235
  text: lookup("notify.selfupgraded", { from: version, to: res.version || "latest" }) + oc.buildsSuffix(res)
5206
5236
  });
5207
5237
  } catch (e) {
@@ -5217,6 +5247,36 @@ function DshmVersionChip({ version, notify, initialCheck, ops }) {
5217
5247
  };
5218
5248
  const state = deriveChipState2(check, version);
5219
5249
  if (state.kind === "outdated") {
5250
+ if (confirming) {
5251
+ return h(
5252
+ "div",
5253
+ { className: "dshm-compat-overlay", ...backdropCloseHandlers2(() => setConfirming(false)) },
5254
+ h(
5255
+ "div",
5256
+ { className: "dshm-compat-dialog", onClick: (e) => e.stopPropagation() },
5257
+ h("div", { className: "dshm-compat-title" }, lookup("selfupdate.available", { v: state.latest || "?" })),
5258
+ h(
5259
+ "div",
5260
+ { className: "dshm-compat-body" },
5261
+ state.latest ? lookup("selfupgrade.confirm", { version: `v${state.latest}` }) : lookup("selfupgrade.confirm.noversion")
5262
+ ),
5263
+ h(
5264
+ "div",
5265
+ { className: "dshm-actions" },
5266
+ h(
5267
+ "button",
5268
+ { className: "dshm-btn", onClick: () => setConfirming(false) },
5269
+ lookup("common.later")
5270
+ ),
5271
+ h(
5272
+ "button",
5273
+ { className: "dshm-btn primary", onClick: performUpgrade, disabled: busy },
5274
+ lookup("selfupgrade.go")
5275
+ )
5276
+ )
5277
+ )
5278
+ );
5279
+ }
5220
5280
  return h(
5221
5281
  "button",
5222
5282
  {
@@ -30,7 +30,7 @@ export const WRITE_GUIDANCE = {
30
30
  'set-enabled': '',
31
31
  upgrade: 'Desktop 暂不支持在市场内升级插件:请在官方 Desktop 的插件管理(Settings → Plugins)检查更新,或回到 DSH Web 使用 dsh-m 升级。',
32
32
  uninstall: 'Desktop 暂不支持在市场内卸载插件:请使用官方 Desktop 的插件管理(Settings → Plugins)卸载。',
33
- 'self-upgrade': 'Desktop 暂不支持 dsh-m 自更新:请在 DSH Web 端执行,或关注官方 Desktop 的插件更新入口。',
33
+ 'self-upgrade': '当前宿主形态不支持面板内自更新:请在官方 Settings → Plugins 对 dsh-m 执行 remove + install(精确版本),或完全退出 Desktop 后用 `dsh plugin --profile desktop add dsh-m@<版本>`。',
34
34
  restart: 'Desktop 不由 dsh-m 重启:请退出并重新打开 Desktop 应用(关闭窗口可能只是隐藏),让新安装的插件生效。',
35
35
  };
36
36
  export class ProfileUnsupportedError extends Error {
@@ -13,9 +13,10 @@ import { runDoctor } from './doctor.js';
13
13
  import { listInstalledWithMeta, listMarket, existingMarketIds, installFromRegistry, selfUpgrade, uninstallPlugin, upgradePlugin, InstallGuardError, } from './market.js';
14
14
  import { runProfileTransaction, TransactionError } from './profile-transaction.js';
15
15
  import { listExternalOperations, clearExternalOperations, removeExternalOperation } from './external-journal.js';
16
- import { readInstalledPluginReadme } from './installed.js';
16
+ import { readUpgradeIntent, resolveUpgradeIntent, writeUpgradeIntent } from './upgrade-intent.js';
17
+ import { readInstalledPluginReadme, listInstalledPlugins } from './installed.js';
17
18
  import { loadPrimaryScreenshots } from './primary-screenshots.js';
18
- import { isNewerVersion, npmLatest, npmPackumentReadme } from './versions.js';
19
+ import { isNewerVersion, npmLatest, npmTarballUrl, npmPackumentReadme } from './versions.js';
19
20
  import { getCommunitySummary } from './community.js';
20
21
  import { RegistryConfigError } from './registry-controller.js';
21
22
  import { checkRegistryEntries } from './registry-check.js';
@@ -24,7 +25,7 @@ import { servingPort, scheduleRestart } from './restart.js';
24
25
  import { togglePlugin, ToggleError } from './toggle.js';
25
26
  import { IncompatibleError } from './compat-check.js';
26
27
  import { ProfileUnsupportedError, assertWriteAllowed } from './active-profile.js';
27
- import { DesktopOpsError, desktopInstallFromRegistry, desktopToggle, desktopUninstall, desktopUpgradeFromRegistry } from './profile-ops.js';
28
+ import { DesktopOpsError, desktopInstallFromRegistry, desktopToggle, desktopUninstall, desktopUpgradeFromRegistry, desktopSelfUpgradeViaTarball } from './profile-ops.js';
28
29
  export class BadJsonError extends Error {
29
30
  }
30
31
  export class BodyTooLargeError extends Error {
@@ -226,12 +227,20 @@ export function createApiDispatcher(ctx) {
226
227
  desktopToggle: ctx.deps?.desktopToggle ?? desktopToggle,
227
228
  desktopUninstall: ctx.deps?.desktopUninstall ?? desktopUninstall,
228
229
  desktopUpgrade: ctx.deps?.desktopUpgrade ?? desktopUpgradeFromRegistry,
230
+ desktopSelfUpgradeViaTarball: ctx.deps?.desktopSelfUpgradeViaTarball ?? desktopSelfUpgradeViaTarball,
231
+ readUpgradeIntent: ctx.deps?.readUpgradeIntent ?? readUpgradeIntent,
232
+ resolveUpgradeIntent: ctx.deps?.resolveUpgradeIntent ?? resolveUpgradeIntent,
233
+ writeUpgradeIntent: ctx.deps?.writeUpgradeIntent ?? writeUpgradeIntent,
234
+ npmTarballUrl: ctx.deps?.npmTarballUrl ?? npmTarballUrl,
235
+ listInstalled: ctx.deps?.listInstalled ?? listInstalledPlugins,
229
236
  ensureService: ctx.deps?.ensureService,
230
237
  listExternalOperations: ctx.deps?.listExternalOperations ?? listExternalOperations,
231
238
  clearExternalOperations: ctx.deps?.clearExternalOperations ?? clearExternalOperations,
232
239
  removeExternalOperation: ctx.deps?.removeExternalOperation ?? removeExternalOperation,
233
240
  };
234
241
  const cfg = () => ctx.controller.config;
242
+ // 0.10.0:开机对账每进程一次(dispatcher 生命周期 = 宿主一次 apply)
243
+ let selfCheckIntentDone = false;
235
244
  // 0.9.0:宿主信任检查委派(含 ping 与未知 method,fail-closed);
236
245
  // profile 为 host 生命周期内不可变对象(host.ts apply 期解析一次)
237
246
  const rejectRequest = ctx.deps?.rejectRequest ?? (() => 403);
@@ -261,30 +270,55 @@ export function createApiDispatcher(ctx) {
261
270
  };
262
271
  break;
263
272
  case 'self-check': {
273
+ // 0.10.0 开机对账(崩溃兜底网,每进程一次):上进程若死于自升级 promise 悬垂,delegated
274
+ // intent 遗留至此——与本进程实际版本比对收口;本进程内 D3 已就地 resolve 的(或无悬垂)
275
+ // 幂等 no-op。ADR-0018:读写独立 intent ledger,零外部日志。对账自身故障零涟漪。
276
+ let intentResolved;
277
+ if (!selfCheckIntentDone) {
278
+ selfCheckIntentDone = true;
279
+ try {
280
+ const rec = await d.readUpgradeIntent();
281
+ if (rec && rec.status === 'delegated') {
282
+ const match = rec.version === ctx.pkg.version;
283
+ await d.resolveUpgradeIntent({ ok: match, installedVersion: match ? ctx.pkg.version : undefined });
284
+ intentResolved = { ok: match, target: rec.target, ...(match ? { installedVersion: ctx.pkg.version } : {}) };
285
+ }
286
+ }
287
+ catch {
288
+ // delegated 残留下次进程再收口;绝不阻塞 self-check
289
+ }
290
+ }
264
291
  try {
265
292
  const latest = await d.npmLatest(ctx.pkg.name, cfg().timeoutMs ?? 20_000);
266
293
  const outdated = isNewerVersion(latest.version, ctx.pkg.version);
267
294
  // ahead(0.8.0):本地 dev 版领先 npm 发布——设置页改显「本地开发版」而非误导性的旧「npm 最新」
268
295
  const ahead = !outdated && latest.version !== ctx.pkg.version;
269
- payload = { current: ctx.pkg.version, latest: latest.version, outdated, ahead };
296
+ payload = { current: ctx.pkg.version, latest: latest.version, outdated, ahead, ...(intentResolved ? { intentResolved } : {}) };
270
297
  }
271
298
  catch (err) {
272
- payload = { current: ctx.pkg.version, latest: null, outdated: false, ahead: false, error: err instanceof Error ? err.message : String(err) };
299
+ payload = { current: ctx.pkg.version, latest: null, outdated: false, ahead: false, error: err instanceof Error ? err.message : String(err), ...(intentResolved ? { intentResolved } : {}) };
273
300
  }
274
301
  break;
275
302
  }
276
303
  case 'self-upgrade': {
277
304
  assertWriteAllowed(profile, 'self-upgrade');
278
- // 0.9.8:desktop → 官方管理器覆盖安装 dsh-m@latest(dsh-market 同策略实证;
279
- // 生效仍需重启 Desktop——banner.desktop 指引由客户端 needsRestart 通道给出)
305
+ // 0.10.0 D3 自升级专用通道(desktop):tarball 直覆盖。desktopInstall 的 registry spec 在
306
+ // 0.2.1 宿主必撞 already-installed 守卫,而 remove+add 对调用者自身是破坏性路径(R6:自升级
307
+ // 绝不落入通用两步——改道唯一执行点在 core 缝,此处调 D3 入口即语义同源);tarball spec
308
+ // 不受守卫约束(spike 实证)。恒 panel 语义零外部日志(ADR-0018),intent 预写/收口在 core 层。
280
309
  if (profile.kind !== 'web') {
281
- const managed = await d.desktopInstall(ctx.pkg.name, cfg(), {
282
- namespace: 'host',
283
- profile: profile.name,
284
- profileDir: profile.dir,
310
+ const managed = await d.desktopSelfUpgradeViaTarball(ctx.pkg.name, ctx.pkg.version, {
311
+ timeoutMs: cfg().timeoutMs,
285
312
  signal,
286
- channel: 'panel', // 0.9.79:面板自升级零外部记录(漏标 = 落 external,违反面板零记录不变量)
287
- }, { getService: d.getService, ensureService: d.ensureService });
313
+ }, {
314
+ getService: d.getService,
315
+ ensureService: d.ensureService,
316
+ npmLatest: d.npmLatest,
317
+ npmTarballUrl: d.npmTarballUrl,
318
+ writeUpgradeIntent: d.writeUpgradeIntent,
319
+ resolveUpgradeIntent: d.resolveUpgradeIntent,
320
+ listInstalled: d.listInstalled,
321
+ });
288
322
  if ('needsBuildApproval' in managed) {
289
323
  // build-blocked(自升级罕见):结构化透出,不冒充成功
290
324
  payload = { ...managed };
@@ -295,7 +329,8 @@ export function createApiDispatcher(ctx) {
295
329
  version: managed.version,
296
330
  buildApprovals: managed.buildApprovals ?? [],
297
331
  fallbackAllBuilds: false,
298
- needsRestart: true,
332
+ // R10:忠实透传宿主 application——applied/overridden → false;restart-required → true
333
+ needsRestart: managed.needsRestart,
299
334
  };
300
335
  break;
301
336
  }
@@ -514,7 +549,16 @@ export function createApiDispatcher(ctx) {
514
549
  profileDir: profile.dir,
515
550
  signal,
516
551
  channel: 'panel',
517
- }, { getService: d.getService, ensureService: d.ensureService })) };
552
+ }, {
553
+ getService: d.getService,
554
+ ensureService: d.ensureService,
555
+ // 0.10.0:R5 previousVersion / SELF_PKG 缝改道注入透传(生产缺省 = 真实现;测试面经 overrides)
556
+ npmLatest: d.npmLatest,
557
+ listInstalled: d.listInstalled,
558
+ npmTarballUrl: d.npmTarballUrl,
559
+ writeUpgradeIntent: d.writeUpgradeIntent,
560
+ resolveUpgradeIntent: d.resolveUpgradeIntent,
561
+ })) };
518
562
  break;
519
563
  }
520
564
  const version = typeof body.version === 'string' ? body.version : undefined;
@@ -572,7 +616,16 @@ export function createApiDispatcher(ctx) {
572
616
  profileDir: profile.dir,
573
617
  signal,
574
618
  channel: 'panel',
575
- }, { getService: d.getService, ensureService: d.ensureService })) };
619
+ }, {
620
+ getService: d.getService,
621
+ ensureService: d.ensureService,
622
+ // 0.10.0:两步化 previousVersion / SELF_PKG 缝改道 → D3 全链注入透传(生产缺省 = 真实现)
623
+ npmLatest: d.npmLatest,
624
+ listInstalled: d.listInstalled,
625
+ npmTarballUrl: d.npmTarballUrl,
626
+ writeUpgradeIntent: d.writeUpgradeIntent,
627
+ resolveUpgradeIntent: d.resolveUpgradeIntent,
628
+ })) };
576
629
  break;
577
630
  }
578
631
  const result = await d.upgradePlugin(target, cfg(), {
@@ -255,6 +255,17 @@ export async function npmVersion(pkg, version, timeoutMs = 20_000, signal, regis
255
255
  throw firstErr;
256
256
  }
257
257
  }
258
+ /**
259
+ * 精确版本 tarball URL(0.10.0,D3 自升级专用通道的取 URL 腿):
260
+ * `npmVersion` 薄封装——复用同一 L2① 履约阶梯(ADR-0012,含镜像 sync 腿与 npmjs 兜底),
261
+ * 不新增 packument 面。tarball 缺失 / 版本不存在 → 可读错误上抛。
262
+ */
263
+ export async function npmTarballUrl(pkg, version, timeoutMs = 20_000, signal, registry, deps) {
264
+ const detail = await npmVersion(pkg, version, timeoutMs, signal, registry, deps);
265
+ if (!detail.tarball)
266
+ throw new Error(`${pkg}@${version} packument 缺 dist.tarball`);
267
+ return detail.tarball;
268
+ }
258
269
  /**
259
270
  * 升级探测(检测读)。L3 权威链(ADR-0012):新鲜度敏感——npmjs 权威源优先
260
271
  * (首腿超时收紧 `Math.min(timeoutMs, 5_000)`),失败降级生效源(≠npmjs 时);
@@ -28,7 +28,8 @@ import { loadRegistry as defaultLoadRegistry } from './registry.js';
28
28
  import { fetchCommunityCatalog as defaultFetchCommunityCatalog } from './community.js';
29
29
  import { listInstalledPlugins as defaultListInstalled } from './installed.js';
30
30
  import { precheckNpmCompat, IncompatibleError } from './compat-check.js';
31
- import { npmLatest as defaultNpmLatest, githubLatestTag as defaultGithubLatestTag } from './versions.js';
31
+ import { npmLatest as defaultNpmLatest, npmTarballUrl as defaultNpmTarballUrl, githubLatestTag as defaultGithubLatestTag } from './versions.js';
32
+ import { writeUpgradeIntent as defaultWriteUpgradeIntent, resolveUpgradeIntent as defaultResolveUpgradeIntent } from './upgrade-intent.js';
32
33
  import { classifyUpgradeActivation } from './activation.js';
33
34
  import { togglePlugin, ToggleError } from './toggle.js';
34
35
  import { describeReleaseAgeFailure, releaseAgePrecheck } from './release-age.js';
@@ -177,12 +178,37 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
177
178
  if (gate.notice)
178
179
  releaseAgeNotice = `;${gate.notice}`;
179
180
  }
181
+ // 0.10.0 R5:install 目标已装(0.2.1 宿主 already-installed 守卫必拦)→ 预查旧版本号,
182
+ // 使 runManagedInstall 在守卫命中时可静默两步化(remove+add);全新包(未命中)不注入——
183
+ // T6 钉零 removeBundle。探测失败 fail-open(守卫命中且无 previousVersion 时走既有失败形态)。
184
+ let previousVersion;
185
+ if (opts.profileDir) {
186
+ try {
187
+ const { items } = await (deps.listInstalled ?? defaultListInstalled)(opts.profileDir);
188
+ previousVersion = items.find((it) => it.pkg === pkg)?.version;
189
+ }
190
+ catch {
191
+ previousVersion = undefined;
192
+ }
193
+ }
194
+ // 0.10.0 R6(install 臂):dsh-m 已装态下的 install 调用实为升级(守卫必拦)→ 改道 D3;
195
+ // 全新安装(未装)走既有单步——守卫不触发,R1「新装路径不动」。
196
+ if (pkg === SELF_PKG && previousVersion !== undefined) {
197
+ return desktopSelfUpgradeViaTarballLocked(pkg, previousVersion, { timeoutMs, signal: opts.signal }, {
198
+ getService: () => service,
199
+ npmLatest: deps.npmLatest,
200
+ npmTarballUrl: deps.npmTarballUrl,
201
+ writeUpgradeIntent: deps.writeUpgradeIntent,
202
+ resolveUpgradeIntent: deps.resolveUpgradeIntent,
203
+ listInstalled: deps.listInstalled,
204
+ });
205
+ }
180
206
  let change;
181
207
  let bundleName;
182
208
  let registerNote;
183
209
  let managed;
184
210
  try {
185
- managed = await runManagedInstall(service, { pkg, spec, version, profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }, { governExclude: deps.governExclude, registerExclude: deps.registerExclude, packumentTimes: deps.packumentTimes });
211
+ managed = await runManagedInstall(service, { pkg, spec, version, ...(previousVersion !== undefined ? { previousVersion } : {}), profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }, { governExclude: deps.governExclude, registerExclude: deps.registerExclude, packumentTimes: deps.packumentTimes });
186
212
  change = managed.change;
187
213
  bundleName = managed.bundleName;
188
214
  registerNote = managed.registerNote;
@@ -200,6 +226,9 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
200
226
  throw err;
201
227
  }
202
228
  const retryNote = managed.governRetryNote !== undefined && !governanceNote.includes(managed.governRetryNote) ? managed.governRetryNote : '';
229
+ const twoStepSuffix = managed.twoStepNote !== undefined ? `;${managed.twoStepNote}` : '';
230
+ const degradedRestoredTo = managed.degraded === true ? managed.restoredTo : undefined;
231
+ const degradedSuffix = degradedRestoredTo !== undefined ? `;升级失败已回装旧版 ${degradedRestoredTo}(降级恢复)` : '';
203
232
  return {
204
233
  id: entry.id,
205
234
  pkg,
@@ -212,9 +241,12 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
212
241
  ...(compat !== null ? { compat } : {}),
213
242
  ...(compatSkipped ? { compatSkipped } : {}),
214
243
  needsRestart: true,
215
- output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}${governanceNote}${retryNote}${releaseAgeNotice}${registerNote ?? ''}`,
244
+ output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}${governanceNote}${retryNote}${releaseAgeNotice}${registerNote ?? ''}${twoStepSuffix}${degradedSuffix}`,
216
245
  via: 'desktop-manager',
217
246
  ...(change.application === 'overridden' ? { overridden: true } : {}),
247
+ ...(degradedRestoredTo !== undefined ? { degraded: true, restoredTo: degradedRestoredTo } : {}),
248
+ // 0.10.0(评审 R1-2):降级恢复的 version 落账为回装后实际版本(账实相符);spec 字段保留尝试目标
249
+ ...(degradedRestoredTo !== undefined ? { version: degradedRestoredTo } : {}),
218
250
  };
219
251
  }
220
252
  /**
@@ -284,8 +316,27 @@ function isGovableFailure(change) {
284
316
  return false;
285
317
  return change.application === 'failed' || change.error !== undefined;
286
318
  }
319
+ /** 官方 pendingBuilds 的鸭子读投影(strings-only)。 */
320
+ function pendingOf(change) {
321
+ return Array.isArray(change.pendingBuilds) ? change.pendingBuilds.filter((n) => typeof n === 'string') : [];
322
+ }
323
+ /** 终态失败(与 runManagedInstall 既有失败闸门同口径)。 */
324
+ function isTerminalFailure(change) {
325
+ return change.application === 'failed' || change.application === 'cancelled' || change.error !== undefined;
326
+ }
327
+ /**
328
+ * 宿主 0.2.1 already-installed 安装守卫命中(0.10.0,R2):application=failed +
329
+ * error.code 精确匹配,且 release-age / build-blocked 形态先于本判定(等待期与审批语义优先)。
330
+ */
331
+ function isAlreadyInstalledGuard(change) {
332
+ if (change.application !== 'failed' || change.error?.code !== 'already-installed')
333
+ return false;
334
+ if (change.packageResult?.kind === 'build-blocked' && pendingOf(change).length > 0)
335
+ return false;
336
+ return !/ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|minimumReleaseAge/i.test(`${change.error?.diagnostic ?? ''}\n${change.error?.code ?? ''}`);
337
+ }
287
338
  async function runManagedInstall(service, ctx, deps = {}) {
288
- const installOnce = async () => changeOf(await service.installBundle(ctx.spec, {
339
+ const installOnce = async (spec = ctx.spec) => changeOf(await service.installBundle(spec, {
289
340
  enabled: true,
290
341
  ...(ctx.approvedBuilds && ctx.approvedBuilds.length > 0 ? { approvedBuilds: ctx.approvedBuilds } : {}),
291
342
  }));
@@ -364,6 +415,55 @@ async function runManagedInstall(service, ctx, deps = {}) {
364
415
  change = await installOnce();
365
416
  }
366
417
  }
418
+ // 0.10.0(宿主 0.2.1 already-installed 守卫适配,R2/R3):首腿守卫命中 → 静默两步化
419
+ // (removeBundle 旧版 → 重试安装同一 spec,至多一次、无循环);第二腿终态失败 → 兜底回装旧版
420
+ // (remove 后旧名已不在 manifest,守卫不适用):回装成功 = 降级恢复结局(degraded,R4);
421
+ // cancelled / build-blocked 语义优先(不冒充降级);回装失败 = 缺席硬错误(rollback-failed)。
422
+ // 无 previousVersion 不两步化——R3 兜底网的存在前提(无旧版即无回装能力)。
423
+ let twoStepNote;
424
+ if (isAlreadyInstalledGuard(change) && ctx.previousVersion && typeof service.removeBundle === 'function') {
425
+ twoStepNote = 'already-installed → two-step';
426
+ const removeChange = changeOf(await service.removeBundle(ctx.pkg));
427
+ if (isTerminalFailure(removeChange)) {
428
+ // remove 腿失败短路(不回装):旧版尚未移除,状态以官方插件页为准
429
+ throw new DesktopOpsError('remove-failed', `两步升级第一步(卸载旧版)失败(${ctx.pkg};application=${removeChange.application ?? 'failed'}${removeChange.error?.code ? `、error=${removeChange.error.code}` : ''}):未执行升级,请到官方 Desktop 插件页确认 ${ctx.pkg} 状态`);
430
+ }
431
+ change = await installOnce();
432
+ if (isTerminalFailure(change)) {
433
+ const pendingAdd = pendingOf(change);
434
+ const addDiagnostic = `${change.error?.diagnostic ?? ''}${change.error?.code ? `\n${change.error.code}` : ''}`.trim();
435
+ const addCancelled = change.application === 'cancelled';
436
+ const addBuildBlocked = change.packageResult?.kind === 'build-blocked' && pendingAdd.length > 0;
437
+ // 兜底回装旧版(R3):回装腿失败(含其自身 build-blocked)= 缺席硬错误,降级失败真相优先
438
+ const rollbackChange = await installOnce(`${ctx.pkg}@${ctx.previousVersion}`);
439
+ const pendingRollback = pendingOf(rollbackChange);
440
+ if (isTerminalFailure(rollbackChange) || (rollbackChange.packageResult?.kind === 'build-blocked' && pendingRollback.length > 0)) {
441
+ throw new DesktopOpsError('rollback-failed', `升级失败且自动回装旧版也失败(${ctx.pkg}@${ctx.previousVersion}):插件当前可能缺席,请在官方 Desktop 插件页手动重装 ${ctx.pkg}@${ctx.previousVersion}${addDiagnostic !== '' ? `;升级失败原因:${addDiagnostic}` : ''}`, {
442
+ pkg: ctx.pkg,
443
+ previousVersion: ctx.previousVersion,
444
+ ...(addDiagnostic !== '' ? { addDiagnostic } : {}),
445
+ ...(pendingRollback.length > 0 ? { pendingBuilds: pendingRollback } : {}),
446
+ });
447
+ }
448
+ const verifiedRollback = await verifyAndBundle(rollbackChange);
449
+ if (addBuildBlocked) {
450
+ // build-blocked 语义优先:旧版已兜住,仍抛审批请求(不冒充降级成功)
451
+ throw new DesktopBuildApprovalNeeded(pendingAdd);
452
+ }
453
+ if (addCancelled) {
454
+ // 取消不冒充降级成功:已恢复旧版、未做升级
455
+ throw new DesktopOpsError('install-refused', `升级已被取消(${ctx.pkg}):已恢复旧版 ${ctx.previousVersion},未做升级`);
456
+ }
457
+ // 降级恢复(R4):成功形态 + degraded 标记;跳过挂点2 登记——实际在装的是旧版,登记新版本会是假账
458
+ return {
459
+ ...verifiedRollback,
460
+ ...(governRetryNote !== undefined ? { governRetryNote } : {}),
461
+ degraded: true,
462
+ restoredTo: ctx.previousVersion,
463
+ twoStepNote,
464
+ };
465
+ }
466
+ }
367
467
  if (change.application === 'failed' || change.application === 'cancelled' || change.error) {
368
468
  try {
369
469
  await mapFailure(change);
@@ -391,7 +491,11 @@ async function runManagedInstall(service, ctx, deps = {}) {
391
491
  // fail-open:登记失败不影响安装/升级成功态
392
492
  }
393
493
  }
394
- return { ...verified, ...(registerNote !== undefined || governRetryNote !== undefined ? { registerNote, governRetryNote } : {}) };
494
+ return {
495
+ ...verified,
496
+ ...(registerNote !== undefined || governRetryNote !== undefined ? { registerNote, governRetryNote } : {}),
497
+ ...(twoStepNote !== undefined ? { twoStepNote } : {}),
498
+ };
395
499
  }
396
500
  /**
397
501
  * Desktop 卸载(0.9.8,dsh-market 同策略):removeBundle 委派 + ChangeResult 判定 +
@@ -469,12 +573,112 @@ export async function desktopUpgradeFromRegistry(pkg, cfg = {}, opts = {}, deps
469
573
  throw err;
470
574
  }
471
575
  }
576
+ /** 0.10.0 R6:自升级专用通道的目标包(core 缝改道唯一判据;host-api/tools/客户端零特判)。 */
577
+ export const SELF_PKG = 'dsh-m';
578
+ /**
579
+ * Desktop 自升级专用通道(0.10.0,R6/R7/D3):npmLatest 精确版本 → 生效 registry packument 的
580
+ * dist.tarball → 预写 intent → installBundle(tarballUrl, {enabled:true}) 直覆盖——tarball spec
581
+ * 的包名在 pnpm 前不可知,不受宿主 0.2.1 already-installed 守卫约束(本机 spike 实证)。
582
+ * - 恒 panel 语义零外部日志(ADR-0018):intent 预写落独立 ledger(upgrade-intent.ts),不入 external journal;
583
+ * - 同版本 → install-refused no-op(T10);URL 构造失败发生在预写之前 → 零副作用失败(T12);
584
+ * - install 终态失败 → 旧版不受影响(spike 实证)+ intent 收口 failed;
585
+ * - 成功(applied/restart-required/overridden)→ intent 收口 ok + installedVersion;
586
+ * - promise 悬垂(进程死于活交换)→ intent 保持 delegated,由 self-check 对账收口(崩溃兜底网)。
587
+ * 导出层自包 withMutationSession(market.ts 同惯例);core 缝改道发生在宿主 upgrade/install 的
588
+ * session 之内,直接调 Locked 内层避免嵌套。
589
+ */
590
+ export async function desktopSelfUpgradeViaTarball(pkgName, currentVersion, opts = {}, deps = {}) {
591
+ return withMutationSession(() => desktopSelfUpgradeViaTarballLocked(pkgName, currentVersion, opts, deps));
592
+ }
593
+ async function desktopSelfUpgradeViaTarballLocked(pkgName, currentVersion, opts, deps) {
594
+ const service = await resolveManager(deps);
595
+ if (!service || typeof service.installBundle !== 'function') {
596
+ throw new DesktopOpsError('no-manager', '官方 pluginManager 服务不可用(desktop profile 的自升级必须由官方管理器执行);拒绝自升级(fail-closed)');
597
+ }
598
+ const timeoutMs = opts.timeoutMs ?? 20_000;
599
+ const latest = await (deps.npmLatest ?? defaultNpmLatest)(pkgName, timeoutMs, opts.signal);
600
+ if (latest.version === currentVersion) {
601
+ throw new DesktopOpsError('install-refused', `已是最新版本(${pkgName}@${currentVersion}),无需升级`);
602
+ }
603
+ const spec = await (deps.npmTarballUrl ?? defaultNpmTarballUrl)(pkgName, latest.version, timeoutMs, opts.signal);
604
+ // 预写 intent(崩溃兜底网):URL 构造失败已在此前抛出,零落盘(T12)
605
+ await (deps.writeUpgradeIntent ?? defaultWriteUpgradeIntent)({
606
+ target: pkgName,
607
+ version: latest.version,
608
+ ...(currentVersion !== '' ? { fromVersion: currentVersion } : {}),
609
+ at: Date.now(),
610
+ });
611
+ const resolveIntent = deps.resolveUpgradeIntent ?? defaultResolveUpgradeIntent;
612
+ try {
613
+ const change = changeOf(await service.installBundle(spec, { enabled: true }));
614
+ if (isTerminalFailure(change)) {
615
+ const pending = pendingOf(change);
616
+ if (change.application === 'cancelled') {
617
+ throw new DesktopOpsError('install-refused', `自升级已被取消(${pkgName}):当前版本 ${currentVersion} 不受影响`);
618
+ }
619
+ if (change.packageResult?.kind === 'build-blocked' && pending.length > 0) {
620
+ throw new DesktopBuildApprovalNeeded(pending);
621
+ }
622
+ throw new DesktopOpsError('install-refused', `自升级失败(${pkgName}@${latest.version};application=${change.application ?? 'failed'}${change.error?.code ? `、error=${change.error.code}` : ''}):当前版本 ${currentVersion} 不受影响,可稍后重试`);
623
+ }
624
+ const bundleName = change.bundle ?? pkgName;
625
+ if (typeof service.listBundles === 'function') {
626
+ const bundles = await service.listBundles();
627
+ const found = (bundles ?? []).find((b) => b && b.name === bundleName);
628
+ if (!found || found.installed === false) {
629
+ throw new DesktopOpsError('verify-failed', `自升级复读失败(${pkgName}):官方结果为 ${change.application ?? 'unknown'} 但 listBundles 未見目标 bundle 在装——不冒充升级成功`);
630
+ }
631
+ }
632
+ await resolveIntent({ ok: true, installedVersion: latest.version });
633
+ return {
634
+ id: pkgName,
635
+ pkg: pkgName,
636
+ spec,
637
+ version: latest.version,
638
+ buildApprovals: Array.isArray(change.approvedBuilds) ? change.approvedBuilds : [],
639
+ fallbackAllBuilds: false,
640
+ // R10 忠实透传宿主 application:restart-required → true;applied/overridden → false
641
+ needsRestart: change.application === 'restart-required',
642
+ output: `official pluginManager (self-upgrade via tarball): application=${change.application ?? 'applied'}, bundle=${bundleName}`,
643
+ via: 'desktop-manager',
644
+ ...(change.application === 'overridden' ? { overridden: true } : {}),
645
+ };
646
+ }
647
+ catch (err) {
648
+ // 未竟路径统一收口 intent 为 failed(成功臂已 resolve 时幂等 no-op);
649
+ // 收口失败不遮蔽原始错误——delegated 残留由 self-check 对账兜底。
650
+ try {
651
+ await resolveIntent({ ok: false });
652
+ }
653
+ catch {
654
+ /* 对账兜底仍在 */
655
+ }
656
+ throw err;
657
+ }
658
+ }
472
659
  async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
473
660
  const service = await resolveManager(deps);
474
661
  if (!service || typeof service.installBundle !== 'function') {
475
662
  throw new DesktopOpsError('no-manager', '官方 pluginManager 服务不可用(desktop profile 的升级必须由官方管理器执行覆盖安装);拒绝升级(fail-closed)');
476
663
  }
477
664
  const timeoutMs = cfg.timeoutMs ?? 20_000;
665
+ // 0.10.0 R6 自升级通道分治(唯一改道点):desktop 上「升级 dsh-m 自身」一律 D3 tarball 直覆盖——
666
+ // remove+add 对调用者自身是破坏性路径,绝不落入通用两步;host-api `case 'upgrade'`、tools
667
+ // `dshm_upgrade` 与一切经 core 的入口在此自动获得同语义(客户端与工具层零特判)。
668
+ if (pkg === SELF_PKG) {
669
+ const { items } = await (deps.listInstalled ?? defaultListInstalled)(opts.profileDir);
670
+ const selfTarget = items.find((it) => it.pkg === pkg);
671
+ if (!selfTarget)
672
+ throw new DesktopOpsError('install-refused', `desktop profile 未安装该插件: ${pkg}(升级目标必须是已装插件)`);
673
+ return desktopSelfUpgradeViaTarballLocked(pkg, selfTarget.version, { timeoutMs, signal: opts.signal }, {
674
+ getService: () => service,
675
+ npmLatest: deps.npmLatest,
676
+ npmTarballUrl: deps.npmTarballUrl,
677
+ writeUpgradeIntent: deps.writeUpgradeIntent,
678
+ resolveUpgradeIntent: deps.resolveUpgradeIntent,
679
+ listInstalled: deps.listInstalled,
680
+ });
681
+ }
478
682
  const loaded = await (deps.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host', profile: opts.profile });
479
683
  if (loaded.status === 'unavailable') {
480
684
  throw new DesktopOpsError('install-refused', `收录清单不可用,无法升级 ${pkg};请检查 registry 配置或网络后重试`);
@@ -594,6 +798,9 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
594
798
  throw err;
595
799
  }
596
800
  const retryNote = managed.governRetryNote !== undefined && !governanceNote.includes(managed.governRetryNote) ? managed.governRetryNote : '';
801
+ const twoStepSuffix = managed.twoStepNote !== undefined ? `;${managed.twoStepNote}` : '';
802
+ const degradedRestoredTo = managed.degraded === true ? managed.restoredTo : undefined;
803
+ const degradedSuffix = degradedRestoredTo !== undefined ? `;升级失败已回装旧版 ${degradedRestoredTo}(降级恢复)` : '';
597
804
  // 0.9.22 生效判定:npm 源升级在成功点分类;任何异常 fail-open 到 unknown,绝不影响升级成功态
598
805
  let activation;
599
806
  let needsRestart = true;
@@ -619,9 +826,12 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
619
826
  ...(compat !== null ? { compat } : {}),
620
827
  ...(compatSkipped ? { compatSkipped } : {}),
621
828
  needsRestart,
622
- output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}${governanceNote}${retryNote}${releaseAgeNotice}${registerNote ?? ''}`,
829
+ output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}${governanceNote}${retryNote}${releaseAgeNotice}${registerNote ?? ''}${twoStepSuffix}${degradedSuffix}`,
623
830
  via: 'desktop-manager',
624
831
  ...(change.application === 'overridden' ? { overridden: true } : {}),
832
+ ...(degradedRestoredTo !== undefined ? { degraded: true, restoredTo: degradedRestoredTo } : {}),
833
+ // 0.10.0(评审 R1-2):降级恢复的 version 落账为回装后实际版本(账实相符);spec 字段保留尝试目标
834
+ ...(degradedRestoredTo !== undefined ? { version: degradedRestoredTo } : {}),
625
835
  fromVersion: target.version,
626
836
  ...(activation ? { activation } : {}),
627
837
  };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * 自升级 intent ledger(0.10.0,ADR-0018 合规):panel 通道零外部日志 ⇒ 自升级 delegated
3
+ * 预写不进 external journal,落独立 ledger——定位为**崩溃兜底网**:D3 正常路径在本进程内
4
+ * 就地 resolve,仅当进程死于 promise 悬垂(活交换中进程死亡)时由下一次 self-check 对账
5
+ * 收口(host-api 挂载)。记录机器本地、单条覆盖写、不含凭据。
6
+ *
7
+ * - 存储文件:`$DSH_HOME/dshm/self-upgrade-intent.json`(`DSHM_UPGRADE_INTENT_FILE`
8
+ * 可整文件覆盖供测试;与 external-operations.json 同基址、不带 profile 维度)。
9
+ * - write:mkdir recursive 先行 → tmp+rename 原子替换(external-journal 同款惯例)。
10
+ * - read:文件缺失 = undefined;损坏 → 改名 `.corrupt-<ts>` 隔离留证后按空处理
11
+ * (best-effort,隔离失败同样不抛——读路径永不炸)。
12
+ * - resolve:仅收口悬垂(status='delegated')记录并落盘终态;无悬垂返回 undefined(幂等)。
13
+ */
14
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
15
+ import { dirname, join } from 'node:path';
16
+ import { dshHome } from './env.js';
17
+ /** ledger 文件路径:env 整文件覆盖优先(测试隔离),缺省 $DSH_HOME/dshm/self-upgrade-intent.json。 */
18
+ export function upgradeIntentFile() {
19
+ return process.env.DSHM_UPGRADE_INTENT_FILE || join(dshHome(), 'dshm', 'self-upgrade-intent.json');
20
+ }
21
+ /** 显式字段白名单重组(超集字段一律不落盘)。 */
22
+ function normalizeRecord(record) {
23
+ return {
24
+ target: record.target,
25
+ version: record.version,
26
+ ...(record.fromVersion !== undefined ? { fromVersion: record.fromVersion } : {}),
27
+ at: record.at,
28
+ status: record.status,
29
+ ...(record.outcome !== undefined ? { outcome: record.outcome } : {}),
30
+ ...(record.resolvedAt !== undefined ? { resolvedAt: record.resolvedAt } : {}),
31
+ ...(record.installedVersion !== undefined ? { installedVersion: record.installedVersion } : {}),
32
+ };
33
+ }
34
+ async function atomicWrite(path, text) {
35
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
36
+ await writeFile(tmp, text);
37
+ await rename(tmp, path);
38
+ }
39
+ /** 损坏文件隔离留证(best-effort,与 external-journal 同款两级重试;失败则放弃隔离按空继续)。 */
40
+ async function quarantine(file) {
41
+ try {
42
+ await rename(file, `${file}.corrupt-${Date.now()}`);
43
+ }
44
+ catch {
45
+ try {
46
+ // 同毫秒重入等极小概率命名冲突:换一个带随机尾缀的名字再试一次;仍败则放弃隔离
47
+ await rename(file, `${file}.corrupt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
48
+ }
49
+ catch {
50
+ /* 放弃隔离:按空继续 */
51
+ }
52
+ }
53
+ }
54
+ /** 读当前记录;文件缺失 → undefined;损坏/形态非法 → 隔离后按空处理(不抛)。 */
55
+ export async function readUpgradeIntent() {
56
+ const file = upgradeIntentFile();
57
+ let text;
58
+ try {
59
+ text = await readFile(file, 'utf8');
60
+ }
61
+ catch (err) {
62
+ if (err.code === 'ENOENT')
63
+ return undefined;
64
+ throw err;
65
+ }
66
+ let parsed;
67
+ try {
68
+ parsed = JSON.parse(text);
69
+ }
70
+ catch {
71
+ await quarantine(file);
72
+ return undefined;
73
+ }
74
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
75
+ await quarantine(file);
76
+ return undefined;
77
+ }
78
+ return normalizeRecord(parsed);
79
+ }
80
+ /** 预写一条 delegated 意图(status 由 writer 盖章;调用方不传 status/outcome 等终态字段)。 */
81
+ export async function writeUpgradeIntent(input) {
82
+ const file = upgradeIntentFile();
83
+ await mkdir(dirname(file), { recursive: true });
84
+ const record = normalizeRecord({ ...input, status: 'delegated' });
85
+ await atomicWrite(file, `${JSON.stringify(record, null, 2)}\n`);
86
+ return record;
87
+ }
88
+ /**
89
+ * 收口悬垂记录:status → resolved、outcome 按 ok 盖章、resolvedAt 就地取值、
90
+ * installedVersion 可选回填;落盘后返回被收口的记录。无悬垂(缺文件/已 resolved)→
91
+ * undefined(幂等,不冒充收口)。
92
+ */
93
+ export async function resolveUpgradeIntent(input) {
94
+ const current = await readUpgradeIntent();
95
+ if (!current || current.status !== 'delegated')
96
+ return undefined;
97
+ const resolved = normalizeRecord({
98
+ ...current,
99
+ status: 'resolved',
100
+ outcome: input.ok ? 'ok' : 'failed',
101
+ resolvedAt: Date.now(),
102
+ ...(input.installedVersion !== undefined ? { installedVersion: input.installedVersion } : {}),
103
+ });
104
+ const file = upgradeIntentFile();
105
+ await mkdir(dirname(file), { recursive: true });
106
+ await atomicWrite(file, `${JSON.stringify(resolved, null, 2)}\n`);
107
+ return resolved;
108
+ }
@@ -11,7 +11,7 @@
11
11
  import { gt } from 'semver';
12
12
  import { fetchJsonLimited, HttpError } from './httpx.js';
13
13
  import { isExactVersion } from './packument.js';
14
- export { npmVersion, npmPackument, npmPackumentReadme, npmLatest, extractGithubRepo, isExactVersion, } from './packument.js';
14
+ export { npmVersion, npmTarballUrl, npmPackument, npmPackumentReadme, npmLatest, extractGithubRepo, isExactVersion, } from './packument.js';
15
15
  /** GitHub 匿名限额(60 次/小时/IP)用尽时返回可读提示(含重置等待分钟数),否则 null。 */
16
16
  function githubRateLimitMessage(err) {
17
17
  if (err instanceof HttpError && err.status === 403 && err.headers?.get('x-ratelimit-remaining') === '0') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.9.94",
3
+ "version": "0.10.0",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",