@noob-stupid/dsh-plugin-console 0.5.32 → 0.5.33

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/README.md CHANGED
@@ -4,12 +4,44 @@
4
4
 
5
5
  ---
6
6
 
7
- <img width="1170" height="609" alt="image" src="https://github.com/user-attachments/assets/b802d606-14ba-4151-9956-ff642ed12b0a" />
8
-
9
7
  # DSH Plugin Gating Hub (dsh-plugin-gating-hub)
10
8
 
11
9
  *Renamed from `dsh-plugin-hub` — old URLs redirect.*
12
10
 
11
+ **Framework-upgrade safety and plugin-version gating for DSH.** A failed upgrade rolls back.
12
+ A framework changed by **any** update channel is still guarded. A version the host cannot
13
+ take is refused, not installed.
14
+
15
+ **Install (npm, one line)**
16
+
17
+ ```sh
18
+ dsh plugin --profile web add @noob-stupid/dsh-plugin-console
19
+ ```
20
+
21
+ Then restart the dsh service → refresh the page → **Settings → Plugins → Plugin Console**.
22
+
23
+ ![Upgrade safety panel, expanded in place](docs/images/upgrade-safety-panel.png)
24
+
25
+ **Three hard guarantees**
26
+
27
+ - 🛡️ **Contract pre-check → rollback point → auto-rollback.** A data-driven **contract pre-check**
28
+ runs first (message contract / dependency API / config schema / removed API / loader contract);
29
+ then config backup + a full-tree **rollback point**. A failed install or relaunch **auto-rolls
30
+ the tree back**, a 「roll back to previous」 button restores the last version, and plugins the
31
+ new framework cannot load are **auto-disabled** — version check catches fake success, 15-min
32
+ timeout + stall detection.
33
+ - 👁️ **Even when another update channel changes the framework.** An **environment fingerprint**
34
+ catches changes made by the official desktop updater or a manual `pnpm`: it auto-runs a
35
+ **read-only pre-flight**, and disables a row **only with hard evidence** (package unresolvable /
36
+ `file://` target gone / named in a boot-failure log). `@deepseek-ai/*` and **protected / core
37
+ rows are never auto-disabled**; each auto-disable writes a one-command recovery record.
38
+ - 🔒 **Plugin install & upgrade gating.** Version/declaration gate (`dsh.engines.framework` /
39
+ `engines.dsh` / `@deepseek-ai/*` ranges, built-in zero-dependency semver), a **repo-size gate**
40
+ on source channels, and explicit handling of **non-registry sources** — a bare version is
41
+ written back only once the registry really resolves it, otherwise the package is materialized
42
+ as `link:`. Install scripts are never auto-authorized, and pnpm's supply-chain gate is never
43
+ bypassed.
44
+
13
45
  [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness)
14
46
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
15
47
  [![GitHub stars](https://img.shields.io/github/stars/Noob-stupid/dsh-plugin-gating-hub?style=flat-square&logo=github)](https://github.com/Noob-stupid/dsh-plugin-gating-hub/stargazers)
@@ -22,13 +54,11 @@
22
54
  [![GitHub Release](https://img.shields.io/github/v/release/Noob-stupid/dsh-plugin-gating-hub?style=flat-square)](https://github.com/Noob-stupid/dsh-plugin-gating-hub/releases)[![dsh.so security](https://www.dsh.so/badge/dsh-plugin-hub.svg)](https://www.dsh.so/artifact/dsh-plugin-hub)
23
55
  [![dsh.so install](https://www.dsh.so/badge/install/dsh-plugin-hub.svg)](https://www.dsh.so/artifact/dsh-plugin-hub)
24
56
 
25
- > **Framework upgrade safety & plugin version gating for DeepSeek Harness (DSH)**: one-click
26
- > framework upgrade with **auto-rollback on failure** → **one-click rollback to the previous
27
- > version** after an upgrade → plugins the new framework cannot load are **auto-disabled** →
28
- > the **plugin upgrade gate** refuses a version the host can't take.
29
- > A **built-in multi-source plugin market & index** (500+ plugins / 300+ skills, zero GitHub
30
- > API calls) rides on top as the **discovery layer** — and **every source is swappable**: install
31
- > source (incl. a private intranet registry), search source (URL template + headers), index source
57
+ <img width="1170" height="609" alt="DSH Plugin Gating Hub" src="https://github.com/user-attachments/assets/b802d606-14ba-4151-9956-ff642ed12b0a" />
58
+
59
+ > **Discovery layer on top**: a **built-in multi-source plugin market & index** (500+ plugins /
60
+ > 300+ skills, zero GitHub API calls) — and **every source is swappable**: install source
61
+ > (incl. a private intranet registry), search source (URL template + headers), index source
32
62
  > (self-hosted intranet index), Git source (incl. a local `file://` bare repo), so plugins can be
33
63
  > installed on an **intranet-only or fully offline** machine.
34
64
 
@@ -385,7 +415,7 @@ Verification matrix, raw logs and the one framework-side caveat (`0.1.2-rc.1` sh
385
415
 
386
416
  ---
387
417
 
388
- ##帮助
418
+ ## Help & Ecosystem
389
419
 
390
420
  - **Panel missing**: restart dsh → refresh → Settings → Plugins → Plugin Console.
391
421
  - **Toggle does nothing**: infrastructure rows are "Protected" (by design); normal toggles
package/README.zh.md CHANGED
@@ -4,15 +4,41 @@
4
4
 
5
5
  ---
6
6
 
7
- <img width="1170" height="609" alt="image" src="https://github.com/user-attachments/assets/b802d606-14ba-4151-9956-ff642ed12b0a" />
8
-
9
7
  # DSH 插件门控中心(dsh-plugin-gating-hub)
10
8
 
11
9
  *原名 `dsh-plugin-hub`,已改名;旧链接 301 跳转有效。*
12
10
 
13
- > **DSH 框架升级安全与插件升级门控**:一键框架升级(**失败自动回滚**)→ 升级后**一键回滚到
14
- > 上一版** → 新框架带不动的插件**自动禁用** → **插件升级门控**按版本/声明拒绝装不上的插件版本。
15
- > **内置多源插件市场与自定义索引**(500+ 插件 / 300+ 技能,零 GitHub API 调用)作为**发现层**——
11
+ **DSH 的框架升级安全 + 插件升级门控。** 升级失败自动回滚。**别的通道**改了框架也照样守门。
12
+ 宿主带不动的插件版本直接拒绝,不会装上。
13
+
14
+ **安装(npm,一行)**
15
+
16
+ ```sh
17
+ dsh plugin --profile web add @noob-stupid/dsh-plugin-console
18
+ ```
19
+
20
+ 然后重启 dsh 服务 → 刷新页面 → **设置 → 插件 → 插件控制台**。
21
+
22
+ ![「升级安全」面板(就地展开)](docs/images/upgrade-safety-panel.png)
23
+
24
+ **三条最硬的能力**
25
+
26
+ - 🛡️ **升级前契约预检 → 回滚点 → 失败自动回滚。** 先跑数据化的**契约预检**(消息契约 /
27
+ 依赖 API / 配置 schema / 被删 API / 装载器契约),再做配置备份 + 全树**回滚点**;
28
+ 安装或拉起失败即**全树自动回滚**,升级成功后还有「回滚到上一版」按钮,
29
+ 新框架带不动的插件**自动禁用** —— 版本校验防假成功,15 分钟硬超时 + 卡死检测。
30
+ - 👁️ **别的更新通道改了框架,也守门。** **环境指纹**能发现官方桌面端更新器或手动 `pnpm`
31
+ 造成的框架变更:变化即自动跑一次**只读预检**,且**有确证证据才**禁用某行
32
+ (包确证解析不到 / `file://` 目标确实不存在 / 启动失败日志点名);`@deepseek-ai/*`(框架自带)
33
+ 与**受保护 / 核心行永不禁用**,每次自动禁用都留一条可读的恢复命令。
34
+ - 🔒 **插件安装与升级门控。** 版本/声明门控(`dsh.engines.framework` / `engines.dsh` /
35
+ `@deepseek-ai/*` 范围,内置零依赖 semver)+ 源码通道的**体积门禁** + **非 registry 来源**的
36
+ 显式处理(只有在 registry 真能解析到该版本时才写回裸版本号,否则物化成 `link:`);
37
+ 安装脚本**绝不自动放行**,pnpm 的供应链闸**绝不绕过**。
38
+
39
+ <img width="1170" height="609" alt="DSH 插件门控中心" src="https://github.com/user-attachments/assets/b802d606-14ba-4151-9956-ff642ed12b0a" />
40
+
41
+ > **上层还带一个发现层**:**内置多源插件市场与自定义索引**(500+ 插件 / 300+ 技能,零 GitHub API 调用)——
16
42
  > 而且**每一条源都能换成你自己的**:安装源(含内网私有 registry)、搜索源(URL 模板 + 请求头)、
17
43
  > 索引源(内网自建索引)、Git 源(含 `file://` 本地裸仓库),**纯内网 / 断网也能装插件**。
18
44
 
package/lib/client.js CHANGED
@@ -256,7 +256,6 @@ window.__ModuleLoader__.load({
256
256
  unloading: "卸载中",
257
257
  toggledOn: "已请求启用",
258
258
  toggledOff: "已请求停用",
259
- failed: "操作失败",
260
259
  view: "查看",
261
260
  install: "添加并启用",
262
261
  installing: "正在安装…",
@@ -527,7 +526,6 @@ window.__ModuleLoader__.load({
527
526
  sourcesUpdated: "软件源已更新",
528
527
  sourceOf: "源:",
529
528
  searchSourceTitle: "点击切换搜索源(GitHub / Gitee / 自定义)",
530
- recentFailures: "最近安装失败",
531
529
  installedAt: "已安装",
532
530
  checkUpdate: "检测更新",
533
531
  checkingUpdate: "检测中…",
@@ -810,7 +808,6 @@ window.__ModuleLoader__.load({
810
808
  unloading: "Unloading",
811
809
  toggledOn: "Enable requested",
812
810
  toggledOff: "Disable requested",
813
- failed: "Operation failed",
814
811
  view: "Inspect",
815
812
  install: "Add & enable",
816
813
  installing: "Installing…",
@@ -1077,7 +1074,6 @@ window.__ModuleLoader__.load({
1077
1074
  sourcesUpdated: "Sources updated",
1078
1075
  sourceOf: "Source: ",
1079
1076
  searchSourceTitle: "Click to switch search source (GitHub / Gitee / custom)",
1080
- recentFailures: "Recent install failures",
1081
1077
  installedAt: "Installed",
1082
1078
  checkUpdate: "Check update",
1083
1079
  checkingUpdate: "Checking…",
@@ -1229,6 +1225,18 @@ window.__ModuleLoader__.load({
1229
1225
  function phaseLabel(phase, t) {
1230
1226
  return phase === null ? t("unobserved") : t(PHASE_KEYS[phase]);
1231
1227
  }
1228
+ // 0.5.33 改错 ①-2:「已适配,立即解锁」按钮的门控判据 —— 必须与同一卡片正文用的是**同一个**判据。
1229
+ // 现症:按钮只判 pendingCompat === true,不判 entry.adoptable → 正文按 adoptable 说"不适配",
1230
+ // 按钮按 pendingCompat 说"已适配",自相矛盾;点下去 POST /adapt-unlock 必得 409(适配校验 fail),
1231
+ // 补丁与行状态零变化 ⇒ 用户观感"点了没反应"。
1232
+ // 现在只有**真的检测到已适配**(adoptable 是服务端 detectAdoptablePending 给出的对象)才出按钮;
1233
+ // 其余情况不给这个承诺,原因照旧由正文那行(pendingCompatHint + checkNote)如实展示。
1234
+ function canAdaptUnlock(entry) {
1235
+ return entry !== null && entry !== undefined
1236
+ && entry.pendingCompat === true
1237
+ && entry.adoptable !== null && entry.adoptable !== undefined
1238
+ && typeof entry.adoptable === "object";
1239
+ }
1232
1240
  async function call(path, body) {
1233
1241
  const response = await fetch(path, body === undefined
1234
1242
  ? {}
@@ -1273,7 +1281,7 @@ window.__ModuleLoader__.load({
1273
1281
  if (reload) setMessage(t("restarting"));
1274
1282
  call("/plugin-console/restart", {}).then(
1275
1283
  () => { if (reload) window.setTimeout(() => window.location.reload(), 12000); },
1276
- (error) => { setMessage(error !== null && typeof error.message === "string" && error.message !== "" ? error.message : t("failed")); },
1284
+ (error) => { setMessage(error !== null && typeof error.message === "string" && error.message !== "" ? error.message : t("safetySwitchFailed")); },
1277
1285
  );
1278
1286
  }
1279
1287
  /** 超时类错误附上可操作提示(网络黑洞期的最常见表现)。 */
@@ -1718,7 +1726,7 @@ window.__ModuleLoader__.load({
1718
1726
  setFwClearing(true);
1719
1727
  call("/plugin-console/framework-status-clear", {}).then(
1720
1728
  () => { setFrameworkStatus({ status: "idle", message: null }); setFwStatusDismissed(true); setMessage(t("fwRecordCleared")); },
1721
- (error) => { setMessage(t("failed") + ":" + friendlyGithubError(error).message); },
1729
+ (error) => { setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message); },
1722
1730
  ).finally(() => setFwClearing(false));
1723
1731
  };
1724
1732
  const fwStatusRefresh = () => {
@@ -1736,7 +1744,7 @@ window.__ModuleLoader__.load({
1736
1744
  setFwCheckBusy(true);
1737
1745
  call("/plugin-console/framework-check", refresh === true ? { refresh: true } : {}).then(
1738
1746
  (data) => { setFwCheckBusy(false); if (data && data.ok === true) setFwCheck(data); },
1739
- (error) => { setFwCheckBusy(false); setMessage(t("failed") + ":" + friendlyGithubError(error).message); },
1747
+ (error) => { setFwCheckBusy(false); setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message); },
1740
1748
  );
1741
1749
  };
1742
1750
  // 升级前契约预检:先只读预检(抓目标版本会话格式契约 + 扫本地生产方),
@@ -1753,7 +1761,7 @@ window.__ModuleLoader__.load({
1753
1761
  setFwPreflightBusy(false);
1754
1762
  setFwPreflight((prev) => (apply === true ? { report: (prev && prev.report) ?? null, patch: data } : { report: data, patch: null }));
1755
1763
  },
1756
- (error) => { setFwPreflightBusy(false); setMessage(t("failed") + ":" + friendlyGithubError(error).message); },
1764
+ (error) => { setFwPreflightBusy(false); setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message); },
1757
1765
  );
1758
1766
  };
1759
1767
  const fwShortPath = (p) => String(p ?? "").replace(/\\/gu, "/").split("/").slice(-3).join("/");
@@ -1766,7 +1774,7 @@ window.__ModuleLoader__.load({
1766
1774
  setFrameworkStatus({ status: "rollback", message: t("frameworkRollbackDone").replace("{from}", String(data?.from ?? "?")) });
1767
1775
  pollFrameworkStatus();
1768
1776
  },
1769
- (error) => { setFrameworkUpgrading(false); setMessage(t("failed") + ":" + friendlyGithubError(error).message); },
1777
+ (error) => { setFrameworkUpgrading(false); setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message); },
1770
1778
  );
1771
1779
  };
1772
1780
  // 回滚按钮可用性:已经处在「回滚目标版本」时不再提供回滚(点了等于恢复现状)。
@@ -2013,10 +2021,10 @@ window.__ModuleLoader__.load({
2013
2021
  setAiJob({ jobId: data.jobId, source, status: "running", stage: "planning" });
2014
2022
  aiPoll(data.jobId);
2015
2023
  }
2016
- else { setAiRunning(false); setMessage(t("failed") + ":" + (data?.error ?? "未知")); }
2024
+ else { setAiRunning(false); setMessage(t("safetySwitchFailed") + ":" + (data?.error ?? "未知")); }
2017
2025
  } catch (error) {
2018
2026
  setAiRunning(false);
2019
- setMessage(t("failed") + ":" + friendlyGithubError(error).message);
2027
+ setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message);
2020
2028
  }
2021
2029
  }
2022
2030
  async function aiRun() {
@@ -2035,7 +2043,7 @@ window.__ModuleLoader__.load({
2035
2043
  aiPoll(aiJob.jobId);
2036
2044
  } catch (error) {
2037
2045
  setAiRunning(false);
2038
- setMessage(t("failed") + ":" + friendlyGithubError(error).message);
2046
+ setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message);
2039
2047
  }
2040
2048
  }
2041
2049
  async function aiCancel() {
@@ -2056,7 +2064,7 @@ window.__ModuleLoader__.load({
2056
2064
  const data = await call("/plugin-console/component/" + action, { id });
2057
2065
  setMessage(t("compActionDone").replace("{action}", t(action === "start" ? "compStart" : "compStop")).replace("{name}", data?.name ?? id));
2058
2066
  } catch (error) {
2059
- setMessage(t("failed") + ":" + error.message);
2067
+ setMessage(t("safetySwitchFailed") + ":" + error.message);
2060
2068
  }
2061
2069
  setCompBusy(null);
2062
2070
  refreshComps();
@@ -2069,7 +2077,7 @@ window.__ModuleLoader__.load({
2069
2077
  setMessage(data.running ? (data.healthy ? t("compHealthy") : t("compUnhealthy")) + ":" + (data.name ?? id) : t("compStopped") + ":" + (data.name ?? id));
2070
2078
  }
2071
2079
  } catch (error) {
2072
- setMessage(t("failed") + ":" + error.message);
2080
+ setMessage(t("safetySwitchFailed") + ":" + error.message);
2073
2081
  }
2074
2082
  setCompBusy(null);
2075
2083
  refreshComps();
@@ -2088,7 +2096,7 @@ window.__ModuleLoader__.load({
2088
2096
  await call("/plugin-console/component/autostart", { id, enabled: on });
2089
2097
  setMessage((on ? "✔ 已开启自启动:" : "已关闭自启动:") + id);
2090
2098
  } catch (error) {
2091
- setMessage(t("failed") + ":" + error.message);
2099
+ setMessage(t("safetySwitchFailed") + ":" + error.message);
2092
2100
  }
2093
2101
  setCompBusy(null);
2094
2102
  refreshComps();
@@ -2500,7 +2508,7 @@ window.__ModuleLoader__.load({
2500
2508
  setRiskyConfirm({ entryId: entry.entryId, moduleName: details.moduleName ?? entry.moduleName ?? null, frameworkVersion: details.frameworkVersion ?? null, checkNote: details.checkNote ?? null });
2501
2509
  return;
2502
2510
  }
2503
- setMessage(t("failed") + ":" + friendlyGithubError(error).message);
2511
+ setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message);
2504
2512
  },
2505
2513
  ).finally(() => setBusy(null));
2506
2514
  };
@@ -2513,7 +2521,7 @@ window.__ModuleLoader__.load({
2513
2521
  : prev));
2514
2522
  setMessage(t("compatGateSaved"));
2515
2523
  },
2516
- (error) => setMessage(t("failed") + ":" + friendlyGithubError(error).message),
2524
+ (error) => setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message),
2517
2525
  );
2518
2526
  };
2519
2527
  const levenshtein = (a, b) => {
@@ -2704,7 +2712,7 @@ window.__ModuleLoader__.load({
2704
2712
  },
2705
2713
  (error) => {
2706
2714
  setLoadingMore(false);
2707
- setMessage(t("failed") + ":" + friendlyGithubError(error).message);
2715
+ setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message);
2708
2716
  },
2709
2717
  );
2710
2718
  return;
@@ -2728,7 +2736,7 @@ window.__ModuleLoader__.load({
2728
2736
  },
2729
2737
  (error) => {
2730
2738
  setLoadingMore(false);
2731
- setMessage(t("failed") + ":" + friendlyGithubError(error).message);
2739
+ setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message);
2732
2740
  },
2733
2741
  );
2734
2742
  return;
@@ -2746,7 +2754,7 @@ window.__ModuleLoader__.load({
2746
2754
  },
2747
2755
  (error) => {
2748
2756
  setLoadingMore(false);
2749
- setMessage(t("failed") + ":" + friendlyGithubError(error).message);
2757
+ setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message);
2750
2758
  },
2751
2759
  );
2752
2760
  return;
@@ -2784,7 +2792,7 @@ window.__ModuleLoader__.load({
2784
2792
  },
2785
2793
  (error) => {
2786
2794
  setLoadingMore(false);
2787
- setMessage(t("failed") + ":" + friendlyGithubError(error).message);
2795
+ setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message);
2788
2796
  },
2789
2797
  ),
2790
2798
  );
@@ -2953,7 +2961,7 @@ window.__ModuleLoader__.load({
2953
2961
  window.setTimeout(() => window.location.reload(), 2500);
2954
2962
  }
2955
2963
  } else {
2956
- setMessage(t("failed") + ":" + (data.error ?? "未知错误") + diagMessage(data));
2964
+ setMessage(t("safetySwitchFailed") + ":" + (data.error ?? "未知错误") + diagMessage(data));
2957
2965
  }
2958
2966
  setInstalling(null);
2959
2967
  setTimeout(refresh, 1500);
@@ -2966,8 +2974,8 @@ window.__ModuleLoader__.load({
2966
2974
  /** 本地 AI 兜底授权:调用模型 API 产生费用,必须用户明确同意。 */
2967
2975
  const aiConsent = (jobId, approved) => {
2968
2976
  call("/plugin-console/ai-consent", { jobId, approved }).then(
2969
- () => { if (!approved) setMessage(t("failed") + ":已取消本地 AI 兜底(不会调用模型 API)"); },
2970
- (error) => setMessage(t("failed") + ":" + friendlyGithubError(error).message),
2977
+ () => { if (!approved) setMessage(t("safetySwitchFailed") + ":已取消本地 AI 兜底(不会调用模型 API)"); },
2978
+ (error) => setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message),
2971
2979
  );
2972
2980
  };
2973
2981
  /** 安装进度文案:服务端 progress{channel,phase,index,total,name,done} → 一行人话。
@@ -3127,7 +3135,7 @@ window.__ModuleLoader__.load({
3127
3135
  // clientId 打码回显(如 abc12345…):用户未修改时原样传回服务端,服务端识别打码标记保留原配置
3128
3136
  const idValue = giteeClientId.trim();
3129
3137
  if (!idValue || !giteeClientSecret.trim()) {
3130
- setMessage(t("failed") + ":client_id / client_secret 不能为空");
3138
+ setMessage(t("safetySwitchFailed") + ":client_id / client_secret 不能为空");
3131
3139
  return;
3132
3140
  }
3133
3141
  if (idValue.endsWith("…")) {
@@ -3139,7 +3147,7 @@ window.__ModuleLoader__.load({
3139
3147
  const startGiteeOauth = () => {
3140
3148
  call("/plugin-console/gitee-oauth-url").then(
3141
3149
  (data) => { if (data && data.url) window.open(data.url, "_blank", "noopener"); },
3142
- (error) => setMessage(t("failed") + ":" + friendlyGithubError(error).message),
3150
+ (error) => setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message),
3143
3151
  );
3144
3152
  };
3145
3153
  const sourcesAction = (payload, done) => {
@@ -3151,12 +3159,12 @@ window.__ModuleLoader__.load({
3151
3159
  setMessage(t("sourcesUpdated"));
3152
3160
  if (done) done();
3153
3161
  },
3154
- (error) => setMessage(t("failed") + ":" + friendlyGithubError(error).message),
3162
+ (error) => setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message),
3155
3163
  ).finally(() => setSourcesBusy(false));
3156
3164
  };
3157
3165
  const addSource = () => {
3158
3166
  if (!isAllowedSourceUrl(sourceUrl.trim())) {
3159
- setMessage(t("failed") + ":" + t("invalidSourceUrl"));
3167
+ setMessage(t("safetySwitchFailed") + ":" + t("invalidSourceUrl"));
3160
3168
  return;
3161
3169
  }
3162
3170
  sourcesAction({ action: "add", name: sourceName.trim(), url: sourceUrl.trim() }, () => { setSourceName(""); setSourceUrl(""); });
@@ -3166,7 +3174,7 @@ window.__ModuleLoader__.load({
3166
3174
  setRegScan({ status: "scanning" });
3167
3175
  call("/plugin-console/registry-scan", {}).then(
3168
3176
  (data) => setRegScan({ status: "ready", results: Array.isArray(data.results) ? data.results : [], scannedAt: typeof data.scannedAt === "number" ? data.scannedAt : null }),
3169
- (error) => { setRegScan(null); setMessage(t("failed") + ":" + friendlyGithubError(error).message); },
3177
+ (error) => { setRegScan(null); setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message); },
3170
3178
  );
3171
3179
  };
3172
3180
  // 扫描结果派生视图:每条源的命中结果 + 可达计数 + 最新版本来自哪个源
@@ -3190,7 +3198,7 @@ window.__ModuleLoader__.load({
3190
3198
  };
3191
3199
  const addIndexSource = () => {
3192
3200
  if (!isAllowedSourceUrl(indexSourceUrl.trim())) {
3193
- setMessage(t("failed") + ":" + t("invalidSourceUrl"));
3201
+ setMessage(t("safetySwitchFailed") + ":" + t("invalidSourceUrl"));
3194
3202
  return;
3195
3203
  }
3196
3204
  sourcesAction({ action: "add-index", name: indexSourceName.trim(), url: indexSourceUrl.trim() }, () => { setIndexSourceName(""); setIndexSourceUrl(""); });
@@ -3213,11 +3221,11 @@ window.__ModuleLoader__.load({
3213
3221
  const addGitSource = () => {
3214
3222
  const tpl = gitSourceUrl.trim();
3215
3223
  if (!tpl.includes("{owner}") || !tpl.includes("{repo}")) {
3216
- setMessage(t("failed") + ":" + t("gitUrlPlaceholder"));
3224
+ setMessage(t("safetySwitchFailed") + ":" + t("gitUrlPlaceholder"));
3217
3225
  return;
3218
3226
  }
3219
3227
  if (!isAllowedSourceUrl(tpl)) {
3220
- setMessage(t("failed") + ":" + t("invalidSourceUrl"));
3228
+ setMessage(t("safetySwitchFailed") + ":" + t("invalidSourceUrl"));
3221
3229
  return;
3222
3230
  }
3223
3231
  sourcesAction({ action: "add-git", name: gitSourceName.trim(), urlTemplate: tpl }, () => { setGitSourceName(""); setGitSourceUrl(""); });
@@ -3241,7 +3249,7 @@ window.__ModuleLoader__.load({
3241
3249
  };
3242
3250
  const addSearchSource = () => {
3243
3251
  if (!isAllowedSourceUrl(searchSourceUrl.trim()) || !searchSourceUrl.includes("{q}")) {
3244
- setMessage(t("failed") + ":" + t("invalidSearchUrl"));
3252
+ setMessage(t("safetySwitchFailed") + ":" + t("invalidSearchUrl"));
3245
3253
  return;
3246
3254
  }
3247
3255
  // 解析请求头:每行 "名称: 值"
@@ -3281,7 +3289,7 @@ window.__ModuleLoader__.load({
3281
3289
  }
3282
3290
  },
3283
3291
  (error) => {
3284
- setMessage(t("failed") + ":" + friendlyGithubError(error).message);
3292
+ setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message);
3285
3293
  setInstalling(null);
3286
3294
  },
3287
3295
  );
@@ -3353,7 +3361,7 @@ window.__ModuleLoader__.load({
3353
3361
  },
3354
3362
  (error) => {
3355
3363
  setTogglingSkill(null);
3356
- setMessage(t("failed") + ":" + friendlyGithubError(error).message);
3364
+ setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message);
3357
3365
  },
3358
3366
  );
3359
3367
  };
@@ -3404,7 +3412,7 @@ window.__ModuleLoader__.load({
3404
3412
  (error) => {
3405
3413
  setFrameworkUpgrading(false);
3406
3414
  setConfirmFrameworkUpgrade(false);
3407
- setMessage(t("failed") + ":" + friendlyGithubError(error).message);
3415
+ setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message);
3408
3416
  },
3409
3417
  );
3410
3418
  };
@@ -3416,7 +3424,7 @@ window.__ModuleLoader__.load({
3416
3424
  setConfirmSkillDelete(null);
3417
3425
  setInstalledSkills((prev) => prev.filter((s) => s.name !== name));
3418
3426
  },
3419
- (error) => setMessage(t("failed") + ":" + friendlyGithubError(error).message),
3427
+ (error) => setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message),
3420
3428
  );
3421
3429
  };
3422
3430
  const installedQueryNorm = installedQuery.trim().toLowerCase();
@@ -3470,7 +3478,7 @@ window.__ModuleLoader__.load({
3470
3478
  el("p", { className: styles.status }, t("loadingDetails")));
3471
3479
  } else if (details.status === "error") {
3472
3480
  detailPanel = el("div", { className: styles.detail },
3473
- el("p", { className: styles.status, "data-error": "true" }, t("failed") + ":" + details.error.message));
3481
+ el("p", { className: styles.status, "data-error": "true" }, t("safetySwitchFailed") + ":" + details.error.message));
3474
3482
  } else {
3475
3483
  const meta = details.data.meta;
3476
3484
  const readme = details.data.readme;
@@ -3499,7 +3507,7 @@ window.__ModuleLoader__.load({
3499
3507
  title: t("aiEmpowerTitle"),
3500
3508
  onClick: () => openAiFor(String(entry.moduleName ?? entry.repo ?? "")),
3501
3509
  }, t("aiEmpowerLabel")),
3502
- entry.pendingCompat === true
3510
+ canAdaptUnlock(entry)
3503
3511
  ? el("button", {
3504
3512
  type: "button",
3505
3513
  className: styles.toggle,
@@ -3514,7 +3522,7 @@ window.__ModuleLoader__.load({
3514
3522
  setMessage(t("adaptUnlockDone"));
3515
3523
  window.setTimeout(() => window.location.reload(), 1200);
3516
3524
  },
3517
- (error) => { setAdaptUnlocking(null); setMessage(t("failed") + ":" + friendlyGithubError(error).message); },
3525
+ (error) => { setAdaptUnlocking(null); setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message); },
3518
3526
  );
3519
3527
  },
3520
3528
  }, adaptUnlocking === entry.entryId ? t("loadingPhase") : t("adaptUnlockBtn"))
@@ -3586,6 +3594,13 @@ window.__ModuleLoader__.load({
3586
3594
  phaseLabel(entry.fiberPhase, t)),
3587
3595
  el("code", null, entry.entryId),
3588
3596
  ),
3597
+ // 0.5.33 加法 ①-4:框架 peer 预检**否决**的本地复算结果(服务端 entries[].veto)。
3598
+ // 框架只在内存里禁用、原因只打 stderr —— 这里把它补出来。复用**既有行**内的
3599
+ // status 短句位(不新增常驻面板行),出路放悬浮提示(复用既有 title 位置)。
3600
+ entry.veto && entry.veto.reason
3601
+ ? el("p", { className: styles.status, "data-pending": "true", title: entry.veto.hint ?? undefined },
3602
+ entry.veto.reason)
3603
+ : null,
3589
3604
  el("div", { className: styles.rowTop, style: { justifyContent: "space-between" } },
3590
3605
  el("div", { className: styles.rowTop },
3591
3606
  el("button", {
@@ -3623,7 +3638,7 @@ window.__ModuleLoader__.load({
3623
3638
  setTimeout(refresh, 1500);
3624
3639
  }
3625
3640
  },
3626
- (error) => setMessage(t("failed") + ":" + friendlyGithubError(error).message),
3641
+ (error) => setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message),
3627
3642
  ).finally(() => setDeleteBusy(null));
3628
3643
  } else {
3629
3644
  setConfirmDeleteRowId(entry.entryId);
@@ -3679,7 +3694,7 @@ window.__ModuleLoader__.load({
3679
3694
  setFamilyCheck((prev) => ({ ...prev, [root]: { latest: d?.latest ?? null, installed } }));
3680
3695
  setMessage(t("familyUpdate") + ":" + root + " " + (d?.latest ?? "?") + (d?.migrate && d.migrate.compatible ? " → " + d.migrate.to + " v" + d.migrate.latest : "") + (d?.error ? "(" + d.error + ")" : ""));
3681
3696
  },
3682
- (e) => setMessage(t("failed") + ":" + friendlyGithubError(e).message),
3697
+ (e) => setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(e).message),
3683
3698
  );
3684
3699
  },
3685
3700
  }, t("familyCheckUpdate")),
@@ -3706,7 +3721,7 @@ window.__ModuleLoader__.load({
3706
3721
  setMessage(t("familyUnlockAllDone") + ":" + unlockedCount + " 行" + (Array.isArray(d?.kept) && d.kept.length > 0 ? ";保留 " + d.kept.length + " 行(未通过校验)" : ""));
3707
3722
  window.setTimeout(() => window.location.reload(), 1500);
3708
3723
  },
3709
- (e) => setMessage(t("failed") + ":" + friendlyGithubError(e).message),
3724
+ (e) => setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(e).message),
3710
3725
  );
3711
3726
  },
3712
3727
  }, t("familyUnlockAll")))
@@ -3765,7 +3780,7 @@ window.__ModuleLoader__.load({
3765
3780
  : t("pendingRestartNote") + ":" + (data?.packageName ?? "") + (data?.uninstallError ? "(包卸载警告:" + data.uninstallError + ")" : ""));
3766
3781
  refresh();
3767
3782
  },
3768
- (error) => setMessage(t("failed") + ":" + friendlyGithubError(error).message),
3783
+ (error) => setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message),
3769
3784
  ).finally(() => setDeleteBusy(null));
3770
3785
  } else {
3771
3786
  setConfirmDeleteRowId(job.jobId);
@@ -4388,7 +4403,7 @@ window.__ModuleLoader__.load({
4388
4403
  ? el("div", { className: styles.rowTop },
4389
4404
  el("p", { className: styles.message, style: { flex: 1, minWidth: 0, wordBreak: "break-all" } }, t("repoLandDone") + repoLandResult.path),
4390
4405
  el("button", { type: "button", className: styles.toggle, onClick: repoLandOpenFolder }, t("repoLandOpenFolder")))
4391
- : el("p", { className: styles.message, "data-error": "true" }, t("failed") + ":" + (repoLandResult.error ?? "?")))
4406
+ : el("p", { className: styles.message, "data-error": "true" }, t("safetySwitchFailed") + ":" + (repoLandResult.error ?? "?")))
4392
4407
  : null,
4393
4408
  el("strong", { className: styles.name }, t("repoLandList") + "(" + repoLanded.length + ")"),
4394
4409
  repoLanded.length === 0
@@ -4415,7 +4430,7 @@ window.__ModuleLoader__.load({
4415
4430
  onClick: () => {
4416
4431
  call("/plugin-console/framework-relaunch", {}).then(
4417
4432
  (data) => { if (data && data.ok === true) setMessage(t("relaunchDone")); },
4418
- (error) => setMessage(t("failed") + ":" + friendlyGithubError(error).message),
4433
+ (error) => setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message),
4419
4434
  );
4420
4435
  },
4421
4436
  }, t("relaunchBtn"))
@@ -4432,7 +4447,7 @@ window.__ModuleLoader__.load({
4432
4447
  const keptNote = keptCount > 0 ? ";保留在用插件 " + keptCount + " 个" : "";
4433
4448
  if (data && data.ok === true) setMessage(t("cleanResiduals") + ":" + (data.count === 0 ? "无残余" : "已清理 " + data.count + " 项") + keptNote);
4434
4449
  },
4435
- (error) => setMessage(t("failed") + ":" + friendlyGithubError(error).message),
4450
+ (error) => setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message),
4436
4451
  );
4437
4452
  },
4438
4453
  }, "🧹 " + t("cleanResiduals")),
@@ -4472,10 +4487,10 @@ window.__ModuleLoader__.load({
4472
4487
  setMessage(t("cfDone").replace("{renamed}", String(renamed)).replace("{linked}", String(relinked)).replace("{removed}", String(removed)).replace("{mb}", String(freed))
4473
4488
  + (failed > 0 ? ";" + t("cfFailed").replace("{n}", String(failed)) : ""));
4474
4489
  },
4475
- (error) => setMessage(t("failed") + ":" + friendlyGithubError(error).message),
4490
+ (error) => setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message),
4476
4491
  );
4477
4492
  },
4478
- (error) => setMessage(t("failed") + ":" + friendlyGithubError(error).message),
4493
+ (error) => setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message),
4479
4494
  );
4480
4495
  },
4481
4496
  }, "🧰 " + t("cleanFrameworkResiduals")),
@@ -4559,10 +4574,10 @@ const lockWarn = data.lockUpdated === false && typeof data.lockNote === "string"
4559
4574
  setMessage((data.note ?? "已更新") + (lockWarn === "" ? "" : "。" + lockWarn) + ",正在重启生效…");
4560
4575
  restartServiceViaServer(setMessage, { reload: false });
4561
4576
  } else {
4562
- setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
4577
+ setMessage(t("safetySwitchFailed") + ":" + (data?.reason ?? "未知"));
4563
4578
  }
4564
4579
  },
4565
- (error) => setMessage(t("failed") + ":" + friendlyGithubError(error).message),
4580
+ (error) => setMessage(t("safetySwitchFailed") + ":" + friendlyGithubError(error).message),
4566
4581
  );
4567
4582
  }
4568
4583
  }, "⬇ " + t("hubUpdateAvailable") + " v" + selfUpdate.latest) : null,
@@ -5146,7 +5161,7 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
5146
5161
  ? el("p", { className: styles.status }, t("loading"))
5147
5162
  : sourcesData.error !== undefined
5148
5163
  ? el("div", null,
5149
- el("p", { className: styles.status }, t("failed") + ":" + sourcesData.error),
5164
+ el("p", { className: styles.status }, t("safetySwitchFailed") + ":" + sourcesData.error),
5150
5165
  el("button", { type: "button", className: styles.toggle, onClick: () => { setSourcesData(null); openSources(); } }, t("retry")))
5151
5166
  : el("div", { className: styles.accWrap },
5152
5167
  accSection("registries", t("sourcesBtn"), [
@@ -5704,6 +5719,9 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
5704
5719
  // 仅测试用导出(tests/test-plugin-actions.mjs 的离线渲染断言:用假 React 真渲染这个组件,
5705
5720
  // 断言"有/无 suggestedAction、执行中、成功、失败、中英切换"五种情形的 DOM 结构)。
5706
5721
  exports.SuggestedActionBox = SuggestedActionBox;
5722
+ // 仅测试用导出(tests/test-adapt-unlock-gate.mjs ①-2:断言「已适配,立即解锁」按钮的门控判据,
5723
+ // 以及它必须与卡片正文用同一个判据)。
5724
+ exports.canAdaptUnlock = canAdaptUnlock;
5707
5725
  return module.exports;
5708
5726
  }
5709
5727
  });
@@ -1,7 +1,7 @@
1
1
  // L1 · domain —— compat.js(框架兼容判定与适配门:设置 API 扫描 / 兼容门读写 / 待适配记录 / 隔离记录合并的前置部分 / 启动失败分析;分层 Step 6 从 lib/index.js 搬出,只搬移未改逻辑。注:detectCompat 等吃运行上下文的留到 Step 8)
2
2
  // 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
3
3
 
4
- import { readFileSync, writeFileSync, readdirSync, mkdirSync, realpathSync } from 'node:fs'
4
+ import { readFileSync, writeFileSync, mkdirSync, realpathSync } from 'node:fs'
5
5
  import { readFile } from 'node:fs/promises'
6
6
  import { spawn } from 'node:child_process'
7
7
  import { dirname, join, sep, resolve } from 'node:path'
@@ -13,6 +13,9 @@ import { listEntries } from './runtime.js'
13
13
  import { fetchJsonUrl } from '../infra/http.js'
14
14
  import { dshHome, findPatchPath, pluginRoot, profileDirOf, resolvePackageJson } from '../infra/paths.js'
15
15
  import { semverRangeMatch, semverRangeMatchLoose } from '../infra/semver.js'
16
+ // 0.5.33 改错:已删除 API 的源码扫描搬进独立 domain 模块(判据收紧为"真的从 dsh-settings 绑定进来")。
17
+ // 这三个名字继续由本模块导出,对外契约不变。
18
+ import { REMOVED_SETTINGS_SYMBOLS, referencesRemovedSymbol, scanSettingsApiUsage } from './settings-api-scan.js'
16
19
 
17
20
  /** Cordis Fiber 状态映射(与 dsh-host-plugin-inventory 一致)。 */
18
21
  const FIBER_STATE = { PENDING: 0, LOADING: 1, ACTIVE: 2, FAILED: 3, DISPOSED: 4, UNLOADING: 5 }
@@ -26,63 +29,6 @@ const FIBER_PHASE = {
26
29
  [FIBER_STATE.UNLOADING]: 'unloading',
27
30
  }
28
31
 
29
- /**
30
- * 扫描已安装包源码,检测对已删除的 dsh-settings API 的引用
31
- * (2026-09-04 教训:0.1.2-rc.1 起 settingsNamespace / installSettingsSection 已删除,
32
- * 静态声明检查判 pass 是假通过;真实兼容性只有模块 import 时见分晓,而 loader 单行失败
33
- * = 整个服务启动崩溃)。返回命中的符号列表,空数组 = 干净。
34
- */
35
- const REMOVED_SETTINGS_SYMBOLS = ['settingsNamespace', 'installSettingsSection']
36
-
37
- /** 源码是否**引用**了已删除符号——而不是「恰好包含同名前缀的其它标识符」或「自己定义的同名局部函数」。
38
- * v0.3.35 修复(真机演练抓到的误伤):原先用 `text.includes('settingsNamespace')` 子串匹配,
39
- * 而框架自带的 @deepseek-ai/dsh-api-settings-controller 里有个 `settingsNamespaceRequestSchema`,
40
- * 于是框架自己的 settings 控制器被判「引用已删除 API」→ 升级预扫会把它自动禁用(砍掉框架功能)。
41
- * 现在要求标识符边界,且排除「本地 const/let/var/function/class 定义且同行没有 dsh-settings 引用」。 */
42
- function referencesRemovedSymbol(text, sym) {
43
- const re = new RegExp(`(?<![A-Za-z0-9_$])${sym}(?![A-Za-z0-9_$])`, 'u')
44
- for (const line of text.split(/\r?\n/u)) {
45
- const m = re.exec(line)
46
- if (m === null) continue
47
- const before = line.slice(0, m.index)
48
- const localDefinition = /(?:^|[^\w$])(?:const|let|var|function|class|async\s+function)\s*$/u.test(before)
49
- if (localDefinition && !/dsh-settings/u.test(line)) continue
50
- return true
51
- }
52
- return false
53
- }
54
-
55
- function scanSettingsApiUsage(pkgDir) {
56
- const found = []
57
- const seen = new Set()
58
- const walk = (dir, depth) => {
59
- if (depth > 3) return
60
- let entries = []
61
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
62
- for (const entry of entries) {
63
- const full = join(dir, entry.name)
64
- if (entry.isDirectory()) {
65
- if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
66
- walk(full, depth + 1)
67
- continue
68
- }
69
- if (!entry.isFile() || !/\.(?:js|mjs|cjs)$/u.test(entry.name)) continue
70
- if (seen.has(full)) continue
71
- seen.add(full)
72
- if (seen.size > 120) return
73
- try {
74
- const text = readFileSync(full, 'utf8').slice(0, 400000)
75
- for (const sym of REMOVED_SETTINGS_SYMBOLS) {
76
- if (found.includes(sym)) continue
77
- if (referencesRemovedSymbol(text, sym)) found.push(sym)
78
- }
79
- } catch {}
80
- }
81
- }
82
- walk(pkgDir, 0)
83
- return found
84
- }
85
-
86
32
  /** 包是否由框架自带(解析到 profile 目录之外,如 npx/pnpm 缓存里的 @deepseek-ai/*)。
87
33
  * v0.3.35:这类行随框架一起发布,禁用**不是**正确处置(正确处置是回滚框架),
88
34
  * 而且一旦判定有误就会把框架功能砍掉——所以升级预扫永不自动禁用它们。 */
@@ -0,0 +1,103 @@
1
+ // L1 · domain —— peer-veto.js(框架 peer 预检「否决」的**本地复算**;0.5.33 加法,诊断缺口 D-⑧)
2
+ // 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
3
+ //
4
+ // ── 要解决什么 ────────────────────────────────────────────────────────────────────
5
+ // 真机症状:「补丁里明明写着启用(或压根没写 disabled)、运行时却没挂载(fiberPhase === null),
6
+ // 面板上一个字都不说为什么」。真实机制(框架侧,不是我们的 bug):
7
+ // `@deepseek-ai/dsh-app-boot/lib/index.js`
8
+ // · `evaluatePluginCompatibility(manifest, exemptions, runtimeVersion)`(:286-313)
9
+ // —— 只看 **peerDependencies**,只看 `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*`,
10
+ // `workspace:^|~|*` 视为「等于当前运行时」,其余按 semver(includePrerelease)判;
11
+ // · `preflight()`(:2063-2109)—— 判不满足就在**内存里** `row.disabled = true`(**不写用户补丁**),
12
+ // 原因只 `process.stderr.write` 一行。
13
+ // 本机典型现场:profile 顶层压着 `@deepseek-ai/dsh-schedule@0.0.1-rc.3` 残影,它的 peer 写
14
+ // `@deepseek-ai/dsh-agent@^0.0.1-rc.3` 等 7 项 → 对运行中的 0.2.0-rc.2 **必然不满足** → 启动即被否决,
15
+ // 而补丁尾部还是 `- id: schedule / disabled: false`,面板只显示「未挂载」。
16
+ //
17
+ // ── 本模块做什么(**加法**:不改任何既有判据与开关,只补一句原因 + 一条出路)────────────
18
+ // 在同一判据下本地复算一遍:读该行**实际解析到的包** manifest 的 peerDependencies × 当前框架版本。
19
+ // 判定不满足 → 给短句原因 + 出路;包解析不到 → **另给**「包不存在」原因(两者绝不混为一谈)。
20
+ //
21
+ // ── 保守性(宁可不说,不可错说)────────────────────────────────────────────────
22
+ // 框架用的是 node-semver 的 `{ includePrerelease: true }`;仓库既有的 semver 工具里,
23
+ // `semverRangeMatchLoose` 与它最接近但**偏宽松**(把它当"满足"的少数情形,框架可能判不满足)。
24
+ // 这里**故意用宽松的那一支**:只有连宽松读法都不满足时才说话 —— 与 ①-1「不误报」同一纪律。
25
+ // 代价是可能漏报少数否决(表现回退成"什么都不显示",不会错说原因)。
26
+
27
+ import { readFileSync } from 'node:fs'
28
+ import { resolvePackageJson } from '../infra/paths.js'
29
+ import { semverRangeMatchLoose } from '../infra/semver.js'
30
+
31
+ /** 框架预检只看这个前缀的 peer(与 dsh-app-boot 的 `name !== '@deepseek-ai/dsh' && !name.startsWith('@deepseek-ai/dsh-')` 对齐)。 */
32
+ const PEER_PREFIX = '@deepseek-ai/dsh'
33
+
34
+ /** `workspace:^` / `workspace:~` / `workspace:*` 在框架里等价于「当前运行时版本」→ 永远满足。 */
35
+ const WORKSPACE_RANGES = new Set(['workspace:^', 'workspace:~', 'workspace:*'])
36
+
37
+ /**
38
+ * 纯判据:返回**不满足**的 peer 列表(空数组 = 没有相关 peer 声明,或全部满足)。
39
+ * 与 `dsh-app-boot` 的 `evaluatePluginCompatibility` 逐条对齐(见文件头)。
40
+ */
41
+ function unsatisfiedPeers(manifest, runtimeVersion) {
42
+ if (manifest === null || typeof manifest !== 'object') return []
43
+ if (!Object.hasOwn(manifest, 'peerDependencies')) return []
44
+ const deps = manifest.peerDependencies
45
+ if (deps === null || typeof deps !== 'object' || Array.isArray(deps)) return []
46
+ if (typeof runtimeVersion !== 'string' || runtimeVersion === '') return []
47
+ const out = []
48
+ for (const [name, range] of Object.entries(deps)) {
49
+ if (name !== PEER_PREFIX && !name.startsWith(`${PEER_PREFIX}-`)) continue
50
+ if (typeof range !== 'string') { out.push({ name, range: String(range) }); continue }
51
+ const requirement = WORKSPACE_RANGES.has(range) ? runtimeVersion : range
52
+ if (requirement.trim() === '' || !semverRangeMatchLoose(runtimeVersion, requirement)) out.push({ name, range })
53
+ }
54
+ return out
55
+ }
56
+
57
+ /** 短句化的「需要什么」:一项给全名,多项给首项 + 总数(面板一行放得下)。 */
58
+ function describePeers(peers) {
59
+ const first = `${peers[0].name}@${peers[0].range}`
60
+ return peers.length === 1 ? first : `${first} 等 ${peers.length} 项`
61
+ }
62
+
63
+ /**
64
+ * 行级复算(IO 可注入,便于离线断言)。
65
+ *
66
+ * 入参:`{ moduleName, profileDir, frameworkVersion }`
67
+ * 返回:`null`(无话可说)|`{ kind:'peers'|'missing', reason, hint }`
68
+ * · `kind:'peers'` —— 本地复算判定 peer 不满足(大概率就是框架否决它的原因)
69
+ * · `kind:'missing'` —— 包解析不到 / 清单读不出来(**另一种原因**,措辞与出路都不同)
70
+ */
71
+ function peerVetoFor({ moduleName, profileDir, frameworkVersion, deps = {} } = {}) {
72
+ const { resolvePkg = resolvePackageJson, readText = (p) => readFileSync(p, 'utf8') } = deps
73
+ if (typeof moduleName !== 'string' || moduleName === '' || moduleName.startsWith('cordis:')) return null
74
+ if (typeof profileDir !== 'string' || profileDir === '') return null
75
+ if (typeof frameworkVersion !== 'string' || frameworkVersion === '') return null
76
+ let pkgPath = null
77
+ try { pkgPath = resolvePkg(moduleName, profileDir) } catch { pkgPath = null }
78
+ if (pkgPath === null) {
79
+ return {
80
+ kind: 'missing',
81
+ reason: `被框架否决:包不存在(${moduleName} 在本 profile 解析不到)`,
82
+ hint: `出路:这一行指向的包已经不在本 profile 里(升级/卸载后的残留行)——重装或更新该插件即可;确实不需要它时,在补丁里删掉或禁用它。`,
83
+ }
84
+ }
85
+ let manifest = null
86
+ try { manifest = JSON.parse(readText(pkgPath)) } catch { manifest = null }
87
+ if (manifest === null || typeof manifest !== 'object') {
88
+ return {
89
+ kind: 'missing',
90
+ reason: `被框架否决:包清单读不出来(${moduleName})`,
91
+ hint: '出路:重装该插件——package.json 读不出来时框架自己也无法校验它,只能按最坏情况跳过挂载。',
92
+ }
93
+ }
94
+ const peers = unsatisfiedPeers(manifest, frameworkVersion)
95
+ if (peers.length === 0) return null
96
+ return {
97
+ kind: 'peers',
98
+ reason: `被框架否决:peer 不满足(需要 ${describePeers(peers)},当前 ${frameworkVersion})`,
99
+ hint: `出路:把 ${moduleName} 更新到兼容当前框架的版本(首选);若它是升级后残留的旧包,从 profile 里清掉;要保留这个精确版本,就得用框架的精确版本豁免(dsh plugin allow-version / 插件管理器 → profile 的 compatibility.json)。`,
100
+ }
101
+ }
102
+
103
+ export { PEER_PREFIX, WORKSPACE_RANGES, unsatisfiedPeers, peerVetoFor }
@@ -0,0 +1,251 @@
1
+ // L1 · domain —— settings-api-scan.js(「已删除 dsh-settings API」源码扫描;0.5.33 改错时从 compat.js 搬出)
2
+ // 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
3
+ //
4
+ // 为什么单独成模块:判据从"源码里出现过这个名字"改成"这个名字**真的从
5
+ // `@deepseek-ai/dsh-settings` 绑定进来**"——要先剥注释/字符串字面量,再解析 import/require 的
6
+ // 绑定子句,逻辑比原先的按行子串匹配长得多,而 compat.js 已经 544 行(守卫上限 600)。
7
+ // 搬移保持对外契约不变:`REMOVED_SETTINGS_SYMBOLS` / `referencesRemovedSymbol` /
8
+ // `scanSettingsApiUsage` 三个名字照旧由 compat.js 导出。
9
+ //
10
+ // ── 0.5.33 改错(真机误报,证据充分)────────────────────────────────────────────────
11
+ // 现症:`dshmarket` 被判 `check:'fail'`(= 「源码仍引用 0.1.2 起已删除的 dsh-settings API」),
12
+ // 于是**框架升级预扫会把这个好插件自动禁用**。逐条核对它的三处命中:
13
+ // · dshmarket/lib/settings.js:45 / :53 / :73 —— 全在**注释**里;
14
+ // · dshmarket/lib/routes.js:3136 `settingsNamespace: settingsNamespaceState(),` —— 一个
15
+ // **HTTP 载荷对象的属性名**(值是自己的局部函数 `settingsNamespaceState`,与本包无关);
16
+ // · 该包对 `@deepseek-ai/dsh-settings` 的 **import 零命中**(它早已把那两个 helper 内联)。
17
+ // 旧判据(`text.includes(sym)` → v0.3.35 换成标识符边界 + 排除局部定义)挡不住这两类:
18
+ // 注释里的名字与对象键上的名字都不是"引用",但它们都在**代码行**上、也不是本地定义。
19
+ //
20
+ // 新判据(更严格,不放宽任何真引用):
21
+ // ① 先掩掉注释、字符串/模板字面量、正则字面量(等长空格替换,保留换行与列偏移);
22
+ // 唯一保留的是**模块说明符字符串**(`from 'x'` / `require('x')` / `import('x')`),
23
+ // 否则没法知道某个绑定来自哪个包。
24
+ // ② 只在**真的对这个包**的 import/require 绑定子句里取名字:
25
+ // `import { settingsNamespace } from '@deepseek-ai/dsh-settings'`、
26
+ // `import settingsNamespace from …`、`import * as s from …` 后的 `s.settingsNamespace`、
27
+ // `const { settingsNamespace } = require('@deepseek-ai/dsh-settings')`、
28
+ // `const { installSettingsSection } = await import('@deepseek-ai/dsh-settings')`。
29
+ // ③ 对象键、局部同名函数、别的模块的同名导入、注释与字符串里的名字 —— 一律不算。
30
+
31
+ import { readdirSync, readFileSync } from 'node:fs'
32
+ import { join } from 'node:path'
33
+
34
+ /** 0.1.2-rc.1 起被官方删除的 dsh-settings API(命中即"实际不兼容")。 */
35
+ const REMOVED_SETTINGS_SYMBOLS = ['settingsNamespace', 'installSettingsSection']
36
+
37
+ /** 这些 API 原本来自哪个包(只有真的从它绑定进来的名字才算引用)。 */
38
+ const SETTINGS_MODULE = '@deepseek-ai/dsh-settings'
39
+
40
+ const SPECIFIER_RE = `['"]${SETTINGS_MODULE.replace(/[/\\^$*+?.()|[\]{}]/gu, '\\$&')}['"]`
41
+
42
+ /**
43
+ * 把注释、字符串/模板字面量、正则字面量掩成**等长空格**(换行/回车保留,行号与列偏移不变)。
44
+ * `keepSpecifier` 为真时保留"模块说明符"字符串的原样内容(`from 'x'` / `require('x')` / `import('x')`)。
45
+ *
46
+ * 这是**掩码**而不是删除:后续判据仍然按整份源码做正则,位置对得上才好排查。
47
+ */
48
+ function maskLiterals(text, keepSpecifier) {
49
+ const src = String(text ?? '')
50
+ const out = src.split('')
51
+ const blank = (from, to) => {
52
+ for (let k = from; k < to; k += 1) if (out[k] !== '\n' && out[k] !== '\r') out[k] = ' '
53
+ }
54
+ const isWordChar = (ch) => ch !== undefined && /[A-Za-z0-9_$]/u.test(ch)
55
+ const wordBefore = (index) => {
56
+ let j = index
57
+ while (j > 0 && (src[j - 1] === ' ' || src[j - 1] === '\t')) j -= 1
58
+ let k = j
59
+ while (k > 0 && isWordChar(src[k - 1])) k -= 1
60
+ return src.slice(k, j)
61
+ }
62
+ const prevMeaningful = (index) => {
63
+ for (let k = index - 1; k >= 0; k -= 1) {
64
+ const ch = src[k]
65
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') continue
66
+ return ch
67
+ }
68
+ return ''
69
+ }
70
+ /** 当前位置若是 `word(` 的第一个实参,返回 `word`(用于 require(...) / import(...) 的说明符判定)。 */
71
+ const callWordBefore = (index) => {
72
+ let k = index - 1
73
+ while (k >= 0 && (src[k] === ' ' || src[k] === '\t')) k -= 1
74
+ if (k < 0 || src[k] !== '(') return ''
75
+ k -= 1
76
+ while (k >= 0 && (src[k] === ' ' || src[k] === '\t')) k -= 1
77
+ const end = k + 1
78
+ while (k >= 0 && isWordChar(src[k])) k -= 1
79
+ return src.slice(k + 1, end)
80
+ }
81
+ // 正则字面量的判定沿用仓库既有启发式(tests/test-architecture-guard.mjs 的 stripCode 同款):
82
+ // 前一个有效字符说明这里不可能是除法,或者前一个词是 return/typeof 这类关键字。
83
+ const REGEX_PREV = new Set(['(', ',', '=', ':', '[', '!', '&', '|', '?', '{', '}', ';', '\n', ''])
84
+ const REGEX_KEYWORDS = new Set(['return', 'typeof', 'case', 'in', 'of', 'do', 'else', 'void', 'delete', 'new', 'instanceof', 'yield', 'await'])
85
+ let i = 0
86
+ while (i < src.length) {
87
+ const c = src[i]
88
+ const n = src[i + 1]
89
+ if (c === '/' && n === '/') {
90
+ const end = src.indexOf('\n', i)
91
+ const to = end < 0 ? src.length : end
92
+ blank(i, to)
93
+ i = to
94
+ continue
95
+ }
96
+ if (c === '/' && n === '*') {
97
+ const end = src.indexOf('*/', i + 2)
98
+ const to = end < 0 ? src.length : end + 2
99
+ blank(i, to)
100
+ i = to
101
+ continue
102
+ }
103
+ if (c === "'" || c === '"' || c === '`') {
104
+ let j = i + 1
105
+ while (j < src.length && src[j] !== c) {
106
+ if (src[j] === '\\') j += 1
107
+ j += 1
108
+ }
109
+ const end = j < src.length ? j + 1 : src.length
110
+ // 说明符字符串保留:紧跟在 `from` 之后,或作为 require(...) / import(...) 的第一个实参。
111
+ const asCallArgument = /^(?:require|import)$/u.test(callWordBefore(i))
112
+ const isSpecifier = keepSpecifier === true && c !== '`' && (wordBefore(i) === 'from' || asCallArgument)
113
+ if (!isSpecifier) blank(i + 1, end - 1)
114
+ i = end
115
+ continue
116
+ }
117
+ if (c === '/') {
118
+ const pm = prevMeaningful(i)
119
+ if (REGEX_PREV.has(pm) || REGEX_KEYWORDS.has(wordBefore(i))) {
120
+ let j = i + 1
121
+ let inClass = false
122
+ while (j < src.length) {
123
+ const ch = src[j]
124
+ if (ch === '\\') { j += 2; continue }
125
+ if (ch === '[') inClass = true
126
+ else if (ch === ']') inClass = false
127
+ else if (ch === '/' && !inClass) { j += 1; break }
128
+ else if (ch === '\n') break
129
+ j += 1
130
+ }
131
+ blank(i, j)
132
+ i = j
133
+ continue
134
+ }
135
+ }
136
+ i += 1
137
+ }
138
+ return out.join('')
139
+ }
140
+
141
+ /** 从一个绑定子句里取出**被导入的名字**(`a as b` 取 `a`;默认导入取它自己;`*` 交给命名空间那条路)。 */
142
+ function importedNamesOf(clause) {
143
+ const names = new Set()
144
+ const text = String(clause ?? '')
145
+ const brace = text.match(/\{([^}]*)\}/u)
146
+ if (brace !== null) {
147
+ for (const part of brace[1].split(',')) {
148
+ const seg = part.trim()
149
+ if (seg === '') continue
150
+ const imported = seg.split(/\s+as\s+/u)[0].trim()
151
+ if (/^[A-Za-z_$][\w$]*$/u.test(imported)) names.add(imported)
152
+ }
153
+ }
154
+ const rest = brace === null ? text : text.replace(brace[0], ' ')
155
+ for (const seg of rest.split(',')) {
156
+ const t = seg.trim()
157
+ if (t === '' || t === '*' || /\s/u.test(t)) continue
158
+ if (/^[A-Za-z_$][\w$]*$/u.test(t)) names.add(t)
159
+ }
160
+ return names
161
+ }
162
+
163
+ /**
164
+ * 从源码里取出「真的从 `@deepseek-ai/dsh-settings` 绑定进来的名字」。
165
+ * 返回 `{ named, namespaces }`:`named` 是直接绑定的符号,`namespaces` 是包级命名空间
166
+ * (`import * as s` / `const s = require(…)`),它们的成员访问 `s.settingsNamespace` 也算引用。
167
+ */
168
+ function settingsApiBindings(text) {
169
+ // 掩码只做一次:注释/字符串/正则变空格,模块说明符字符串保留 —— 判据据此认包。
170
+ const code = maskLiterals(text, true)
171
+ const named = new Set()
172
+ const namespaces = new Set()
173
+ // ① ESM 命名空间导入:`import * as s from '<mod>'`
174
+ for (const m of code.matchAll(new RegExp(`\\bimport\\s*\\*\\s*as\\s+([A-Za-z_$][\\w$]*)\\s+from\\s*${SPECIFIER_RE}`, 'gu'))) {
175
+ namespaces.add(m[1])
176
+ }
177
+ // ② ESM 具名/默认导入:`import { a, b as c } from '<mod>'` / `import d from '<mod>'`
178
+ // 子句里不允许再出现 import/from,长度封顶 2000 —— 挡住"跨到上一条 import 语句"的假匹配。
179
+ for (const m of code.matchAll(new RegExp(`\\bimport\\s+(?!\\*)((?:(?!\\bimport\\b|\\bfrom\\b)[\\s\\S]){0,2000}?)\\s+from\\s*${SPECIFIER_RE}`, 'gu'))) {
180
+ for (const name of importedNamesOf(m[1])) named.add(name)
181
+ }
182
+ // ③ CJS / 动态导入的解构绑定:`const { a } = require('<mod>')` / `= await import('<mod>')`
183
+ const callRe = `(?:require|import)\\s*\\(\\s*${SPECIFIER_RE}\\s*\\)`
184
+ for (const m of code.matchAll(new RegExp(`(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*(?:await\\s+)?${callRe}`, 'gu'))) {
185
+ for (const name of importedNamesOf(`{${m[1]}}`)) named.add(name)
186
+ }
187
+ // ④ CJS / 动态导入的命名空间绑定:`const s = require('<mod>')`
188
+ for (const m of code.matchAll(new RegExp(`(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*(?:await\\s+)?${callRe}`, 'gu'))) {
189
+ namespaces.add(m[1])
190
+ }
191
+ return { named, namespaces }
192
+ }
193
+
194
+ /** 命中的已删除符号列表(空数组 = 干净)。判据见文件头:只有真绑定/真成员访问才算。 */
195
+ function settingsApiReferences(text) {
196
+ const { named, namespaces } = settingsApiBindings(text)
197
+ const found = []
198
+ for (const sym of REMOVED_SETTINGS_SYMBOLS) {
199
+ if (named.has(sym)) { found.push(sym); continue }
200
+ for (const ns of namespaces) {
201
+ if (new RegExp(`(?<![\\w$.])${ns}\\s*\\.\\s*${sym}(?![\\w$])`, 'u').test(maskLiterals(text, true))) { found.push(sym); break }
202
+ }
203
+ }
204
+ return found
205
+ }
206
+
207
+ /** 源码是否**引用**了某个已删除符号(对外契约不变;判据已收紧到"真绑定/真成员访问")。 */
208
+ function referencesRemovedSymbol(text, sym) {
209
+ return settingsApiReferences(text).includes(sym)
210
+ }
211
+
212
+ /**
213
+ * 扫描已安装包源码,检测对已删除的 dsh-settings API 的引用。
214
+ *
215
+ * 2026-09-04 教训:0.1.2-rc.1 起 settingsNamespace / installSettingsSection 已删除,
216
+ * 静态声明检查判 pass 是假通过;真实兼容性只有模块 import 时见分晓,而 loader 单行失败
217
+ * = 整个服务启动崩溃。
218
+ *
219
+ * 扫描预算(未改):限深 3 层、上限 120 个文件、单文件读前 400KB。
220
+ */
221
+ function scanSettingsApiUsage(pkgDir) {
222
+ const found = []
223
+ const seen = new Set()
224
+ const walk = (dir, depth) => {
225
+ if (depth > 3) return
226
+ let entries = []
227
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
228
+ for (const entry of entries) {
229
+ const full = join(dir, entry.name)
230
+ if (entry.isDirectory()) {
231
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
232
+ walk(full, depth + 1)
233
+ continue
234
+ }
235
+ if (!entry.isFile() || !/\.(?:js|mjs|cjs)$/u.test(entry.name)) continue
236
+ if (seen.has(full)) continue
237
+ seen.add(full)
238
+ if (seen.size > 120) return
239
+ try {
240
+ const text = readFileSync(full, 'utf8').slice(0, 400000)
241
+ for (const sym of settingsApiReferences(text)) {
242
+ if (!found.includes(sym)) found.push(sym)
243
+ }
244
+ } catch {}
245
+ }
246
+ }
247
+ walk(pkgDir, 0)
248
+ return found
249
+ }
250
+
251
+ export { REMOVED_SETTINGS_SYMBOLS, SETTINGS_MODULE, maskLiterals, settingsApiBindings, settingsApiReferences, referencesRemovedSymbol, scanSettingsApiUsage }
@@ -327,7 +327,9 @@ async function routeAdaptUnlock(req, res, rc) {
327
327
  const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : '?'
328
328
  const check = pkg !== null ? checkPluginFrameworkCompat(pkg, fwVer, pkgDir) : { decision: 'unknown', reason: '无法读取包信息' }
329
329
  if (check.decision === 'fail') {
330
- sendError(res, 409, `适配校验未通过:${check.reason}`)
330
+ // 0.5.33 改错 ①-2:文案必须写清「为什么不行 + 出路」——旧文案只有一句"适配校验未通过",
331
+ // 用户在界面上根本不知道下一步该做什么(而按钮此前又只在 adoptable 时出现,容易变成对不上的承诺)。
332
+ sendError(res, 409, `适配校验未通过:${String(check.reason ?? '适配校验未通过')}。出路:先点「检测更新」把它更新到兼容当前框架 ${fwVer} 的版本(更新完成会自动重跑源码扫描,通过即解锁);若它已不再需要,可在补丁里删掉/禁用这一行。本次未改动补丁与适配门清单。`)
331
333
  return
332
334
  }
333
335
  await removeDisableBlock(patchPath, rowId)
@@ -5,7 +5,8 @@ import { readFileSync, existsSync } from 'node:fs'
5
5
  import { dirname, join } from 'node:path'
6
6
  import { detectAdoptablePending, detectCompat, gatingSummary, readCompatGate, readCompatPending } from '../domain/compat.js'
7
7
  import { compUiUrl, findComponents } from '../domain/components.js'
8
- import { detectFrameworkUpgrade } from '../domain/framework.js'
8
+ import { currentFrameworkVersion, detectFrameworkUpgrade } from '../domain/framework.js'
9
+ import { peerVetoFor } from '../domain/peer-veto.js'
9
10
  import { readExtraBundleRows } from '../domain/install-job.js'
10
11
  import { installJobView, readGithubAuth } from '../domain/install.js'
11
12
  import { readPluginDetails } from '../domain/market.js'
@@ -46,6 +47,7 @@ async function routeStateGet(req, res, rc) {
46
47
  setPatchHealReport(patchHeal)
47
48
  }
48
49
  const extraRows = await readExtraBundleRows(dirname(patchPath))
50
+ const profileDir = dirname(patchPath)
49
51
  const compatPending = readCompatPending()
50
52
  const compatPendingRows = new Set((compatPending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => p.rowId))
51
53
  // 兼容门总开关 + 自动检测(只提示不自动开)
@@ -58,11 +60,21 @@ async function routeStateGet(req, res, rc) {
58
60
  rollbackRec = { from: rr.from ?? null, to: rr.to ?? null, at: rr.at ?? null, applicable: true }
59
61
  }
60
62
  } catch {}
63
+ // 0.5.33 加法 D-⑧(诊断缺口):**补丁要求启用、运行时却没挂载**(fiberPhase === null)的行,
64
+ // 本地复算框架启动时的 peer 预检,补出短句原因 + 出路。框架那一侧只在**内存里**把
65
+ // row.disabled 置 true(不写用户补丁)、原因只 process.stderr.write —— 面板此前一个字都看不出。
66
+ // 判据与 dsh-app-boot 的 evaluatePluginCompatibility / preflight 对齐(见 domain/peer-veto.js)。
67
+ const vetoFrameworkVersion = currentFrameworkVersion(ctx)
61
68
  const entries = listEntries(ctx).map((entry) => {
62
69
  const meta = entryPkgMeta(entry.moduleName, ctx.baseUrl ?? 'file:///', profileDirOf(ctx))
70
+ const disabledByPatch = patch.disables.includes(entry.rowId)
71
+ let veto = null
72
+ if (!disabledByPatch && entry.fiberPhase === null && vetoFrameworkVersion !== null) {
73
+ try { veto = peerVetoFor({ moduleName: entry.moduleName, profileDir, frameworkVersion: vetoFrameworkVersion }) } catch { veto = null }
74
+ }
63
75
  return {
64
76
  ...entry,
65
- userDisabled: patch.disables.includes(entry.rowId),
77
+ userDisabled: disabledByPatch,
66
78
  userForced: patch.forced.includes(entry.rowId),
67
79
  extra: patch.inserts.includes(entry.rowId) || extraRows.has(entry.moduleName) || extraRows.has(entry.rowId),
68
80
  installDate: meta?.installDate ?? null,
@@ -70,9 +82,11 @@ async function routeStateGet(req, res, rc) {
70
82
  repository: meta?.repository ?? null,
71
83
  // v0.3.45:只有"补丁里此刻确实还禁着它"的行才显示【待适配】——
72
84
  // 记录与开关脱节时(用户手动启用过 / 补丁块被清掉)不能继续挂着「待适配」误导人
73
- pendingCompat: compatPendingRows.has(entry.rowId) && patch.disables.includes(entry.rowId),
85
+ pendingCompat: compatPendingRows.has(entry.rowId) && disabledByPatch,
74
86
  // 自动检测结果(只提示,不自动开):该待适配行现在是否已适配(插件已更新 + 扫描通过)
75
87
  adoptable: adoptable.get(entry.rowId) ?? null,
88
+ // 框架 peer 预检的本地复算结果(null = 无话可说;只在这类行上下发,不新增常驻面板行)
89
+ veto,
76
90
  }
77
91
  })
78
92
  const compat = await detectCompat(ctx.baseUrl ?? 'file:///')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.32",
3
+ "version": "0.5.33",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",