@noob-stupid/dsh-plugin-console 0.5.51 → 0.5.55
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 +98 -6
- package/lib/server/domain/bundle-veto.js +195 -0
- package/lib/server/domain/compat.js +16 -3
- package/lib/server/domain/install-health.js +64 -0
- package/lib/server/domain/link-inventory.js +142 -0
- package/lib/server/domain/peer-veto.js +14 -0
- package/lib/server/domain/update-candidates.js +27 -1
- package/lib/server/infra/exec.js +5 -2
- package/lib/server/routes/compat.js +8 -0
- package/lib/server/routes/framework.js +2 -2
- package/lib/server/routes/index.js +3 -1
- package/lib/server/routes/state.js +68 -3
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -116,6 +116,12 @@ 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
|
+
healthSummary: "需要处理 {n} 类问题",
|
|
124
|
+
linksFailed: "开发式链接清单读取失败",
|
|
119
125
|
actionReconcileLabel: "重建 pnpm-lock.yaml",
|
|
120
126
|
actionRun: "执行",
|
|
121
127
|
actionCopy: "复制",
|
|
@@ -704,6 +710,12 @@ window.__ModuleLoader__.load({
|
|
|
704
710
|
actionBoxTitle: "Suggested action",
|
|
705
711
|
actionPinLabel: "Pin this dependency (record as link:)",
|
|
706
712
|
actionPersistLabel: "Pin it in one click (manifest + lock + mount row)",
|
|
713
|
+
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)",
|
|
714
|
+
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",
|
|
715
|
+
hostedNoChannel: "Hosted by the desktop shell: use the desktop app for framework upgrade and rollback (unavailable here)",
|
|
716
|
+
linksTitle: "Dev links (link:): {n} total, {ready} self-sufficient - checkmark = ready, dot = something missing (hover for why)",
|
|
717
|
+
healthSummary: "{n} issue type(s) need attention",
|
|
718
|
+
linksFailed: "Failed to read the dev-link inventory",
|
|
707
719
|
actionReconcileLabel: "Rebuild pnpm-lock.yaml",
|
|
708
720
|
actionRun: "Run",
|
|
709
721
|
actionCopy: "Copy",
|
|
@@ -2121,8 +2133,15 @@ window.__ModuleLoader__.load({
|
|
|
2121
2133
|
// 只要 /state 里已有 framework.version 与 rollback.from 就能判定,刷新页面即生效。
|
|
2122
2134
|
const rollbackInfo = state.status === "ready" ? state.data.rollback : null;
|
|
2123
2135
|
const curFwVersion = state.status === "ready" && typeof state.data.framework?.version === "string" ? state.data.framework.version : null;
|
|
2136
|
+
// ★ 0.5.53 加法(真机 2026-10-15):本实例是否由**桌面端外壳托管**。
|
|
2137
|
+
// 升级/回滚两条通道在外壳托管时会被服务端**拒绝**(409 + 那句"请在桌面端内重启/更新"),
|
|
2138
|
+
// 而面板过去照旧显示可点的「回滚到上一版」⇒ 点下去必然失败。
|
|
2139
|
+
// 现在按同一份事实(/state 的 host 字段)禁用并说明原因,而不是让人撞墙。
|
|
2140
|
+
const hostedByShell = state.status === "ready" && state.data.host?.hosted === true;
|
|
2141
|
+
const hostedNotice = hostedByShell ? (state.data.host?.notice ?? null) : null;
|
|
2124
2142
|
const rollbackUsable = rollbackInfo !== null && rollbackInfo !== undefined
|
|
2125
2143
|
&& rollbackInfo.applicable !== false
|
|
2144
|
+
&& hostedByShell !== true
|
|
2126
2145
|
&& !(curFwVersion !== null && rollbackInfo.from !== null && rollbackInfo.from !== undefined && curFwVersion === rollbackInfo.from);
|
|
2127
2146
|
// 升级步骤视图(卡片与常驻面板共用;失败时按 stage 标已完成步骤,不整列红叉)
|
|
2128
2147
|
const fwStepsView = (st) => {
|
|
@@ -3985,10 +4004,18 @@ window.__ModuleLoader__.load({
|
|
|
3985
4004
|
updateCheck !== null && updateCheck.status === "ready"
|
|
3986
4005
|
? (updateCheck.error
|
|
3987
4006
|
? el("p", { className: styles.status, "data-error": "true" }, t("updateCheckFailed") + ":" + updateCheck.error)
|
|
3988
|
-
|
|
4007
|
+
// 0.5.52 改错(真机 2026-10-15):这里原先只认 `latest`。
|
|
4008
|
+
// 而 npm 上 `@deepseek-ai/*` 的 `dist-tags.latest` 是**远古版本**,
|
|
4009
|
+
// 被服务端按"低于已装"净化成 null ⇒ 面板永远说「已是最新版本」,
|
|
4010
|
+
// 「不适配 → 禁用 → 更新 → 启用」这条流程**走不完**(用户原话:
|
|
4011
|
+
// "我们原本意思就是如果不适配先禁用,然后再更新适配启用吧")。
|
|
4012
|
+
// 服务端现在同时下发 `target`(latest 优先,其次 next/beta,
|
|
4013
|
+
// 与框架卡片 pickFrameworkTarget 同口径),这里改用它、并回落 latest
|
|
4014
|
+
// 以兼容老服务端(字段缺失时行为与旧版一字不差)。
|
|
4015
|
+
: ((updateCheck.target ?? updateCheck.latest) && meta !== null && meta.version && (updateCheck.target ?? updateCheck.latest) !== meta.version
|
|
3989
4016
|
? el("div", null,
|
|
3990
4017
|
el("div", { className: styles.rowTop },
|
|
3991
|
-
el("p", { className: styles.status }, t("updateAvailable") + ":" + meta.version + " → " + updateCheck.latest + (updateCheck.source === "github" ? "(GitHub release)" : "")),
|
|
4018
|
+
el("p", { className: styles.status }, t("updateAvailable") + ":" + meta.version + " → " + (updateCheck.target ?? updateCheck.latest) + (updateCheck.source === "github" ? "(GitHub release)" : "")),
|
|
3992
4019
|
el("button", {
|
|
3993
4020
|
type: "button",
|
|
3994
4021
|
className: styles.toggle,
|
|
@@ -4045,6 +4072,17 @@ window.__ModuleLoader__.load({
|
|
|
4045
4072
|
? el("p", { className: styles.status, "data-pending": "true", title: entry.veto.hint ?? undefined },
|
|
4046
4073
|
entry.veto.reason)
|
|
4047
4074
|
: null,
|
|
4075
|
+
// 0.5.52 加法(真机现场):否决判据用的那份副本,与面板显示的版本**可能不是同一个**。
|
|
4076
|
+
// 桌面端有 asar(框架自带)与 profile 两份同名包:显示版本走框架 base 优先,
|
|
4077
|
+
// 判据只按 profile ⇒ 卡片会自相矛盾(「版本 0.2.1-rc.2」+「peer 不满足」)。
|
|
4078
|
+
// 只在两者确实不一致时才多写一行,且如实给出判据那份的路径 —— 不猜、不掩盖。
|
|
4079
|
+
entry.veto && entry.veto.judged && entry.veto.judged.version
|
|
4080
|
+
&& entry.version && entry.veto.judged.version !== entry.version
|
|
4081
|
+
? el("p", { className: styles.status, title: entry.veto.judged.path ?? undefined },
|
|
4082
|
+
t("vetoJudgedCopy")
|
|
4083
|
+
.replace("{shown}", String(entry.version))
|
|
4084
|
+
.replace("{judged}", String(entry.veto.judged.version)))
|
|
4085
|
+
: null,
|
|
4048
4086
|
el("div", { className: styles.rowTop, style: { justifyContent: "space-between" } },
|
|
4049
4087
|
el("div", { className: styles.rowTop },
|
|
4050
4088
|
el("button", {
|
|
@@ -4551,6 +4589,31 @@ window.__ModuleLoader__.load({
|
|
|
4551
4589
|
}, t("compatNoticeDismiss")),
|
|
4552
4590
|
)
|
|
4553
4591
|
: null,
|
|
4592
|
+
// ★ 0.5.55 加法:**一行健康摘要** —— 面板有 176 个按钮、信息散在六处,
|
|
4593
|
+
// 过去没有任何地方能回答"整体健康吗、该先处理哪件"。这里把服务端已折好的
|
|
4594
|
+
// 分项计数摆在最上面(不新增判据、不新增 IO:全部来自 /state 已有的块)。
|
|
4595
|
+
state.status === "ready" && state.data.health && state.data.health.issues > 0
|
|
4596
|
+
&& Array.isArray(state.data.health.items)
|
|
4597
|
+
? el("p", { className: styles.message, "data-error": "true", style: { marginTop: "8px", marginBottom: "0" } },
|
|
4598
|
+
fill(t("healthSummary"), [["n", String(state.data.health.issues)]]) + ":"
|
|
4599
|
+
+ state.data.health.items.map((x) => x.label + " " + String(x.count)).join(" · "))
|
|
4600
|
+
: null,
|
|
4601
|
+
// ★ 0.5.53 加法(真机 2026-10-15):**被框架整层跳过的 bundle**。
|
|
4602
|
+
// 为什么必须放在这里(不是列表里):被跳过的 bundle **不产生任何 loader 行**,
|
|
4603
|
+
// 所以在插件列表里根本没有位置可挂 —— 用户看到的是"我明明有,却不显示"。
|
|
4604
|
+
// 服务端 skippedBundles.reliable === true 才渲染(判据不可靠时宁可不报,见 domain/bundle-veto.js)。
|
|
4605
|
+
state.status === "ready" && state.data.skippedBundles
|
|
4606
|
+
&& state.data.skippedBundles.reliable === true
|
|
4607
|
+
&& Array.isArray(state.data.skippedBundles.items)
|
|
4608
|
+
&& state.data.skippedBundles.items.length > 0
|
|
4609
|
+
? el("div", { className: styles.message, "data-error": "true", style: { marginTop: "8px" } },
|
|
4610
|
+
el("p", null,
|
|
4611
|
+
t("skippedBundlesTitle").replace("{n}", String(state.data.skippedBundles.items.length))),
|
|
4612
|
+
...state.data.skippedBundles.items.map((b) => el("p", { key: b.packageName, title: b.hint ?? undefined, style: { margin: "4px 0 0 12px" } },
|
|
4613
|
+
"• " + b.packageName + (b.version ? " v" + b.version : "") + " — " + b.reason)),
|
|
4614
|
+
)
|
|
4615
|
+
: null,
|
|
4616
|
+
|
|
4554
4617
|
state.status === "ready" && comps.length > 0 && details === null && repoInfo === null
|
|
4555
4618
|
? el("button", {
|
|
4556
4619
|
type: "button",
|
|
@@ -4698,7 +4761,10 @@ window.__ModuleLoader__.load({
|
|
|
4698
4761
|
el("div", { className: styles.rowTop },
|
|
4699
4762
|
el("button", { type: "button", className: styles.toggle, disabled: frameworkUpgrading, onClick: () => { setFwConfirmPanel(false); doFrameworkUpgrade(); } }, frameworkUpgrading ? t("installingLocal") : t("frameworkUpgradeConfirm")),
|
|
4700
4763
|
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)))
|
|
4764
|
+
: 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)))
|
|
4765
|
+
: null,
|
|
4766
|
+
hostedByShell
|
|
4767
|
+
? el("span", { className: styles.status }, hostedNotice ?? t("hostedNoChannel"))
|
|
4702
4768
|
: null,
|
|
4703
4769
|
rollbackUsable
|
|
4704
4770
|
? el("button", { type: "button", className: styles.toggle, title: t("frameworkRollbackTitle"), disabled: frameworkUpgrading, onClick: doFrameworkRollback }, t("frameworkRollbackBtn") + " → " + (rollbackInfo.from ?? "?"))
|
|
@@ -6054,9 +6120,15 @@ window.__ModuleLoader__.load({
|
|
|
6054
6120
|
const [backfill, setBackfill] = react.useState(null);
|
|
6055
6121
|
// 2026-09-27 加法:依赖锁体检结果(null = 还没体检过;不自动跑,用户点「体检」才打接口)
|
|
6056
6122
|
const [lockFix, setLockFix] = react.useState(null);
|
|
6123
|
+
// ★ 0.5.54 加法:link 开发式安装清单(/links,纯只读)。
|
|
6124
|
+
// 过去 link 钉住的插件与普通插件长得一模一样 —— 看不出指向哪、目标还在不在、
|
|
6125
|
+
// 依赖/peer 齐不齐、垫片还在不在。这份清单让"看见"先成立。
|
|
6126
|
+
const [links, setLinks] = react.useState(null);
|
|
6057
6127
|
const load = async () => {
|
|
6058
6128
|
try {
|
|
6059
|
-
|
|
6129
|
+
// link 清单:独立 try(拿不到时这块显示"读取失败",绝不静默)
|
|
6130
|
+
try { const lk = await call("/plugin-console/links"); if (lk !== null) setLinks(lk); } catch {}
|
|
6131
|
+
const data = await call("/plugin-console/compat-status");
|
|
6060
6132
|
if (data !== null) {
|
|
6061
6133
|
setStatus(data);
|
|
6062
6134
|
// 0.5.32 加法(D1/D4/D5):**瞬时提示**,沿用既有 note 通道(自动消失、无新组件、无按钮)。
|
|
@@ -6189,7 +6261,27 @@ window.__ModuleLoader__.load({
|
|
|
6189
6261
|
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
6262
|
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
6263
|
row("pin", t("safetyFrameworkTitle"), fill(t("safetyFrameworkDesc"), [["v", fw]])),
|
|
6192
|
-
|
|
6264
|
+
// ★ 0.5.53:外壳托管时回滚点是**点了会被拒**的(409),这里按同一份事实标成"不可用"
|
|
6265
|
+
// 并把原因说出来,而不是显示"可用"让人撞墙。
|
|
6266
|
+
row("back", t("safetyRollbackTitle"), status !== null && status?.host?.hosted === true
|
|
6267
|
+
? fill(t("safetyRollbackNone"), [["reason", status?.host?.notice ?? t("hostedNoChannel")]])
|
|
6268
|
+
: (rb.applicable === true ? fill(t("safetyRollbackOk"), [["from", rb.from], ["to", rb.to]]) : fill(t("safetyRollbackNone"), [["reason", rb.reason === null || rb.reason === undefined ? "-" : rb.reason]]))),
|
|
6269
|
+
// ★ 0.5.54 加法:开发式链接(link)清单 —— 每行给出目标、版本、自足性与垫片。
|
|
6270
|
+
// 判据全部来自服务端 /links(纯只读);这里只负责如实展示,不推断、不美化。
|
|
6271
|
+
links !== null && Array.isArray(links.items) && links.items.length > 0
|
|
6272
|
+
? 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))" } },
|
|
6273
|
+
el("div", { style: { fontSize: "12px", fontWeight: 500 } }, fill(t("linksTitle"), [["n", String(links.count ?? links.items.length)], ["ready", String(links.readyCount ?? 0)]])),
|
|
6274
|
+
...links.items.map((x) => el("div", { key: x.packageName, title: x.note ?? undefined, style: { fontSize: "12px", marginLeft: "10px", opacity: .85, wordBreak: "break-all" } },
|
|
6275
|
+
(x.ready === true ? "✓ " : "• ") + x.packageName + (x.version ? " v" + x.version : "")
|
|
6276
|
+
+ (x.target ? " → " + x.target : "")
|
|
6277
|
+
+ (Array.isArray(x.missingDeps) && x.missingDeps.length > 0 ? " · 缺依赖 " + x.missingDeps.length : "")
|
|
6278
|
+
+ (Array.isArray(x.missingPeers) && x.missingPeers.length > 0 ? " · 缺 peer " + x.missingPeers.length : "")
|
|
6279
|
+
+ (Array.isArray(x.foreignPeers) && x.foreignPeers.length > 0 ? " · 异副本 " + x.foreignPeers.length : "")
|
|
6280
|
+
+ (x.shims !== null && x.shims !== undefined ? " · 垫片 " + String(x.shims.count ?? 0) : "")
|
|
6281
|
+
+ (x.persisted !== null && x.persisted !== undefined && x.persisted.ok === false ? " · 未持久化" : ""))))
|
|
6282
|
+
: (links !== null && links.ok === false
|
|
6283
|
+
? el("div", { style: { fontSize: "12px", marginTop: "4px", opacity: .8 } }, t("linksFailed") + ":" + String(links.error ?? "-"))
|
|
6284
|
+
: null),
|
|
6193
6285
|
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
6286
|
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
6287
|
// 依赖锁体检 / 重建(2026-09-27 加法):体检只读;重建必须用户点(服务端也只认显式 POST)。
|
|
@@ -6278,4 +6370,4 @@ window.__ModuleLoader__.load({
|
|
|
6278
6370
|
exports.canAdaptUnlock = canAdaptUnlock;
|
|
6279
6371
|
return module.exports;
|
|
6280
6372
|
}
|
|
6281
|
-
});
|
|
6373
|
+
});
|
|
@@ -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
|
-
//
|
|
85
|
-
|
|
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,64 @@
|
|
|
1
|
+
// L1 · domain —— 安装健康摘要(0.5.55 加法)
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要(审计结论 + 用户 2026-10-15「优化到你能做到的最好」):
|
|
4
|
+
// 面板有 176 个按钮、3 个区块,信息散在「兼容门控 / peer 否决 / 持久化 / 依赖锁 / 框架残留 / 闭包预检」
|
|
5
|
+
// 六处。用户打开面板时**没有一个地方能回答**:"我这套安装现在整体健康吗?有哪几件事需要我处理?"
|
|
6
|
+
// 于是该点的找不到、不该点的全在眼前 —— 这不是功能不足,是**入口缺失**。
|
|
7
|
+
//
|
|
8
|
+
// 本模块刻意**只做折叠、不新增判据**:
|
|
9
|
+
// 输入全部是 /state 里**已经算好**的东西(否决行、被跳过的 bundle、待适配行、补丁自愈结果),
|
|
10
|
+
// 输出一句话摘要 + 分项计数。**不读盘、不联网、不 spawn** —— 因此可以放进 /state 的返回里,
|
|
11
|
+
// 不带来任何额外 IO 成本。
|
|
12
|
+
//
|
|
13
|
+
// 判据口径(与各自真源保持一致,别在这里另立标准):
|
|
14
|
+
// · veto ← entries[].veto(domain/peer-veto.js 的结论)
|
|
15
|
+
// · skippedBundle← skippedBundles(domain/bundle-veto.js 的结论;只在 reliable 时计入)
|
|
16
|
+
// · pendingCompat← compatPending.pending(兼容门控的待适配行)
|
|
17
|
+
// · patchHeal ← patchHeal.uncertain / autoDisabled(补丁自愈的遗留项)
|
|
18
|
+
//
|
|
19
|
+
// 分层:L1 domain —— 纯函数,收已解开的数组/对象(因此**不 import 任何其它模块**)。
|
|
20
|
+
|
|
21
|
+
/** 严重度:blocker 会让人"看不到/用不了",warn 只是"有风险"。 */
|
|
22
|
+
const SEVERITY = { blocker: 'blocker', warn: 'warn' }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 把已算好的各块折叠成一份健康摘要。
|
|
26
|
+
*
|
|
27
|
+
* @param {object} p
|
|
28
|
+
* @param {Array} [p.entries] /state 的 entries(含 veto)
|
|
29
|
+
* @param {{items?: Array, reliable?: boolean}} [p.skippedBundles] bundle 级否决结果
|
|
30
|
+
* @param {Array} [p.pendingCompat] 待适配行
|
|
31
|
+
* @param {Array} [p.patchUncertain] 补丁自愈里"说不准"的行 id
|
|
32
|
+
* @returns {{ issues: number, items: Array<{kind: string, severity: string, count: number, label: string}> }}
|
|
33
|
+
*/
|
|
34
|
+
function buildInstallHealth(input = {}) {
|
|
35
|
+
// 体检模块自己不能崩:null / undefined / 非对象一律当空输入(tests/test-install-health.mjs ⑥ 钉住)
|
|
36
|
+
const src = input !== null && typeof input === 'object' ? input : {}
|
|
37
|
+
const list = Array.isArray(src.entries) ? src.entries : []
|
|
38
|
+
const skippedBundles = src.skippedBundles ?? null
|
|
39
|
+
const pendingCompat = Array.isArray(src.pendingCompat) ? src.pendingCompat : []
|
|
40
|
+
const patchUncertain = Array.isArray(src.patchUncertain) ? src.patchUncertain : []
|
|
41
|
+
const items = []
|
|
42
|
+
|
|
43
|
+
// ① bundle 级:被框架**整层跳过** —— 最严重,因为它在列表里连位置都没有(用户看不到)
|
|
44
|
+
const skipped = skippedBundles !== null && skippedBundles !== undefined && skippedBundles.reliable === true && Array.isArray(skippedBundles.items)
|
|
45
|
+
? skippedBundles.items.length
|
|
46
|
+
: 0
|
|
47
|
+
if (skipped > 0) items.push({ kind: 'skipped-bundle', severity: SEVERITY.blocker, count: skipped, label: '被框架跳过的 bundle(装了但没装载,列表里看不到)' })
|
|
48
|
+
|
|
49
|
+
// ② 行级:被 peer 否决(可见但未加载)
|
|
50
|
+
const vetoed = list.filter((e) => e !== null && e !== undefined && e.veto !== null && e.veto !== undefined)
|
|
51
|
+
if (vetoed.length > 0) items.push({ kind: 'vetoed', severity: SEVERITY.blocker, count: vetoed.length, label: '被框架否决的插件行(未加载)' })
|
|
52
|
+
|
|
53
|
+
// ③ 待适配(兼容门控挂着的行)
|
|
54
|
+
const pending = Array.isArray(pendingCompat) ? pendingCompat.length : 0
|
|
55
|
+
if (pending > 0) items.push({ kind: 'pending-compat', severity: SEVERITY.warn, count: pending, label: '待适配确认的行' })
|
|
56
|
+
|
|
57
|
+
// ④ 补丁自愈里"说不准"的行(工具改过但无法确认是否它想要的)
|
|
58
|
+
const uncertain = Array.isArray(patchUncertain) ? patchUncertain.length : 0
|
|
59
|
+
if (uncertain > 0) items.push({ kind: 'patch-uncertain', severity: SEVERITY.warn, count: uncertain, label: '补丁自愈无法确认的行' })
|
|
60
|
+
|
|
61
|
+
return { issues: items.length, items }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { SEVERITY, buildInstallHealth }
|
|
@@ -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
|
-
|
|
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 }
|
package/lib/server/infra/exec.js
CHANGED
|
@@ -507,8 +507,11 @@ function pnpmFetchArgs(timeoutMs = 60000, retries = 1) {
|
|
|
507
507
|
* ★ 2026-10-14(实测更正 0.5.48 的理由陈述,**行为未动**):0.5.48 写"命中供应链闸 ⇒ 弹提问 ⇒
|
|
508
508
|
* 管道永不关闭 ⇒ 永久挂住",这条因果链实测**不成立**:pnpm 的
|
|
509
509
|
* `canPrompt = !inCi && Boolean(process.stdin.isTTY)`,管道下 isTTY 恒假 ⇒ pnpm 从不 prompt,
|
|
510
|
-
* 五种 ci
|
|
511
|
-
*
|
|
510
|
+
* 五种 ci 配置实测全部快速失败(无一挂住)。
|
|
511
|
+
* ⚠️ 本节**不指定真凶**:那"挂到 90s/180s"是我们**自己的超时常量**(`runPnpmAdd` timeout=90000、
|
|
512
|
+
* `installTargetDependencies` timeoutMs=180000),与当时观察逐一吻合 ⇒ 是"超过我们的超时被杀";
|
|
513
|
+
* 而 corepack 的下载提问**默认是关的**(入口 shim `COREPACK_ENABLE_DOWNLOAD_PROMPT ??= '0'`)。
|
|
514
|
+
* **根因至今未查明**(最可能是网络)—— 别再把 corepack(或 pnpm)当成已证实的结论。
|
|
512
515
|
* 所以这一位的真实价值是:**把 ci 位钉死**(与 `CI=true` 同向、但不受 env 被覆盖影响),
|
|
513
516
|
* 并让 install 类命令在 lock 过时走 frozen 拒绝而非静默改写。`pnpm add` 不受 frozen 影响
|
|
514
517
|
* (实测:带/不带此旗,add 都照常装上并更新 lock)⇒ 不会把安装变成拒绝。
|
|
@@ -262,6 +262,14 @@ async function routeCompatStatusGet(req, res, rc) {
|
|
|
262
262
|
frameworkVersion: current.frameworkVersion,
|
|
263
263
|
fingerprint: { current, previous, changed, reasons, baselineAt },
|
|
264
264
|
rollback: readRollbackRecord(),
|
|
265
|
+
// ★ 0.5.53 加法:外壳托管时升级/回滚通道会被服务端拒绝(409),面板据此把回滚点标成"不可用"
|
|
266
|
+
// 并说明原因,而不是显示"可用"却点了必然失败。判据与 upgrade/rollback 路由共用 detectHostShape。
|
|
267
|
+
host: (() => {
|
|
268
|
+
try {
|
|
269
|
+
const shape = detectHostShape()
|
|
270
|
+
return { hosted: shape.hosted === true, kind: shape.kind, notice: shape.hosted === true ? shape.notice : null }
|
|
271
|
+
} catch { return { hosted: false, kind: 'standalone', notice: null } }
|
|
272
|
+
})(),
|
|
265
273
|
// D1 加法字段:自动预检的状态/结论(面板只放一句短句,长文进详情)
|
|
266
274
|
autoPreflight: { ...autoPreflight, scheduled: autoRun !== null, scheduledAt: autoRun === null ? null : Date.now(), timeoutMs: autoRun?.timeoutMs ?? preflightTimeoutMs() },
|
|
267
275
|
// D5 加法字段:自动禁用的开关状态 + **本次一次性提示**(客户端走既有瞬时 note 通道;
|
|
@@ -16,7 +16,7 @@ import { fetchJsonUrl } from '../infra/http.js'
|
|
|
16
16
|
import { sendError, sendJson } from '../infra/httpd.js'
|
|
17
17
|
import { dshHome, entryPkgMeta, findPatchPath, packageNameOf, pluginRoot, profileDirOf, resolvePackageJson } from '../infra/paths.js'
|
|
18
18
|
import { isFrameworkVersionNewer, semverRangeMatchLoose, frameworkUpgradeCandidates } from '../infra/semver.js'
|
|
19
|
-
import { dropStaleUpdateCandidates } from '../domain/update-candidates.js'
|
|
19
|
+
import { dropStaleUpdateCandidates, pickUpdateTarget } from '../domain/update-candidates.js'
|
|
20
20
|
import { fwCheckCache, setFwCheckCache } from '../state.js'
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -337,7 +337,7 @@ async function routeCheckUpdate(req, res, rc) {
|
|
|
337
337
|
} catch {}
|
|
338
338
|
// 兜底(0.5.37):低于已装版本的候选不是"新版"(真机:镜像 latest=0.1.1-rc.1 < 已装 0.2.0-rc.2)
|
|
339
339
|
;({ latest, next, beta } = dropStaleUpdateCandidates({ latest, next, beta }, packageName, ctx.baseUrl ?? 'file:///', profileDirOf(ctx)))
|
|
340
|
-
sendJson(res, 200, { ok: true, packageName, latest, next, beta, depsOutdated, error, source, migrate })
|
|
340
|
+
sendJson(res, 200, { ok: true, packageName, latest, next, beta, target: pickUpdateTarget({ latest, next, beta }), depsOutdated, error, source, migrate })
|
|
341
341
|
return
|
|
342
342
|
}
|
|
343
343
|
|
|
@@ -30,12 +30,14 @@ import { routeEnrich, routeMarketIndex, routeRepo, routeSearch, routeSubpackages
|
|
|
30
30
|
import { routeAdaptUnlock, routeAdaptUnlockAll, routeCleanResiduals, routeSelfUpdate, routeToggle, routeUninstall } from './plugins.js'
|
|
31
31
|
import { routeSkillRemove, routeSkillToggle, routeSkillsInstalledGet } from './skills.js'
|
|
32
32
|
import { routeGiteeOauthCallbackGet, routeGiteeOauthUrlGet, routeRegistryScan, routeSources, routeSourcesGet } from './sources.js'
|
|
33
|
-
import { routeDetails, routeStateGet } from './state.js'
|
|
33
|
+
import { routeDetails, routeLinksGet, routeStateGet } from './state.js'
|
|
34
34
|
|
|
35
35
|
const ROUTE_PREFIX = '/plugin-console'
|
|
36
36
|
|
|
37
37
|
const ROUTES_EARLY = [
|
|
38
38
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/state`, handler: routeStateGet },
|
|
39
|
+
// link 开发式安装清单(0.5.54 加法):纯只读,供面板渲染"已挂载 / 自足性 / 垫片 / 持久化"
|
|
40
|
+
{ methods: ['GET'], path: `${ROUTE_PREFIX}/links`, handler: routeLinksGet },
|
|
39
41
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/sources`, handler: routeSourcesGet },
|
|
40
42
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-url`, handler: routeGiteeOauthUrlGet },
|
|
41
43
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-callback`, handler: routeGiteeOauthCallbackGet },
|
|
@@ -5,9 +5,13 @@ import { readFileSync, existsSync } from 'node:fs'
|
|
|
5
5
|
import { dirname, join } from 'node:path'
|
|
6
6
|
import { detectAdoptablePending, detectCompat, gatingSummary, readCompatGate, readCompatPendingWithVerdicts } from '../domain/compat.js'
|
|
7
7
|
import { compUiUrl, findComponents } from '../domain/components.js'
|
|
8
|
-
import { currentFrameworkVersion, detectFrameworkUpgrade } from '../domain/framework.js'
|
|
8
|
+
import { currentFrameworkVersion, detectFrameworkUpgrade, detectHostShape } from '../domain/framework.js'
|
|
9
9
|
import { peerVetoFor } from '../domain/peer-veto.js'
|
|
10
10
|
import { readExtraBundleRows } from '../domain/install-job.js'
|
|
11
|
+
import { bundleVetoList } from '../domain/bundle-veto.js'
|
|
12
|
+
import { buildLinkInventory } from '../domain/link-inventory.js'
|
|
13
|
+
import { buildInstallHealth } from '../domain/install-health.js'
|
|
14
|
+
import { readBundlesSync } from '../domain/persist.js'
|
|
11
15
|
import { failedJobEvidence } from '../domain/install-verify.js'
|
|
12
16
|
import { installJobView, readGithubAuth } from '../domain/install.js'
|
|
13
17
|
import { readPluginDetails } from '../domain/market.js'
|
|
@@ -128,7 +132,44 @@ async function routeStateGet(req, res, rc) {
|
|
|
128
132
|
const selfPkg = JSON.parse(readFileSync(join(pluginRoot(), 'package.json'), 'utf8'))
|
|
129
133
|
selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
|
|
130
134
|
} catch {}
|
|
131
|
-
|
|
135
|
+
// ★ 0.5.53 加法(真机 2026-10-15):**被框架整层跳过的 bundle**。
|
|
136
|
+
// 症状:插件装了、在 dsh.profile.bundles 里,但面板上连"未加载"都不显示(用户原话:
|
|
137
|
+
// "为啥有的插件我本身是有的,但是没显示呢")。原因是框架在**装载 bundle 时**就因 peer 不满足
|
|
138
|
+
// throw,整个 bundle 层进 skippedBundles,它要插的行根本不存在 —— 而面板 enumerate 的是 loader 行。
|
|
139
|
+
// 这里复算同一判据(纯只读),把它们如实列出来;绝不改动任何东西。
|
|
140
|
+
let skippedBundles = { items: [], reliable: true, note: null }
|
|
141
|
+
try {
|
|
142
|
+
skippedBundles = bundleVetoList({
|
|
143
|
+
bundleNames: readBundlesSync(profileDir),
|
|
144
|
+
profileDir,
|
|
145
|
+
baseUrl: ctx.baseUrl ?? 'file:///',
|
|
146
|
+
frameworkVersion: vetoFrameworkVersion,
|
|
147
|
+
})
|
|
148
|
+
} catch {}
|
|
149
|
+
// ★ 0.5.53 加法(真机 2026-10-15):把「本实例是否由桌面端外壳托管」告诉面板。
|
|
150
|
+
// 为什么必须下发:一键升级 / 一键回滚这两条通道在**外壳托管时会被服务端拒绝**
|
|
151
|
+
// (409 + 「本实例由桌面端外壳托管,请在桌面端内重启/更新」)—— 那是一份历史事故的修补:
|
|
152
|
+
// 回滚脚本曾按端口 Stop-Process 杀掉 Electron 承载的 host,重新拉起时又找不到 bin.js,
|
|
153
|
+
// 于是外壳记了一次 crash。拒绝本身是对的,但面板**过去照旧显示一个可点的「回滚到上一版」**,
|
|
154
|
+
// 点下去必然 409 —— 典型的"按钮存在但永远失败"。这里把同一份事实(detectHostShape)
|
|
155
|
+
// 下发给面板,让按钮按事实禁用并说明原因,而不是让人撞墙。
|
|
156
|
+
let host = { hosted: false, kind: 'standalone', notice: null }
|
|
157
|
+
try {
|
|
158
|
+
const shape = detectHostShape()
|
|
159
|
+
host = { hosted: shape.hosted === true, kind: shape.kind, notice: shape.hosted === true ? shape.notice : null }
|
|
160
|
+
} catch {}
|
|
161
|
+
// ★ 0.5.55 加法:把已经算好的各块**折成一行健康摘要**(不新增 IO、不新增判据)。
|
|
162
|
+
// 面板 176 个按钮、信息散在六处,用户没有一个地方能回答"整体健康吗、我该先处理哪件"。
|
|
163
|
+
let health = { issues: 0, items: [] }
|
|
164
|
+
try {
|
|
165
|
+
health = buildInstallHealth({
|
|
166
|
+
entries,
|
|
167
|
+
skippedBundles,
|
|
168
|
+
pendingCompat: compatPending?.pending ?? [],
|
|
169
|
+
patchUncertain: patchHeal === null ? [] : (patchHeal.uncertain ?? []),
|
|
170
|
+
})
|
|
171
|
+
} catch {}
|
|
172
|
+
sendJson(res, 200, { ok: true, entries, skippedBundles, host, health, patchPath, compat, installJobs: jobs, recentFailures, pendingRestart, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0, written: patchHeal.written === true, writeError: patchHeal.writeError ?? null, skippedFrameworkOwned: (patchHeal.skipped ?? []).map((s) => s.id ?? null), uncertain: (patchHeal.uncertain ?? []).map((u) => u.id ?? null) }, gating: gatingSummary(ctx, compatPending, entries, compatVerdicts), compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null, checkNote: p.checkNote ?? null, check: p.check ?? null, riskyApprovedAt: p.riskyApprovedAt ?? null, adoptable: adoptable.get(p.rowId) ?? null, verdict: compatVerdicts.get(p.rowId) ?? null })), verdictRefresh: pendingState.refresh === null ? null : { version: pendingState.refresh.version ?? null, updated: pendingState.refresh.updated ?? [], unchanged: pendingState.refresh.unchanged ?? [], unresolved: pendingState.refresh.unresolved ?? [], wrote: pendingState.refresh.wrote === true, writeError: pendingState.refresh.writeError ?? null } }, compatGate, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
|
|
132
173
|
return
|
|
133
174
|
}
|
|
134
175
|
|
|
@@ -159,4 +200,28 @@ async function routeDetails(req, res, rc) {
|
|
|
159
200
|
return
|
|
160
201
|
}
|
|
161
202
|
|
|
162
|
-
|
|
203
|
+
/**
|
|
204
|
+
* GET /links —— **link 开发式安装清单**(0.5.54 加法,纯只读)。
|
|
205
|
+
*
|
|
206
|
+
* 用户 2026-10-15 的原话:「这主要是对标他们本地开发插件 link 挂载钉住」。
|
|
207
|
+
* 过去面板上 link 钉住的插件与普通插件长得一模一样:看不出指向哪、目标还在不在、
|
|
208
|
+
* 依赖/peer 齐不齐、改完代码加载的是哪一份。本路由把它们聚合成一份清单,让"看见"先成立。
|
|
209
|
+
*
|
|
210
|
+
* 判据全部复用既有唯一真源(domain/link-inventory.js → planLinkSelfSufficiency / persistReport /
|
|
211
|
+
* listSelfSufficiencyRecords),**不新增任何写盘、不 spawn、不联网**。
|
|
212
|
+
*/
|
|
213
|
+
async function routeLinksGet(req, res, rc) {
|
|
214
|
+
const ctx = rc.ctx
|
|
215
|
+
let payload = { ok: true, items: [], count: 0, readyCount: 0, error: null }
|
|
216
|
+
try {
|
|
217
|
+
const patchPath = findPatchPath(ctx)
|
|
218
|
+
const profileDir = dirname(patchPath)
|
|
219
|
+
payload = { ok: true, ...buildLinkInventory({ profileDir, patchPath, entries: listEntries(ctx) }), error: null }
|
|
220
|
+
} catch (error) {
|
|
221
|
+
// 体检失败要如实说,不能假装"没有 link 依赖"
|
|
222
|
+
payload = { ok: false, items: [], count: 0, readyCount: 0, error: error instanceof Error ? error.message : String(error) }
|
|
223
|
+
}
|
|
224
|
+
sendJson(res, 200, payload)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export { routeStateGet, routeDetails, routeLinksGet }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noob-stupid/dsh-plugin-console",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.55",
|
|
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",
|