@noob-stupid/dsh-plugin-console 0.5.50 → 0.5.54

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/lib/client.js CHANGED
@@ -116,6 +116,11 @@ window.__ModuleLoader__.load({
116
116
  actionBoxTitle: "建议动作",
117
117
  actionPinLabel: "钉住该依赖(按 link: 记录)",
118
118
  actionPersistLabel: "一键钉住(清单 + lock + 挂载行)",
119
+ vetoJudgedCopy: "面板显示 {shown},但这条否决判定用的是 profile 里那份 {judged} —— 鼠标悬停可看路径(说明这是两份副本,见 CHANGELOG v0.5.52)",
120
+ skippedBundlesTitle: "被框架跳过的 bundle({n} 个)—— 它们装了但整层没被装载,所以插件列表里看不到;鼠标悬停看每一项的出路",
121
+ hostedNoChannel: "本实例由桌面端外壳托管:框架升级与回滚请走桌面端(此处不可用)",
122
+ linksTitle: "开发式链接(link):共 {n} 个,自足 {ready} 个 —— ✓ 自足 · · 缺东西(悬停看原因)",
123
+ linksFailed: "开发式链接清单读取失败",
119
124
  actionReconcileLabel: "重建 pnpm-lock.yaml",
120
125
  actionRun: "执行",
121
126
  actionCopy: "复制",
@@ -704,6 +709,11 @@ window.__ModuleLoader__.load({
704
709
  actionBoxTitle: "Suggested action",
705
710
  actionPinLabel: "Pin this dependency (record as link:)",
706
711
  actionPersistLabel: "Pin it in one click (manifest + lock + mount row)",
712
+ vetoJudgedCopy: "Panel shows {shown}, but this veto was computed against the profile copy {judged} - hover for the path (two copies exist; see CHANGELOG v0.5.52)",
713
+ skippedBundlesTitle: "{n} bundle(s) skipped by the framework - installed but never loaded, which is why they are absent from the plugin list; hover an item for the way out",
714
+ hostedNoChannel: "Hosted by the desktop shell: use the desktop app for framework upgrade and rollback (unavailable here)",
715
+ linksTitle: "Dev links (link:): {n} total, {ready} self-sufficient - checkmark = ready, dot = something missing (hover for why)",
716
+ linksFailed: "Failed to read the dev-link inventory",
707
717
  actionReconcileLabel: "Rebuild pnpm-lock.yaml",
708
718
  actionRun: "Run",
709
719
  actionCopy: "Copy",
@@ -2121,8 +2131,15 @@ window.__ModuleLoader__.load({
2121
2131
  // 只要 /state 里已有 framework.version 与 rollback.from 就能判定,刷新页面即生效。
2122
2132
  const rollbackInfo = state.status === "ready" ? state.data.rollback : null;
2123
2133
  const curFwVersion = state.status === "ready" && typeof state.data.framework?.version === "string" ? state.data.framework.version : null;
2134
+ // ★ 0.5.53 加法(真机 2026-10-15):本实例是否由**桌面端外壳托管**。
2135
+ // 升级/回滚两条通道在外壳托管时会被服务端**拒绝**(409 + 那句"请在桌面端内重启/更新"),
2136
+ // 而面板过去照旧显示可点的「回滚到上一版」⇒ 点下去必然失败。
2137
+ // 现在按同一份事实(/state 的 host 字段)禁用并说明原因,而不是让人撞墙。
2138
+ const hostedByShell = state.status === "ready" && state.data.host?.hosted === true;
2139
+ const hostedNotice = hostedByShell ? (state.data.host?.notice ?? null) : null;
2124
2140
  const rollbackUsable = rollbackInfo !== null && rollbackInfo !== undefined
2125
2141
  && rollbackInfo.applicable !== false
2142
+ && hostedByShell !== true
2126
2143
  && !(curFwVersion !== null && rollbackInfo.from !== null && rollbackInfo.from !== undefined && curFwVersion === rollbackInfo.from);
2127
2144
  // 升级步骤视图(卡片与常驻面板共用;失败时按 stage 标已完成步骤,不整列红叉)
2128
2145
  const fwStepsView = (st) => {
@@ -3985,10 +4002,18 @@ window.__ModuleLoader__.load({
3985
4002
  updateCheck !== null && updateCheck.status === "ready"
3986
4003
  ? (updateCheck.error
3987
4004
  ? el("p", { className: styles.status, "data-error": "true" }, t("updateCheckFailed") + ":" + updateCheck.error)
3988
- : (updateCheck.latest && meta !== null && meta.version && updateCheck.latest !== meta.version
4005
+ // 0.5.52 改错(真机 2026-10-15):这里原先只认 `latest`。
4006
+ // 而 npm 上 `@deepseek-ai/*` 的 `dist-tags.latest` 是**远古版本**,
4007
+ // 被服务端按"低于已装"净化成 null ⇒ 面板永远说「已是最新版本」,
4008
+ // 「不适配 → 禁用 → 更新 → 启用」这条流程**走不完**(用户原话:
4009
+ // "我们原本意思就是如果不适配先禁用,然后再更新适配启用吧")。
4010
+ // 服务端现在同时下发 `target`(latest 优先,其次 next/beta,
4011
+ // 与框架卡片 pickFrameworkTarget 同口径),这里改用它、并回落 latest
4012
+ // 以兼容老服务端(字段缺失时行为与旧版一字不差)。
4013
+ : ((updateCheck.target ?? updateCheck.latest) && meta !== null && meta.version && (updateCheck.target ?? updateCheck.latest) !== meta.version
3989
4014
  ? el("div", null,
3990
4015
  el("div", { className: styles.rowTop },
3991
- el("p", { className: styles.status }, t("updateAvailable") + ":" + meta.version + " → " + updateCheck.latest + (updateCheck.source === "github" ? "(GitHub release)" : "")),
4016
+ el("p", { className: styles.status }, t("updateAvailable") + ":" + meta.version + " → " + (updateCheck.target ?? updateCheck.latest) + (updateCheck.source === "github" ? "(GitHub release)" : "")),
3992
4017
  el("button", {
3993
4018
  type: "button",
3994
4019
  className: styles.toggle,
@@ -4045,6 +4070,17 @@ window.__ModuleLoader__.load({
4045
4070
  ? el("p", { className: styles.status, "data-pending": "true", title: entry.veto.hint ?? undefined },
4046
4071
  entry.veto.reason)
4047
4072
  : null,
4073
+ // 0.5.52 加法(真机现场):否决判据用的那份副本,与面板显示的版本**可能不是同一个**。
4074
+ // 桌面端有 asar(框架自带)与 profile 两份同名包:显示版本走框架 base 优先,
4075
+ // 判据只按 profile ⇒ 卡片会自相矛盾(「版本 0.2.1-rc.2」+「peer 不满足」)。
4076
+ // 只在两者确实不一致时才多写一行,且如实给出判据那份的路径 —— 不猜、不掩盖。
4077
+ entry.veto && entry.veto.judged && entry.veto.judged.version
4078
+ && entry.version && entry.veto.judged.version !== entry.version
4079
+ ? el("p", { className: styles.status, title: entry.veto.judged.path ?? undefined },
4080
+ t("vetoJudgedCopy")
4081
+ .replace("{shown}", String(entry.version))
4082
+ .replace("{judged}", String(entry.veto.judged.version)))
4083
+ : null,
4048
4084
  el("div", { className: styles.rowTop, style: { justifyContent: "space-between" } },
4049
4085
  el("div", { className: styles.rowTop },
4050
4086
  el("button", {
@@ -4551,6 +4587,22 @@ window.__ModuleLoader__.load({
4551
4587
  }, t("compatNoticeDismiss")),
4552
4588
  )
4553
4589
  : null,
4590
+ // ★ 0.5.53 加法(真机 2026-10-15):**被框架整层跳过的 bundle**。
4591
+ // 为什么必须放在这里(不是列表里):被跳过的 bundle **不产生任何 loader 行**,
4592
+ // 所以在插件列表里根本没有位置可挂 —— 用户看到的是"我明明有,却不显示"。
4593
+ // 服务端 skippedBundles.reliable === true 才渲染(判据不可靠时宁可不报,见 domain/bundle-veto.js)。
4594
+ state.status === "ready" && state.data.skippedBundles
4595
+ && state.data.skippedBundles.reliable === true
4596
+ && Array.isArray(state.data.skippedBundles.items)
4597
+ && state.data.skippedBundles.items.length > 0
4598
+ ? el("div", { className: styles.message, "data-error": "true", style: { marginTop: "8px" } },
4599
+ el("p", null,
4600
+ t("skippedBundlesTitle").replace("{n}", String(state.data.skippedBundles.items.length))),
4601
+ ...state.data.skippedBundles.items.map((b) => el("p", { key: b.packageName, title: b.hint ?? undefined, style: { margin: "4px 0 0 12px" } },
4602
+ "• " + b.packageName + (b.version ? " v" + b.version : "") + " — " + b.reason)),
4603
+ )
4604
+ : null,
4605
+
4554
4606
  state.status === "ready" && comps.length > 0 && details === null && repoInfo === null
4555
4607
  ? el("button", {
4556
4608
  type: "button",
@@ -4698,7 +4750,10 @@ window.__ModuleLoader__.load({
4698
4750
  el("div", { className: styles.rowTop },
4699
4751
  el("button", { type: "button", className: styles.toggle, disabled: frameworkUpgrading, onClick: () => { setFwConfirmPanel(false); doFrameworkUpgrade(); } }, frameworkUpgrading ? t("installingLocal") : t("frameworkUpgradeConfirm")),
4700
4752
  el("button", { type: "button", className: styles.toggle, onClick: () => setFwConfirmPanel(false) }, t("closeModal"))))
4701
- : el("button", { type: "button", className: styles.toggle, disabled: frameworkUpgrading, title: t("frameworkUpgradeTitle"), onClick: () => setFwConfirmPanel(true) }, t("frameworkUpgradeBtn") + " → v" + (fwSelected ?? fwCheck.target)))
4753
+ : el("button", { type: "button", className: styles.toggle, disabled: frameworkUpgrading || hostedByShell, title: hostedByShell ? (hostedNotice ?? t("frameworkUpgradeTitle")) : t("frameworkUpgradeTitle"), onClick: () => setFwConfirmPanel(true) }, t("frameworkUpgradeBtn") + " → v" + (fwSelected ?? fwCheck.target)))
4754
+ : null,
4755
+ hostedByShell
4756
+ ? el("span", { className: styles.status }, hostedNotice ?? t("hostedNoChannel"))
4702
4757
  : null,
4703
4758
  rollbackUsable
4704
4759
  ? el("button", { type: "button", className: styles.toggle, title: t("frameworkRollbackTitle"), disabled: frameworkUpgrading, onClick: doFrameworkRollback }, t("frameworkRollbackBtn") + " → " + (rollbackInfo.from ?? "?"))
@@ -6054,9 +6109,15 @@ window.__ModuleLoader__.load({
6054
6109
  const [backfill, setBackfill] = react.useState(null);
6055
6110
  // 2026-09-27 加法:依赖锁体检结果(null = 还没体检过;不自动跑,用户点「体检」才打接口)
6056
6111
  const [lockFix, setLockFix] = react.useState(null);
6112
+ // ★ 0.5.54 加法:link 开发式安装清单(/links,纯只读)。
6113
+ // 过去 link 钉住的插件与普通插件长得一模一样 —— 看不出指向哪、目标还在不在、
6114
+ // 依赖/peer 齐不齐、垫片还在不在。这份清单让"看见"先成立。
6115
+ const [links, setLinks] = react.useState(null);
6057
6116
  const load = async () => {
6058
6117
  try {
6059
- const data = await call("/plugin-console/compat-status");
6118
+ // link 清单:独立 try(拿不到时这块显示"读取失败",绝不静默)
6119
+ try { const lk = await call("/plugin-console/links"); if (lk !== null) setLinks(lk); } catch {}
6120
+ const data = await call("/plugin-console/compat-status");
6060
6121
  if (data !== null) {
6061
6122
  setStatus(data);
6062
6123
  // 0.5.32 加法(D1/D4/D5):**瞬时提示**,沿用既有 note 通道(自动消失、无新组件、无按钮)。
@@ -6189,7 +6250,27 @@ window.__ModuleLoader__.load({
6189
6250
  const panel = el("div", { className: "pc-safety-panel", style: { marginTop: "8px", padding: "8px", borderRadius: "12px", border: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.18))", background: "var(--dsw-alias-bg-layer-4, rgba(127,127,127,.10))", display: "flex", flexDirection: "column", gap: "4px" } },
6190
6251
  row("shield", t("safetyMode"), mode === "observer" ? t("safetyModeObserver") : t("safetyModeManaged") + (status !== null && typeof status.modeReason === "string" && status.modeReason !== "" ? "(" + status.modeReason + ")" : ""), sw(mode === "observer", () => switchMode(mode === "observer" ? "managed" : "observer"), t("safetyModeSwitch"))),
6191
6252
  row("pin", t("safetyFrameworkTitle"), fill(t("safetyFrameworkDesc"), [["v", fw]])),
6192
- row("back", t("safetyRollbackTitle"), rb.applicable === true ? fill(t("safetyRollbackOk"), [["from", rb.from], ["to", rb.to]]) : fill(t("safetyRollbackNone"), [["reason", rb.reason === null || rb.reason === undefined ? "-" : rb.reason]])),
6253
+ // ★ 0.5.53:外壳托管时回滚点是**点了会被拒**的(409),这里按同一份事实标成"不可用"
6254
+ // 并把原因说出来,而不是显示"可用"让人撞墙。
6255
+ row("back", t("safetyRollbackTitle"), status !== null && status?.host?.hosted === true
6256
+ ? fill(t("safetyRollbackNone"), [["reason", status?.host?.notice ?? t("hostedNoChannel")]])
6257
+ : (rb.applicable === true ? fill(t("safetyRollbackOk"), [["from", rb.from], ["to", rb.to]]) : fill(t("safetyRollbackNone"), [["reason", rb.reason === null || rb.reason === undefined ? "-" : rb.reason]]))),
6258
+ // ★ 0.5.54 加法:开发式链接(link)清单 —— 每行给出目标、版本、自足性与垫片。
6259
+ // 判据全部来自服务端 /links(纯只读);这里只负责如实展示,不推断、不美化。
6260
+ links !== null && Array.isArray(links.items) && links.items.length > 0
6261
+ ? el("div", { style: { display: "flex", flexDirection: "column", gap: "2px", marginTop: "4px", paddingTop: "4px", borderTop: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.18))" } },
6262
+ el("div", { style: { fontSize: "12px", fontWeight: 500 } }, fill(t("linksTitle"), [["n", String(links.count ?? links.items.length)], ["ready", String(links.readyCount ?? 0)]])),
6263
+ ...links.items.map((x) => el("div", { key: x.packageName, title: x.note ?? undefined, style: { fontSize: "12px", marginLeft: "10px", opacity: .85, wordBreak: "break-all" } },
6264
+ (x.ready === true ? "✓ " : "• ") + x.packageName + (x.version ? " v" + x.version : "")
6265
+ + (x.target ? " → " + x.target : "")
6266
+ + (Array.isArray(x.missingDeps) && x.missingDeps.length > 0 ? " · 缺依赖 " + x.missingDeps.length : "")
6267
+ + (Array.isArray(x.missingPeers) && x.missingPeers.length > 0 ? " · 缺 peer " + x.missingPeers.length : "")
6268
+ + (Array.isArray(x.foreignPeers) && x.foreignPeers.length > 0 ? " · 异副本 " + x.foreignPeers.length : "")
6269
+ + (x.shims !== null && x.shims !== undefined ? " · 垫片 " + String(x.shims.count ?? 0) : "")
6270
+ + (x.persisted !== null && x.persisted !== undefined && x.persisted.ok === false ? " · 未持久化" : ""))))
6271
+ : (links !== null && links.ok === false
6272
+ ? el("div", { style: { fontSize: "12px", marginTop: "4px", opacity: .8 } }, t("linksFailed") + ":" + String(links.error ?? "-"))
6273
+ : null),
6193
6274
  row("scan", t("safetyFpTitle"), fp.changed === true ? fill(t("safetyFpChanged"), [["reasons", (fp.reasons || []).join(";")]]) : t("safetyFpStable"), fp.changed === true ? el("button", { type: "button", className: styles.toggle, onClick: ack, disabled: busy }, t("safetyFpAck")) : null),
6194
6275
  row("list", t("safetyBackfillTitle"), pending > 0 ? fill(t("safetyBackfillDesc"), [["n", pending]]) : t("safetyBackfillNone"), pending > 0 ? el("button", { type: "button", className: styles.toggle, disabled: busy, onClick: applyBackfill }, fill(t("safetyBackfillBtn"), [["n", pending]])) : null),
6195
6276
  // 依赖锁体检 / 重建(2026-09-27 加法):体检只读;重建必须用户点(服务端也只认显式 POST)。
@@ -6278,4 +6359,4 @@ window.__ModuleLoader__.load({
6278
6359
  exports.canAdaptUnlock = canAdaptUnlock;
6279
6360
  return module.exports;
6280
6361
  }
6281
- });
6362
+ });
@@ -0,0 +1,195 @@
1
+ // L1 · domain —— bundle 级否决(「被框架跳过的 bundle」)
2
+ //
3
+ // 真机现场(2026-10-15,用户原话:「为啥有的插件我本身是有的,但是没显示呢,比如 opviking 的 memory 插件」):
4
+ // `@openviking/dsh-memory-plugin` 装了、在 `dsh.profile.bundles` 里、自己的补丁也正常,
5
+ // 但面板上**一个字都没有**。原因在框架侧:`dsh-app-boot#loadProfileDirectory` 在**装载 bundle 时**
6
+ // 检查该 bundle 自身的 dsh peers,不满足就 `throw` ⇒ 整个 bundle 层进 `skippedBundles`,
7
+ // 它要插入的行因此**根本不存在**;而面板是 enumerate loader 行的,自然什么都不显示。
8
+ //
9
+ // 框架只把原因写进 `skippedBundles`(且不打印),控制台过去**完全不知道**这件事
10
+ // (全仓 grep 只有一句提示文案提过 compatibility.json)—— 于是"插件静默消失"。
11
+ // 本模块把它变成可见:**复算同一判据,纯只读,绝不改动任何东西**。
12
+ //
13
+ // 判据与框架逐字对齐(`dsh-app-boot` 的 `evaluatePluginCompatibility` + `resolveBundleDir`):
14
+ // ① **解析顺序:安装锚点优先,profile 兜底**。框架注释写明"内置 bundle 永远来自运行中的 dsh 安装,
15
+ // 不用 profile 本地副本"——所以不能用"profile 优先",否则会把内置 bundle 判成跳过(误报)。
16
+ // ② 只看 `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` 这类 peer(复用 peer-veto 的 unsatisfiedPeers)。
17
+ // ③ **豁免优先**:profile 的 `compatibility.json` 里 `"包@精确版本": ["DSH版本"...]` 命中当前框架版本
18
+ // ⇒ 框架不跳过 ⇒ 我们也不许报"被跳过"。读不出/坏文件 ⇒ 视为无豁免(与框架同款降级)。
19
+ // ④ 没有 `dsh.bundle` 声明的也算跳过(框架在同一个 try 里抛错),但**原因不同**,必须分开说。
20
+ //
21
+ // 分层:L1 domain —— 不认识 cordis ctx;收的是已解开的 profileDir / baseUrl(架构守卫 ⑤)。
22
+ import { existsSync, readFileSync } from 'node:fs'
23
+ import { createRequire } from 'node:module'
24
+ import { dirname, join } from 'node:path'
25
+ import { unsatisfiedPeers } from './peer-veto.js'
26
+
27
+ /** profile 本地兼容豁免文件名(框架常量同名)。 */
28
+ const COMPATIBILITY_FILE = 'compatibility.json'
29
+
30
+ const readJsonSafe = (file) => {
31
+ try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null }
32
+ }
33
+
34
+ /**
35
+ * 读 profile 的兼容豁免。形状与框架 `readProfileCompatibility` 一致:
36
+ * 顶层是 map:`"@scope/pkg@1.2.3"` → `["0.2.1-rc.2", ...]`
37
+ * 坏文件 / 坏记录一律**忽略**(绝不因为读不懂就不让 profile 用;与框架同款降级)。
38
+ * @returns {Object<string, string[]>} 只含被接受的记录
39
+ */
40
+ function readCompatExemptions(profileDir) {
41
+ if (typeof profileDir !== 'string' || profileDir === '') return {}
42
+ const value = readJsonSafe(join(profileDir, COMPATIBILITY_FILE))
43
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
44
+ const out = {}
45
+ for (const [key, versions] of Object.entries(value)) {
46
+ if (typeof key !== 'string' || key.lastIndexOf('@') <= 0) continue
47
+ if (!Array.isArray(versions) || !versions.every((v) => typeof v === 'string' && v !== '')) continue
48
+ out[key] = versions
49
+ }
50
+ return out
51
+ }
52
+
53
+ /** 安装锚点:运行中 dsh 安装的 package.json 绝对路径(框架 resolveBundleDir 的第一个锚点)。取不到返回 null。 */
54
+ function installAnchorFrom(baseUrl) {
55
+ try {
56
+ return createRequire(typeof baseUrl === 'string' && baseUrl !== '' ? baseUrl : 'file:///').resolve('@deepseek-ai/dsh/package.json')
57
+ } catch {
58
+ return null
59
+ }
60
+ }
61
+
62
+ /** 从某个锚点按 Node 语义找包的 package.json;找不到返回 null。
63
+ * 与框架的 `packageDirFromAnchor` 同义:从 anchor 所在目录向上找 `node_modules/<包名>/package.json`
64
+ * (bundle 可能装在上一层,例如安装锚点就在 `node_modules/@deepseek-ai/dsh/` 里)。 */
65
+ function packageJsonFromAnchor(anchor, packageName) {
66
+ if (typeof anchor !== 'string' || anchor === '') return null
67
+ const parts = String(packageName).split('/')
68
+ let dir = dirname(anchor)
69
+ for (let i = 0; i < 12; i += 1) {
70
+ const candidate = join(dir, 'node_modules', ...parts, 'package.json')
71
+ if (existsSync(candidate)) return candidate
72
+ const parent = dirname(dir)
73
+ if (parent === dir) break
74
+ dir = parent
75
+ }
76
+ return null
77
+ }
78
+
79
+ /**
80
+ * 解析这个 bundle 的 package.json 路径:**安装锚点优先,profile 兜底**(与框架 resolveBundleDir 同序)。
81
+ * @returns {string|null}
82
+ */
83
+ function resolveBundlePackageJson(packageName, profileDir, baseUrl) {
84
+ const anchors = []
85
+ const install = installAnchorFrom(baseUrl)
86
+ if (install !== null) anchors.push(install)
87
+ if (typeof profileDir === 'string' && profileDir !== '') anchors.push(join(profileDir, 'package.json'))
88
+ for (const anchor of anchors) {
89
+ const hit = packageJsonFromAnchor(anchor, packageName)
90
+ if (hit !== null) return hit
91
+ }
92
+ return null
93
+ }
94
+
95
+ /**
96
+ * 复算:这个 bundle 会不会被框架**整层跳过**?返回 null = 不会(正常装载)。
97
+ *
98
+ * @param {object} p
99
+ * @param {string} p.bundleName `dsh.profile.bundles` 里的包名
100
+ * @param {string} p.profileDir profile 根目录
101
+ * @param {string} [p.baseUrl] 解析安装锚点用(route 传 ctx.baseUrl)
102
+ * @param {string} p.frameworkVersion 运行时框架版本
103
+ * @param {Object<string,string[]>} [p.exemptions] 已读好的豁免(省得每个 bundle 重读一遍文件)
104
+ * @returns {null | { packageName: string, version: string|null, path: string|null,
105
+ * kind: 'missing'|'no-bundle'|'peers', reason: string, hint: string }}
106
+ */
107
+ function bundleVetoFor({ bundleName, profileDir, baseUrl = '', frameworkVersion, exemptions = null } = {}) {
108
+ if (typeof bundleName !== 'string' || bundleName === '') return null
109
+ if (typeof frameworkVersion !== 'string' || frameworkVersion === '') return null
110
+ const pkgPath = resolveBundlePackageJson(bundleName, profileDir, baseUrl)
111
+ if (pkgPath === null) {
112
+ return {
113
+ packageName: bundleName,
114
+ version: null,
115
+ path: null,
116
+ kind: 'missing',
117
+ reason: `被框架跳过:bundle ${bundleName} 解析不到(既不在安装里,也不在 profile 里)`,
118
+ hint: '出路:该 bundle 声明在 dsh.profile.bundles 里但包体不存在——重装它,或从 dsh.profile.bundles 里删掉这一行。',
119
+ }
120
+ }
121
+ const manifest = readJsonSafe(pkgPath)
122
+ if (manifest === null || typeof manifest !== 'object') {
123
+ return {
124
+ packageName: bundleName, version: null, path: pkgPath, kind: 'missing',
125
+ reason: `被框架跳过:bundle ${bundleName} 的 package.json 读不出来`,
126
+ hint: '出路:重装该 bundle(清单读不出来时框架只能跳过它)。',
127
+ }
128
+ }
129
+ const version = typeof manifest.version === 'string' ? manifest.version : null
130
+ // ④ 没有 dsh.bundle 声明:框架在同一个 try 里抛错 ⇒ 同样被跳过,但原因与 peer 无关(分开说)
131
+ if (manifest.dsh?.bundle === undefined) {
132
+ return {
133
+ packageName: bundleName, version, path: pkgPath, kind: 'no-bundle',
134
+ reason: `被框架跳过:bundle ${bundleName}@${version ?? '?'} 的 package.json 没有 dsh.bundle 声明`,
135
+ hint: '出路:这不是一个 bundle 包——把它从 dsh.profile.bundles 里删掉,或换成声明了 dsh.bundle 的包。',
136
+ }
137
+ }
138
+ // ② + ③ peers 与豁免
139
+ const peers = unsatisfiedPeers(manifest, frameworkVersion)
140
+ if (peers.length === 0) return null
141
+ const table = exemptions === null ? readCompatExemptions(profileDir) : exemptions
142
+ const key = `${manifest.name ?? bundleName}@${version ?? ''}`
143
+ if (Array.isArray(table[key]) && table[key].includes(frameworkVersion)) return null // 已豁免 ⇒ 框架不跳过
144
+ const first = `${peers[0].name}@${peers[0].range}`
145
+ const what = peers.length === 1 ? first : `${first} 等 ${peers.length} 项`
146
+ return {
147
+ packageName: bundleName,
148
+ version,
149
+ path: pkgPath,
150
+ kind: 'peers',
151
+ reason: `被框架跳过:bundle 自身的 peer 不满足(需要 ${what},当前 ${frameworkVersion})`,
152
+ hint: `出路:把该 bundle 更新到兼容当前框架的版本(首选);或在 profile 的 ${COMPATIBILITY_FILE} 里授精确版本豁免(${key} → ["${frameworkVersion}"]);不需要就把它从 dsh.profile.bundles 删掉。`,
153
+ judged: { version, path: pkgPath, peers },
154
+ }
155
+ }
156
+
157
+ /** 批量复算(豁免只读一次)。
158
+ *
159
+ * ★ 可靠性判定(真机试算后加的,必须有):框架解析 bundle 是"**安装锚点优先**、profile 兜底"。
160
+ * 如果**安装锚点解析不出来**(例如普通 Node 读不进 Electron 的 asar),我们的解析就退化成
161
+ * "只看 profile" —— 那会把**内置 bundle**(`@deepseek-ai/dsh-web-app` 这种明明在跑的)误报成"被跳过"。
162
+ * 实测过一次:普通 Node 下把 5 个 bundle 报成跳过,其中 `dsh-web-app` 显然在正常工作。
163
+ * ⇒ 判据不可靠时**宁可不报**(返回空 + 说明),也绝不制造假警报。
164
+ * 生产环境(Electron 宿主)里锚点是可解析的 —— 面板那句"当前 0.2.1-rc.2"正是同一套解析出来的。
165
+ *
166
+ * @returns {{ items: Array<object>, reliable: boolean, note: string|null }}
167
+ */
168
+ function bundleVetoList({ bundleNames, profileDir, baseUrl = '', frameworkVersion } = {}) {
169
+ const list = Array.isArray(bundleNames) ? bundleNames : []
170
+ const empty = { items: [], reliable: true, note: null }
171
+ if (list.length === 0) return empty
172
+ if (typeof frameworkVersion !== 'string' || frameworkVersion === '') {
173
+ return { items: [], reliable: false, note: '拿不到运行时框架版本,无法判定 bundle 是否被跳过' }
174
+ }
175
+ const installAnchor = installAnchorFrom(baseUrl)
176
+ if (installAnchor === null) {
177
+ // 与框架的解析顺序不一致 ⇒ 我们的结论不可信 ⇒ 什么都不报(见上面的真机试算)。
178
+ return {
179
+ items: [],
180
+ reliable: false,
181
+ note: '解析不到 dsh 安装位置(安装锚点),无法复现框架的 bundle 解析顺序——本项判定已跳过,以免误报',
182
+ }
183
+ }
184
+ const exemptions = readCompatExemptions(profileDir)
185
+ const items = []
186
+ for (const name of list) {
187
+ try {
188
+ const hit = bundleVetoFor({ bundleName: name, profileDir, baseUrl, frameworkVersion, exemptions })
189
+ if (hit !== null) items.push(hit)
190
+ } catch {}
191
+ }
192
+ return { items, reliable: true, note: null }
193
+ }
194
+
195
+ export { COMPATIBILITY_FILE, readCompatExemptions, installAnchorFrom, resolveBundlePackageJson, bundleVetoFor, bundleVetoList }
@@ -81,8 +81,21 @@ function checkPluginFrameworkCompat(pkg, frameworkVersion, pkgDir = null) {
81
81
  return { decision: 'unknown', reason: '依赖范围包含当前框架版本但未显式声明兼容(版本已更新时放行)' }
82
82
  }
83
83
 
84
- // 支持 0.1.x 系列(0.1.0 / 0.1.1 等);0.2 / 1.0 等破坏性大版本才标记不支持
85
- const SUPPORTED_WEB_APP_PATTERN = /^0\.1\.\d+/u
84
+ // 受支持的 web 包系列:**0.1.x 与 0.2.x**。
85
+ //
86
+ // ★ 2026-10-15 加法(真机现场):这里原先只认 0.1.x(`/^0\.1\.\d+/`,注释写"0.2 / 1.0 等
87
+ // 破坏性大版本才标记不支持")。可框架已经走到 0.2.1-rc.2,于是面板长期挂着一条
88
+ // 「当前 DSH web 包版本 0.2.1-rc.2 不在受支持的 0.1.x 系列内…可能因官方破坏性更新而失效」的
89
+ // 红字警告 —— 而实际使用中控制台在 0.2.x 上工作正常(面板、安装、门控、看门狗都跑着)。
90
+ // 换句话说:**警告本身过期了**,它按"当初的预期"报警,而不是按"实测能不能用"。
91
+ // 所以把 0.2.x 正式纳入支持面。
92
+ //
93
+ // 边界(保持不变,别顺手放宽):
94
+ // · `supported === false` **只影响那句警告的显示**(`resolveNoticeState` / 客户端 noticeVisible),
95
+ // **不门控任何功能** —— 改这里不会改变任何门控语义(tests/test-compat-notice.mjs ⑥ 钉死了这点)。
96
+ // · 未验证的新系列(0.3.x / 1.0 …)仍要报警:警告的价值是"这个系列我没验证过",不是"越新越好"。
97
+ // 真要支持新系列,必须**先在这条线上实测过**再往这里加,别把警告改成永远不响。
98
+ const SUPPORTED_WEB_APP_PATTERN = /^0\.[12]\.\d+/u
86
99
 
87
100
  /**
88
101
  * 启用前冒烟检查(服务永不崩机制):在独立子进程中动态 import 插件主模块,
@@ -298,7 +311,7 @@ async function detectCompat(baseUrl) {
298
311
  }
299
312
  if (result.webAppVersion !== null && !SUPPORTED_WEB_APP_PATTERN.test(result.webAppVersion)) {
300
313
  result.supported = false
301
- result.notice = `当前 DSH web 包版本 ${result.webAppVersion} 不在受支持的 0.1.x 系列内,插件控制台的部分功能可能因官方破坏性更新而失效;请到 https://github.com/Noob-stupid/dsh-plugin-gating-hub 获取匹配的更新`
314
+ result.notice = `当前 DSH web 包版本 ${result.webAppVersion} 不在受支持的 0.1.x / 0.2.x 系列内,插件控制台的部分功能可能因官方破坏性更新而失效;请到 https://github.com/Noob-stupid/dsh-plugin-gating-hub 获取匹配的更新`
302
315
  }
303
316
  // 展示层(2026-09-28 加法):`supported` 与 `notice` 保持原样,这里只额外给出"该不该显示 + 为什么"。
304
317
  try {
@@ -0,0 +1,142 @@
1
+ // L1 · domain —— link 开发式安装的**清单与体检**(0.5.54 加法)
2
+ //
3
+ // 为什么需要(用户 2026-10-15 明确诉求 + 审计结论):
4
+ // 控制台**算得出**这些信息,却**从不展示**:
5
+ // · `planLinkSelfSufficiency` 会给出 target / ready / 缺哪些 dep 与 peer / 哪些 peer
6
+ // 解析到了**别的副本**(模块身份不共享)—— 但过去只用来判断"要不要修",结果一算就丢;
7
+ // · `listSelfSufficiencyRecords` 记着"我们造过哪些垫片",也没有任何生产调用方;
8
+ // · `persistReport` 能判"清单 + lock + 挂载行"三处是否齐备,只在安装作业里用一次。
9
+ // 于是开发者的真实处境是:**插件钉到本地目录了,但面板上它和普通插件长得一模一样** ——
10
+ // 看不出指向哪、目标还在不在、依赖齐不齐、改完代码到底加载的是哪一份。
11
+ //
12
+ // 本模块把这些**只读地聚合成一份清单**(纯读盘、零写盘、不联网、不 spawn):
13
+ // 每项 = 包名 + spec + 目标目录 + 目标版本 + 自足性明细 + 垫片账 → 供面板直接渲染。
14
+ //
15
+ // 边界(与既有模块同一套纪律):
16
+ // · 判据**全部复用**既有唯一真源(`planLinkSelfSufficiency` / `persistReport` /
17
+ // `listSelfSufficiencyRecords`),本模块**不新写**任何 lock / 清单 / 解析器;
18
+ // · 只处理**路径型来源**(`link:` / `file:`)—— 那是"开发式安装"的定义(见 source-spec 判据 C);
19
+ // · 任何单个包出问题都**不抛**,如实降级成该行的一个字段(一份清单不能因为一行坏掉就整体消失)。
20
+ //
21
+ // 分层:L1 domain —— 不认识 cordis ctx;home / profileDir / patchPath 全部由调用方解开后传入。
22
+ import { readFileSync } from 'node:fs'
23
+ import { join } from 'node:path'
24
+ import { canonicalSourceSpec, sourceSpecTarget } from './dep-source.js'
25
+ import { planLinkSelfSufficiency, listSelfSufficiencyRecords } from './link-self-sufficiency.js'
26
+ import { persistReport } from './persist.js'
27
+
28
+ const readJsonSafe = (file) => {
29
+ try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null }
30
+ }
31
+
32
+ /** 路径型来源(`link:` / `file:`)—— 只有这一族是"开发式安装",才有目标目录可体检。 */
33
+ const PATH_SPEC_RE = /^(?:link:|file:)/u
34
+
35
+ /** profile 清单里的依赖(只取路径型来源,保持清单原顺序)。 */
36
+ function pathDepsOf(profileDir) {
37
+ const manifest = readJsonSafe(join(profileDir, 'package.json'))
38
+ const deps = manifest?.dependencies
39
+ if (deps === null || typeof deps !== 'object') return []
40
+ const out = []
41
+ for (const [name, spec] of Object.entries(deps)) {
42
+ if (typeof spec !== 'string' || spec === '') continue
43
+ const canonical = canonicalSourceSpec(spec)
44
+ if (canonical === null || !PATH_SPEC_RE.test(canonical)) continue
45
+ out.push({ packageName: name, spec: canonical })
46
+ }
47
+ return out
48
+ }
49
+
50
+ /** 该包的垫片记录(按包名匹配;找不到返回 null)。 */
51
+ function shimRecordOf(records, packageName) {
52
+ for (const item of records) {
53
+ const rec = item?.record
54
+ if (rec && rec.packageName === packageName) return { file: item.file, shims: Array.isArray(rec.shims) ? rec.shims : [], updatedAt: rec.updatedAt ?? null, note: rec.note ?? null }
55
+ }
56
+ return null
57
+ }
58
+
59
+ /**
60
+ * 聚合一份 link 清单。
61
+ *
62
+ * @param {object} p
63
+ * @param {string} p.profileDir profile 根目录
64
+ * @param {string} [p.patchPath] 用户补丁路径(persistReport 判"挂载行"用;缺省则该项 skipped)
65
+ * @param {Array} [p.entries] loader 行(判"运行时已有行"用;缺省则由 persistReport 自行读盘)
66
+ * @param {string} [p.home] DSH 家目录(读垫片账用;缺省走 dshHome)
67
+ * @returns {{ items: Array<object>, count: number, readyCount: number }}
68
+ */
69
+ function buildLinkInventory({ profileDir, patchPath = null, entries = null, home = null } = {}) {
70
+ const deps = pathDepsOf(profileDir)
71
+ if (deps.length === 0) return { items: [], count: 0, readyCount: 0 }
72
+ const records = listSelfSufficiencyRecords({ home })
73
+ const items = []
74
+ for (const { packageName, spec } of deps) {
75
+ // 每一项都独立 try:一行坏掉不能让整份清单消失(体检报告的价值在于"看得到")
76
+ const item = {
77
+ packageName,
78
+ spec,
79
+ target: null,
80
+ targetExists: false,
81
+ version: null,
82
+ ready: null,
83
+ missingDeps: [],
84
+ missingPeers: [],
85
+ shimmablePeers: [],
86
+ unavailablePeers: [],
87
+ foreignPeers: [],
88
+ shims: null,
89
+ persisted: null,
90
+ note: null,
91
+ error: null,
92
+ }
93
+ try {
94
+ const plan = planLinkSelfSufficiency({ profileDir, packageName, spec, home })
95
+ item.target = plan.target ?? sourceSpecTarget(profileDir, spec)
96
+ item.targetExists = plan.targetExists === true
97
+ item.ready = plan.ready === true
98
+ item.missingDeps = plan.missingDeps ?? []
99
+ item.missingPeers = plan.missingPeers ?? []
100
+ item.shimmablePeers = plan.shimmablePeers ?? []
101
+ item.unavailablePeers = plan.unavailablePeers ?? []
102
+ // 「能解析」不等于「解析到宿主那一份」:声明过框架 peer 却解析到别处 ⇒ 模块身份不共享。
103
+ // 这条最隐蔽,必须让开发者看见(只报告、不动手)。
104
+ item.foreignPeers = plan.foreignPeers ?? []
105
+ item.note = plan.note ?? plan.reason ?? null
106
+ if (item.target !== null && item.targetExists) {
107
+ const pkg = readJsonSafe(join(item.target, 'package.json'))
108
+ item.version = typeof pkg?.version === 'string' ? pkg.version : null
109
+ }
110
+ } catch (error) {
111
+ item.error = error instanceof Error ? error.message : String(error)
112
+ }
113
+ try {
114
+ const rec = shimRecordOf(records, packageName)
115
+ item.shims = rec === null ? null : { count: rec.shims.length, items: rec.shims, updatedAt: rec.updatedAt, note: rec.note }
116
+ } catch {}
117
+ try {
118
+ if (typeof patchPath === 'string' && patchPath !== '') {
119
+ const report = persistReport({ profileDir, patchPath, packageName, entries })
120
+ item.persisted = { ok: report.persisted === true, missing: report.missing ?? [] }
121
+ }
122
+ } catch {}
123
+ items.push(item)
124
+ }
125
+ const readyCount = items.filter((x) => x.ready === true).length
126
+ return { items, count: items.length, readyCount }
127
+ }
128
+
129
+ /** 该路径是不是本 profile 里某个 `link:`/`file:` 依赖的**实际目标**(`repo-open` 的白名单判据)。
130
+ * 只认"清单里写着的那条",浏览器无法通过请求体扩大它 —— 因此不构成任意路径打开的越权。 */
131
+ function isLinkTargetOfProfile(profileDir, candidate) {
132
+ if (typeof candidate !== 'string' || candidate === '') return false
133
+ const norm = (s) => String(s).replace(/\\/gu, '/').replace(/\/+$/u, '').toLowerCase()
134
+ const want = norm(candidate)
135
+ for (const { spec } of pathDepsOf(profileDir)) {
136
+ const target = sourceSpecTarget(profileDir, spec)
137
+ if (target !== null && norm(target) === want) return true
138
+ }
139
+ return false
140
+ }
141
+
142
+ export { PATH_SPEC_RE, pathDepsOf, buildLinkInventory, isLinkTargetOfProfile }
@@ -93,10 +93,24 @@ function peerVetoFor({ moduleName, profileDir, frameworkVersion, deps = {} } = {
93
93
  }
94
94
  const peers = unsatisfiedPeers(manifest, frameworkVersion)
95
95
  if (peers.length === 0) return null
96
+ // ★ 2026-10-15 加法(真机现场):把"判的是哪一份副本"一并带回去。
97
+ // 为什么需要:桌面端存在**两份同名的包** ——
98
+ // · asar 内(框架自带,0.2.1-rc.2)
99
+ // · profile 内(实际加载的那份,0.2.0-rc.2)
100
+ // 而面板**显示版本**走 `resolvePackageJson(name, baseDirOf(baseUrl), profileDir)`(框架 base 优先),
101
+ // 本模块判定**只按 profileDir**;Electron 宿主里 asar 可解析,于是同一张卡片出现
102
+ // 「版本 0.2.1-rc.2」+「peer 不满足」自相矛盾(用户原话:插件本身版本是对的啊)。
103
+ // 两份基准各有道理(显示想反映"装了什么",判定必须反映"会加载哪一份"),
104
+ // 所以这里不擅自改基准,而是**把判定用的那一份也说出来**,让卡片能自解释。
105
+ const judged = {
106
+ version: typeof manifest.version === 'string' ? manifest.version : null,
107
+ path: pkgPath,
108
+ }
96
109
  return {
97
110
  kind: 'peers',
98
111
  reason: `被框架否决:peer 不满足(需要 ${describePeers(peers)},当前 ${frameworkVersion})`,
99
112
  hint: `出路:把 ${moduleName} 更新到兼容当前框架的版本(首选);若它是升级后残留的旧包,从 profile 里清掉;要保留这个精确版本,就得用框架的精确版本豁免(dsh plugin allow-version / 插件管理器 → profile 的 compatibility.json)。`,
113
+ judged,
100
114
  }
101
115
  }
102
116
 
@@ -43,4 +43,30 @@ function dropStaleUpdateCandidates(candidates, packageName, baseUrl, profileDir)
43
43
  return { latest: keep(src.latest), next: keep(src.next), beta: keep(src.beta) }
44
44
  }
45
45
 
46
- export { dropStaleUpdateCandidates, installedVersionOf }
46
+ /**
47
+ * 从(已净化的)候选里挑"这次更新该去哪个版本"。
48
+ *
49
+ * ★ 0.5.52 加法(真机现场 2026-10-15):面板对 `@deepseek-ai/*` 这类包**永远说"已是最新版本"**,
50
+ * 于是「不适配 → 禁用 → 更新 → 启用」这条既定流程**走不完**。根因不在候选上(服务端一直把
51
+ * `latest/next/beta` 三个都返回了),而在**没有任何一处在用 next**:
52
+ * · 框架那张卡片有语义(`pickFrameworkTarget`:latest 优先,latest 不高于当前而 next 更新时取 next),
53
+ * · 插件那张卡片却只认 `latest`;而 npm 上 `@deepseek-ai/*` 的 `dist-tags.latest`
54
+ * 是**远古版本**(本机实测 latest=0.1.6-alpha.1、next=0.2.1-rc.2),被
55
+ * `dropStaleUpdateCandidates` 按"低于已装"清成 null ⇒ 客户端看到 latest 为空 ⇒ 报"已是最新"。
56
+ * 本函数把框架那套语义搬到插件侧,保持**同一口径**:latest 优先,否则 next,再否则 beta。
57
+ * 安全性由调用顺序保证:只传**已净化**的候选进来(低于已装的已被清成 null),
58
+ * 所以"取第一个非空"天然等于"取一个确实更高、且 latest 优先"的版本。
59
+ *
60
+ * @param {{ latest?: string|null, next?: string|null, beta?: string|null }} candidates
61
+ * @returns {string|null} 目标版本;全空 ⇒ null(真的没有更高版本)
62
+ */
63
+ function pickUpdateTarget(candidates) {
64
+ const src = candidates !== null && typeof candidates === 'object' ? candidates : {}
65
+ for (const key of ['latest', 'next', 'beta']) {
66
+ const value = src[key]
67
+ if (typeof value === 'string' && value !== '') return value
68
+ }
69
+ return null
70
+ }
71
+
72
+ export { dropStaleUpdateCandidates, installedVersionOf, pickUpdateTarget }