@noob-stupid/dsh-plugin-console 0.3.65 → 0.3.67

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
@@ -336,4 +336,4 @@ The layered-refactor preview lives in its **own repository**: [**`Noob-stupid/ds
336
336
  - **What it is**: the monolithic `lib/index.js` (8547 lines) split into layers — `lib/index.js` **141 lines** + `lib/server/**` **38 modules**; **feature-equivalent to `0.3.57`**, maintainability only;
337
337
  - **Verified**: 19 test suites green · 8 architecture-guard assertions · **route inventory identical** (`status` + response fields) against the stable build;
338
338
  - **Not yet exercised**: real framework upgrade / rollback, restart guardian, component start/stop, real AI run, Gitee OAuth (~10–25% long-tail risk, subjective);
339
- - **For everyday use** stick to npm `@noob-stupid/dsh-plugin-console@0.3.65`, or `main` of this repo.
339
+ - **For everyday use** stick to npm `@noob-stupid/dsh-plugin-console@0.3.67`, or `main` of this repo.
package/README.zh.md CHANGED
@@ -300,5 +300,5 @@ MIT
300
300
  - **已验证**:19 套测试全绿 · 8 条架构守卫断言 · 与稳定版逐条对打**路由清单一致**(`status` + 响应字段);
301
301
  - **已实测**(2026-09-20 真装真卸演练):普通插件 / bundle 插件 / 无 npm 仓库 / 套装 / 技能 / 聚合仓库子包 / 仓库落地 / 服务器组件启停;
302
302
  - **尚未实测**:真框架升级 / 真回滚、重启守护链路、AI 真跑、Gitee OAuth 回调(长尾风险主观估计 **10%~25%**);
303
- - **日常使用请继续用**:npm `@noob-stupid/dsh-plugin-console@0.3.65`,或本仓库 `main`。
303
+ - **日常使用请继续用**:npm `@noob-stupid/dsh-plugin-console@0.3.67`,或本仓库 `main`。
304
304
  - 请多反馈问题
package/lib/client.js CHANGED
@@ -143,6 +143,15 @@ window.__ModuleLoader__.load({
143
143
  gateModalTitle: "门控(兼容门总开关)",
144
144
  gatePendingInfo: "当前待适配:{n} 行(升级预扫或启动失败隔离记下的,更新插件后可一键解锁)",
145
145
  gatePendingNone: "当前没有待适配的行",
146
+ gateListPending: "待适配明细({n} 行)",
147
+ gateListAdopted: "已适配解锁({n} 行,历史上被拦过、现已放行)",
148
+ gateListMore: "展开全部 {n} 行",
149
+ gateListFold: "收起列表",
150
+ gateRowReason: "原因:{reason}",
151
+ gateRowFromBoot: "来源:启动失败隔离",
152
+ gateRowFromUpgrade: "来源:框架升级预扫",
153
+ gateRowAt: " · {t}",
154
+ gateRowAdoptable: " · 已通过源码扫描,可解锁",
146
155
  fwFailedButUpgraded: "框架本体其实已经升到 {v} 并已生效——失败的只是脚本最后「重启服务」那一步。可点「重启服务」确认,或必要时回滚。",
147
156
  fwPanelBtn: "框架",
148
157
  fwPanelTitle: "框架升级 / 回滚",
@@ -463,6 +472,12 @@ window.__ModuleLoader__.load({
463
472
  frameworkPatchSkipped: "无需应用",
464
473
  frameworkUpgradeBtn: "框架升级",
465
474
  frameworkUpgradeTitle: "框架升级:自动打包备份现有配置 → 停止并重启 DSH 服务 → npm 升级框架与配套包(失败自动回滚)→ 拉起服务并适配",
475
+ frameworkUpgradeTarget: "升级目标",
476
+ frameworkUpgradePick: "选择要升级到的版本(列出所有比当前新的版本,含预发布;latest 为稳定版)",
477
+ frameworkUpgradeMore: "展开全部可选版本({n})",
478
+ frameworkUpgradeFold: "收起版本列表",
479
+ frameworkUpgradeChannelStable: "稳定版",
480
+ frameworkUpgradeChannelPre: "预发布",
466
481
  frameworkUpgradeConfirm: "确认升级?(服务将自动重启)",
467
482
  frameworkUpgradeDone: "框架升级流程已执行",
468
483
  frameworkRollbackBtn: "回滚到上一版",
@@ -540,6 +555,15 @@ window.__ModuleLoader__.load({
540
555
  gateModalTitle: "Gating (compat gate master switches)",
541
556
  gatePendingInfo: "Pending adaptation: {n} row(s) — recorded by the upgrade pre-scan or boot quarantine; unlock them after updating the plugin",
542
557
  gatePendingNone: "No rows pending adaptation",
558
+ gateListPending: "Pending rows ({n})",
559
+ gateListAdopted: "Adapted & unlocked ({n} — blocked at some point, now allowed)",
560
+ gateListMore: "Show all {n} rows",
561
+ gateListFold: "Collapse list",
562
+ gateRowReason: "Reason: {reason}",
563
+ gateRowFromBoot: "Source: boot-failure quarantine",
564
+ gateRowFromUpgrade: "Source: framework upgrade pre-scan",
565
+ gateRowAt: " · {t}",
566
+ gateRowAdoptable: " · passed the source scan, can be unlocked",
543
567
  fwFailedButUpgraded: "The framework itself is already on {v} and active — only the final \"restart service\" step failed. Use Restart service to confirm, or roll back if needed.",
544
568
  fwPanelBtn: "Framework",
545
569
  fwPanelTitle: "Framework upgrade / rollback",
@@ -860,6 +884,12 @@ window.__ModuleLoader__.load({
860
884
  frameworkPatchSkipped: "not needed",
861
885
  frameworkUpgradeBtn: "Framework upgrade",
862
886
  frameworkUpgradeTitle: "Framework upgrade: backup config → stop & restart DSH service → npm upgrade framework + official packages (auto rollback on failure) → relaunch and adapt",
887
+ frameworkUpgradeTarget: "Upgrade target",
888
+ frameworkUpgradePick: "Pick a version to upgrade to (every version newer than the current one, pre-releases included; latest = stable channel)",
889
+ frameworkUpgradeMore: "Show all available versions ({n})",
890
+ frameworkUpgradeFold: "Collapse version list",
891
+ frameworkUpgradeChannelStable: "stable",
892
+ frameworkUpgradeChannelPre: "pre-release",
863
893
  frameworkUpgradeConfirm: "Confirm upgrade? (service will restart)",
864
894
  frameworkUpgradeDone: "Framework upgrade flow executed",
865
895
  frameworkUpgradeUpToDate: " (already latest)",
@@ -1353,6 +1383,15 @@ window.__ModuleLoader__.load({
1353
1383
  const [fwCheck, setFwCheck] = react.useState(null);
1354
1384
  const [fwCheckBusy, setFwCheckBusy] = react.useState(false);
1355
1385
  const [fwConfirmPanel, setFwConfirmPanel] = react.useState(false);
1386
+ // 可选升级版本列表(用户 2026-09-23 要求):默认收起;选中项优先用户选择 → latest → 原 target
1387
+ const [fwPicking, setFwPicking] = react.useState(false);
1388
+ const [fwPickAll, setFwPickAll] = react.useState(false);
1389
+ const [fwSelectedVersion, setFwSelectedVersion] = react.useState(null);
1390
+ const fwVersions = Array.isArray(fwCheck && fwCheck.versions) ? fwCheck.versions : [];
1391
+ const fwSelected = fwSelectedVersion !== null && fwVersions.some((v) => v.version === fwSelectedVersion)
1392
+ ? fwSelectedVersion
1393
+ : (fwCheck && fwCheck.tagDefault) || (fwCheck && fwCheck.target) || (fwVersions[0] ? fwVersions[0].version : null);
1394
+ const fwChannelLabel = (v) => (v && v.isLatest === true ? t("frameworkUpgradeChannelStable") + " · latest" : (v && typeof v.channel === "string" && v.channel !== "" ? t("frameworkUpgradeChannelPre") + " · " + v.channel : t("frameworkUpgradeChannelPre")));
1356
1395
  // 面板里的状态刷新:**无视**「终态已关闭」标记——卡片可以被永久关掉,面板必须永远能查到真相
1357
1396
  const fwStatusRefresh = () => {
1358
1397
  call("/plugin-console/framework-upgrade-status").then(
@@ -2840,7 +2879,8 @@ window.__ModuleLoader__.load({
2840
2879
  /** 框架升级:Hub 打包备份现有配置 + 版本检测 + 升级脚本(脚本会自行停止/拉起服务,无需手动重启)。 */
2841
2880
  const doFrameworkUpgrade = () => {
2842
2881
  setFrameworkUpgrading(true);
2843
- call("/plugin-console/framework-upgrade", {}).then(
2882
+ // 用户选了版本就带上(宿主端按候选列表校验:只接受比当前新的真实版本)
2883
+ call("/plugin-console/framework-upgrade", fwSelected ? { version: fwSelected } : {}).then(
2844
2884
  (data) => {
2845
2885
  setFrameworkUpgrading(false);
2846
2886
  setConfirmFrameworkUpgrade(false);
@@ -3515,6 +3555,10 @@ window.__ModuleLoader__.load({
3515
3555
  const gateAutoDisable = !gateReady || (state.data.compatGate ?? {}).autoDisable !== false;
3516
3556
  const gateAutoDetect = !gateReady || (state.data.compatGate ?? {}).autoDetect !== false;
3517
3557
  const gatePendingCount = gateReady ? (state.data.compatPending?.pending ?? []).length : 0;
3558
+ // 门控明细清单(用户 2026-09-24 要求:面板要显示"是哪些插件",不只是数量)
3559
+ const [gateShowAll, setGateShowAll] = react.useState(false);
3560
+ const gateRows = gateReady ? (state.data.gating ?? null) : null;
3561
+ const gateTime = (ms) => (typeof ms === "number" && ms > 0 ? new Date(ms).toLocaleString() : "—");
3518
3562
  // 「功能包 → 框架」按钮角标:升级进行中 ⟳ / 上次失败 ✕ / 有可用更新 ↑(优先级从高到低)
3519
3563
  const fwRunning = frameworkStatus !== null
3520
3564
  && frameworkStatus.status !== "idle" && frameworkStatus.status !== "done" && frameworkStatus.status !== "failed";
@@ -3584,10 +3628,25 @@ window.__ModuleLoader__.load({
3584
3628
  fwCheck === null
3585
3629
  ? null
3586
3630
  : fwCheck.target
3587
- ? el("p", { className: styles.status, "data-pending": "true" }, t("fwPanelAvailable").replace("{v}", fwCheck.target) + "(latest " + (fwCheck.latest ?? "—") + " · next " + (fwCheck.next ?? "—") + ")")
3631
+ ? el("p", { className: styles.status, "data-pending": "true" }, t("fwPanelAvailable").replace("{v}", fwSelected ?? fwCheck.target) + "(latest " + (fwCheck.latest ?? "—") + " · next " + (fwCheck.next ?? "—") + (fwCheck.alpha ? " · alpha " + fwCheck.alpha : "") + ")")
3588
3632
  : fwCheck.registryError
3589
3633
  ? el("p", { className: styles.message, "data-error": "true" }, t("fwPanelCheckFailed").replace("{err}", fwCheck.registryError))
3590
3634
  : el("p", { className: styles.status }, t("fwPanelUpToDate").replace("{latest}", fwCheck.latest ?? "—").replace("{next}", fwCheck.next ?? "—")),
3635
+ el("p", { className: styles.status }, t("frameworkUpgradePick")),
3636
+ fwPicking
3637
+ ? el("div", { style: { flexDirection: "column", gap: 6, display: "flex", flexBasis: "100%" } },
3638
+ (fwPickAll ? fwVersions : fwVersions.slice(0, 6)).map((v) => el("button", {
3639
+ key: v.version, type: "button", className: styles.toggle, disabled: frameworkUpgrading,
3640
+ title: v.version + "(" + fwChannelLabel(v) + ")",
3641
+ style: { justifyContent: "space-between", display: "flex", width: "100%" },
3642
+ "data-active": v.version === fwSelected ? "true" : undefined,
3643
+ onClick: () => { setFwSelectedVersion(v.version); setFwPicking(false); },
3644
+ }, el("span", null, (v.version === fwSelected ? "● " : "○ ") + v.version), el("span", { className: styles.status }, fwChannelLabel(v)))),
3645
+ fwVersions.length > 6
3646
+ ? el("button", { type: "button", className: styles.toggle, onClick: () => setFwPickAll((x) => !x) }, fwPickAll ? t("frameworkUpgradeFold") : t("frameworkUpgradeMore").replace("{n}", String(fwVersions.length)))
3647
+ : null)
3648
+ : null,
3649
+ el("button", { type: "button", className: styles.toggle, disabled: frameworkUpgrading || fwVersions.length === 0, title: t("frameworkUpgradePick"), "data-active": fwPicking ? "true" : undefined, onClick: () => setFwPicking((x) => !x) }, t("frameworkUpgradeTarget") + ":" + (fwSelected ?? "—") + (fwPicking ? " ▴" : " ▾")),
3591
3650
  // ② 上次/当前升级记录(卡片可被永久关闭,这里是常驻的真相入口)
3592
3651
  frameworkStatus !== null && frameworkStatus.status !== "idle"
3593
3652
  ? el("div", { style: { flexDirection: "column", gap: 8, display: "flex" } },
@@ -3613,7 +3672,7 @@ window.__ModuleLoader__.load({
3613
3672
  el("div", { className: styles.rowTop },
3614
3673
  el("button", { type: "button", className: styles.toggle, disabled: frameworkUpgrading, onClick: () => { setFwConfirmPanel(false); doFrameworkUpgrade(); } }, frameworkUpgrading ? t("installingLocal") : t("frameworkUpgradeConfirm")),
3615
3674
  el("button", { type: "button", className: styles.toggle, onClick: () => setFwConfirmPanel(false) }, t("closeModal"))))
3616
- : el("button", { type: "button", className: styles.toggle, disabled: frameworkUpgrading, title: t("frameworkUpgradeTitle"), onClick: () => setFwConfirmPanel(true) }, t("frameworkUpgradeBtn") + " → v" + fwCheck.target))
3675
+ : el("button", { type: "button", className: styles.toggle, disabled: frameworkUpgrading, title: t("frameworkUpgradeTitle"), onClick: () => setFwConfirmPanel(true) }, t("frameworkUpgradeBtn") + " → v" + (fwSelected ?? fwCheck.target)))
3617
3676
  : null,
3618
3677
  rollbackUsable
3619
3678
  ? el("button", { type: "button", className: styles.toggle, title: t("frameworkRollbackTitle"), disabled: frameworkUpgrading, onClick: doFrameworkRollback }, t("frameworkRollbackBtn") + " → " + (rollbackInfo.from ?? "?"))
@@ -3656,7 +3715,35 @@ window.__ModuleLoader__.load({
3656
3715
  }, el("div", { className: styles.pcSwitchKnob }))),
3657
3716
  el("p", { className: styles.message }, t("compatGateHint")),
3658
3717
  el("p", { className: styles.status, "data-pending": gatePendingCount > 0 ? "true" : undefined },
3659
- gatePendingCount > 0 ? t("gatePendingInfo").replace("{n}", String(gatePendingCount)) : t("gatePendingNone")))))
3718
+ gatePendingCount > 0 ? t("gatePendingInfo").replace("{n}", String(gatePendingCount)) : t("gatePendingNone")),
3719
+ // 明细清单(2026-09-24 用户要求:面板要能看到是哪些插件,有列表数据)
3720
+ gateRows !== null && gateRows.pendingCount + gateRows.adoptedCount > 0
3721
+ ? el("div", { style: { flexDirection: "column", gap: 6, display: "flex" } },
3722
+ el("strong", { className: styles.name }, t("gateListPending").replace("{n}", String(gateRows.pendingCount))),
3723
+ gateRows.pending.length === 0
3724
+ ? el("p", { className: styles.status }, t("gatePendingNone"))
3725
+ : null,
3726
+ (gateShowAll ? gateRows.pending : gateRows.pending.slice(0, 8)).map((r) => el("div", {
3727
+ key: "p-" + String(r.rowId),
3728
+ style: { flexDirection: "column", gap: 2, display: "flex", padding: "4px 0" },
3729
+ },
3730
+ el("span", { className: styles.name }, (r.enabled === false ? "⛔ " : "• ") + String(r.name) + (r.version ? " v" + r.version : "")),
3731
+ el("span", { className: styles.status }, t("gateRowReason").replace("{reason}", String(r.note ?? "—"))),
3732
+ el("span", { className: styles.status },
3733
+ (r.source === "boot-quarantine" ? t("gateRowFromBoot") : t("gateRowFromUpgrade"))
3734
+ + (r.forcedAt ? t("gateRowAt").replace("{t}", gateTime(r.forcedAt)) : "")
3735
+ + (r.adoptable ? t("gateRowAdoptable") : "")))),
3736
+ gateRows.pendingCount > 8
3737
+ ? el("button", { type: "button", className: styles.toggle, onClick: () => setGateShowAll((v) => !v) },
3738
+ gateShowAll ? t("gateListFold") : t("gateListMore").replace("{n}", String(gateRows.pendingCount)))
3739
+ : null,
3740
+ gateRows.adoptedCount > 0
3741
+ ? el("div", { style: { flexDirection: "column", gap: 2, display: "flex" } },
3742
+ el("strong", { className: styles.name }, t("gateListAdopted").replace("{n}", String(gateRows.adoptedCount))),
3743
+ gateRows.adopted.slice(0, 8).map((r) => el("span", { key: "a-" + String(r.rowId), className: styles.status },
3744
+ "✓ " + String(r.name) + (r.version ? " v" + r.version : "") + (r.adoptedAt ? " · " + gateTime(r.adoptedAt) : ""))))
3745
+ : null)
3746
+ : null)))
3660
3747
  : null,
3661
3748
  aiOpen
3662
3749
  ? el("div", { className: styles.modalBackdrop },
package/lib/index.js CHANGED
@@ -1180,6 +1180,34 @@ function queuedWrite(fn) {
1180
1180
  return run
1181
1181
  }
1182
1182
 
1183
+ /**
1184
+ * 解析"这次要升到哪个版本"(2026-09-23 抽出:原先 framework-check 与 framework-upgrade 各写了一份,
1185
+ * 加了「用户自选版本」后两份逻辑必须完全一致,索性收成一处)。
1186
+ *
1187
+ * @param {object} p
1188
+ * @param {string|null} p.current 当前框架版本
1189
+ * @param {string|null} p.latest dist-tags.latest
1190
+ * @param {string|null} p.next dist-tags.next
1191
+ * @param {string} [p.wanted] 用户在面板里显式选的版本(空串 = 没选)
1192
+ * @param {Array<{version: string}>} [p.candidates] 可选候选列表(有 wanted 时必须提供,用作白名单校验)
1193
+ * @returns {{ target: string|null, rejected: string|null }} rejected 非空 = 用户选的版本非法
1194
+ */
1195
+ function pickFrameworkTarget({ current, latest, next, wanted = '', candidates = [] }) {
1196
+ const want = typeof wanted === 'string' ? wanted.trim() : ''
1197
+ if (want !== '') {
1198
+ const hit = candidates.find((v) => v.version === want)
1199
+ return hit === undefined ? { target: null, rejected: want } : { target: hit.version, rejected: null }
1200
+ }
1201
+ return {
1202
+ // 稳定版 latest 优先;latest 不高于当前而 next(预发布渠道)确实更新时,目标取 next。
1203
+ // 必须用版本号比较而不是字符串不等(避免 current=0.1.1 稳定版时被 next=0.1.1-rc.3 反向降级)。
1204
+ target: latest !== null && current !== null && isFrameworkVersionNewer(latest, current)
1205
+ ? latest
1206
+ : (next !== null && current !== null && isFrameworkVersionNewer(next, current) ? next : null),
1207
+ rejected: null,
1208
+ }
1209
+ }
1210
+
1183
1211
  /** 解析 DSH 框架版本号为可比较对象;正式版(无预发布段)视为 rc.∞。 */
1184
1212
  function parseFrameworkVersion(value) {
1185
1213
  const m = String(value ?? '').match(/^(\d+)\.(\d+)\.(\d+)(?:-(?:[a-z]+\.)?(\d+))?$/iu)
@@ -1204,6 +1232,49 @@ function isFrameworkVersionNewer(candidate, current) {
1204
1232
  return a.rc > b.rc
1205
1233
  }
1206
1234
 
1235
+ /**
1236
+ * 框架升级候选列表(2026-09-23 用户要求:「有的版本都加上,测试版也可以有列表」)。
1237
+ *
1238
+ * 为什么需要它:升级面板原先只有**一个**目标(`latest` 优先、否则 `next`),用户看到
1239
+ * 「可升级到 0.1.5-rc.3(latest 0.1.5-rc.3 · next 0.1.7-rc.1)」却**没法自己选**——
1240
+ * 想上 0.1.7-rc.1 或某个 alpha 只能手敲 pnpm 命令。
1241
+ *
1242
+ * 规则(安全边界,都在这里钉死):
1243
+ * · 只收 **严格比 current 新** 的版本(用 `compareSemverText`,保留完整 prerelease 语义:
1244
+ * 0.1.5-rc.3 < 0.1.6-alpha.1 < 0.1.7-alpha.1 < 0.1.7-rc.1 < 0.1.7 正式版);
1245
+ * 等于或低于当前的**一律不进列表**——降级另有「回滚到上一版」那条路,不从这里走。
1246
+ * · 注册表顺序(按发布时间)不可信,这里按语义版本**降序**排(最新在最前);
1247
+ * · `latest` / `next` / `beta` / `alpha` 等 dist-tag 命中的版本带 `channel` 标注,
1248
+ * 供前端打「稳定版 / 预发布」标签;`latest` 同时作为 `tagDefault`(默认选中项,保持原行为)。
1249
+ *
1250
+ * @param {object|null|undefined} meta registry 元数据({ 'dist-tags', versions })
1251
+ * @param {string|null} current 当前已装框架版本
1252
+ * @returns {{ versions: Array<{version: string, channel: string|null, isLatest: boolean}>, tagDefault: string|null }}
1253
+ */
1254
+ function frameworkUpgradeCandidates(meta, current) {
1255
+ const tags = meta !== null && typeof meta === 'object' && meta['dist-tags'] !== null && typeof meta['dist-tags'] === 'object'
1256
+ ? meta['dist-tags']
1257
+ : {}
1258
+ const cur = parseSemverText(current)
1259
+ const all = meta !== null && typeof meta === 'object' && meta.versions !== null && typeof meta.versions === 'object'
1260
+ ? Object.keys(meta.versions)
1261
+ : []
1262
+ const newer = []
1263
+ for (const v of all) {
1264
+ const parsed = parseSemverText(v)
1265
+ if (parsed === null) continue
1266
+ if (cur !== null && compareSemverText(parsed, cur) <= 0) continue
1267
+ newer.push(v)
1268
+ }
1269
+ newer.sort((a, b) => compareSemverText(parseSemverText(b), parseSemverText(a)))
1270
+ const tagDefault = typeof tags.latest === 'string' && newer.includes(tags.latest) ? tags.latest : null
1271
+ const versions = newer.map((v) => {
1272
+ const channel = Object.keys(tags).find((name) => tags[name] === v) ?? null
1273
+ return { version: v, channel, isLatest: channel === 'latest' }
1274
+ })
1275
+ return { versions, tagDefault }
1276
+ }
1277
+
1207
1278
  /** 默认 profile 用户补丁层路径(无 include 条目可推导时的兜底)。 */
1208
1279
  function defaultPatchPath() {
1209
1280
  const home = dshHome()
@@ -1477,6 +1548,8 @@ function logQuarantineMergeError(error) {
1477
1548
  * 顺序很关键(v0.3.44):**先写清单并校验成功,再归档移除记录**。反过来的话,
1478
1549
  * 任何解析/写入意外都会"记录没了、清单也没进",用户只看到一个没有理由的【停用】。
1479
1550
  * 导出仅供测试直接驱动(避免测试为了跑它而 apply 整个插件)。 */
1551
+ export { frameworkUpgradeCandidates, pickFrameworkTarget }
1552
+
1480
1553
  export function mergeQuarantineRecord(ctx) {
1481
1554
  const file = join(dshHome(), 'plugin-console', 'fw-quarantine.json')
1482
1555
  if (!existsSync(file)) return null
@@ -1570,6 +1643,72 @@ function markPendingAdopted(pending, rowId, by, meta) {
1570
1643
  * ② 记录还是 pending、但这一行**当前已经启用**(用户手动启用过 / 补丁被清过)→ 转 adopted。
1571
1644
  * 不这么做就会出现用户实测的那种矛盾:**已启用的行,重启后仍挂着【待适配】**。
1572
1645
  * 返回 { backfilled, adopted } 供日志/测试核对;无变化则不写盘。 */
1646
+ /**
1647
+ * 门控面板要的「明细清单」(2026-09-24 用户要求:门控面板应该能显示是哪些插件,有列表数据)。
1648
+ *
1649
+ * 此前面板只有两个总开关 + 一句「当前待适配:N 行」——用户看得到数量,看不到**是谁**:
1650
+ * 待适配清单 compat-pending.json 里有 rowId / moduleName / version / checkNote / 来源 / 时间,
1651
+ * 但只有 pending 那部分被下发,且没有「已适配(adopted)」那半边,也没把 rowId 补成可读模块名。
1652
+ *
1653
+ * 这里产出面板直接可渲染的两组明细:
1654
+ * pending —— 当前被拦下/待解锁的行(含原因、来源、是否已通过源码扫描可解锁)
1655
+ * adopted —— 曾经被拦、现已适配解锁的行(留着让用户能核对"到底动过哪些行")
1656
+ * 身份补全:清单里很多行只有 rowId(隔离脚本写的),按当前 loader 反查 moduleName/version。
1657
+ */
1658
+ function gatingSummary(ctx, compatPending, ctxEntries = null) {
1659
+ const rec = compatPending ?? { frameworkVersion: null, upgradeFrom: null, pending: [] }
1660
+ const all = Array.isArray(rec.pending) ? rec.pending : []
1661
+ let info = new Map()
1662
+ try {
1663
+ info = rowIdModuleMap(ctx)
1664
+ } catch {}
1665
+ const byRow = new Map()
1666
+ for (const e of Array.isArray(ctxEntries) ? ctxEntries : []) {
1667
+ if (typeof e?.rowId === 'string' && e.rowId !== '') byRow.set(e.rowId, e)
1668
+ }
1669
+ const row = (p) => {
1670
+ const meta = info.get(p.rowId) ?? null
1671
+ const entry = byRow.get(p.rowId) ?? null
1672
+ const moduleName = p.moduleName ?? meta?.moduleName ?? entry?.moduleName ?? null
1673
+ // 界面上的"名字":优先可读模块名,其次是 loader 里的条目,最后才是 raw rowId
1674
+ const name = moduleName ?? (entry === null ? null : entry.moduleName) ?? p.rowId ?? '(未知行)'
1675
+ return {
1676
+ rowId: p.rowId ?? null,
1677
+ name,
1678
+ shortName: typeof name === 'string' && name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name,
1679
+ moduleName,
1680
+ version: p.version ?? meta?.version ?? entry?.version ?? null,
1681
+ status: p.status ?? 'pending',
1682
+ check: p.check ?? null,
1683
+ note: p.checkNote ?? null,
1684
+ source: p.source ?? null,
1685
+ adoptedAt: p.adoptedAt ?? null,
1686
+ forcedAt: p.forcedAt ?? null,
1687
+ adoptedBy: p.adoptedBy ?? null,
1688
+ enabled: entry === null ? null : entry.enabled === true,
1689
+ }
1690
+ }
1691
+ const isPending = (p) => (p.status ?? 'pending') === 'pending'
1692
+ const pendingRows = all.filter(isPending).map(row)
1693
+ const adoptedRows = all.filter((p) => !isPending(p)).map(row)
1694
+ // 可解锁提示:仅对 pending 行算(detectAdoptablePending 是只读检测,不写盘)
1695
+ let adoptable = new Map()
1696
+ try {
1697
+ adoptable = detectAdoptablePending(ctx)
1698
+ } catch {}
1699
+ for (const r of pendingRows) r.adoptable = adoptable.get(r.rowId) ?? null
1700
+ return {
1701
+ version: rec.frameworkVersion ?? null,
1702
+ upgradeFrom: rec.upgradeFrom ?? null,
1703
+ quarantineAt: rec.quarantineAt ?? null,
1704
+ quarantineLines: Array.isArray(rec.quarantineLines) ? rec.quarantineLines.slice(0, 5) : [],
1705
+ pendingCount: pendingRows.length,
1706
+ adoptedCount: adoptedRows.length,
1707
+ pending: pendingRows,
1708
+ adopted: adoptedRows,
1709
+ }
1710
+ }
1711
+
1573
1712
  export function reconcileCompatPending(ctx) {
1574
1713
  const pending = readCompatPending()
1575
1714
  if (pending === null || !Array.isArray(pending.pending)) return { backfilled: 0, adopted: 0 }
@@ -6666,7 +6805,7 @@ async function handle(ctx, req, res) {
6666
6805
  const selfPkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'))
6667
6806
  selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
6668
6807
  } catch {}
6669
- sendJson(res, 200, { ok: true, entries, 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 }, 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 })) }, 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 })) })
6808
+ sendJson(res, 200, { ok: true, entries, 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 }, gating: gatingSummary(ctx, compatPending, entries), 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 })) }, 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 })) })
6670
6809
  return
6671
6810
  }
6672
6811
 
@@ -7868,26 +8007,33 @@ async function handle(ctx, req, res) {
7868
8007
  // body.refresh === true 强制重查——面板是常驻入口,不能每次打开都打 registry。
7869
8008
  const now = Date.now()
7870
8009
  if (body.refresh === true || fwCheckCache === null || now - fwCheckCache.at > 300000) {
8010
+ // 当前版本先从本地包读出来——候选列表要拿它做「只收更新的」过滤
8011
+ let current = null
8012
+ try {
8013
+ const localRequire = createRequire(ctx.baseUrl ?? 'file:///')
8014
+ const pkg = JSON.parse(readFileSync(localRequire.resolve('@deepseek-ai/dsh/package.json'), 'utf8'))
8015
+ current = typeof pkg.version === 'string' ? pkg.version : null
8016
+ } catch {}
7871
8017
  let latest = null
7872
8018
  let next = null
8019
+ let alpha = null
8020
+ let versions = []
8021
+ let tagDefault = null
7873
8022
  let registryError = null
7874
8023
  try {
7875
8024
  const data = await fetchJsonUrl('https://registry.npmmirror.com/@deepseek-ai%2fdsh')
7876
8025
  latest = data?.['dist-tags']?.latest ?? null
7877
8026
  next = data?.['dist-tags']?.next ?? null
8027
+ alpha = data?.['dist-tags']?.alpha ?? null
8028
+ // 可选升级目标列表(用户 2026-09-23 要求:所有比当前新的版本都列出来,测试版也列)
8029
+ const cand = frameworkUpgradeCandidates(data, current)
8030
+ versions = cand.versions
8031
+ tagDefault = cand.tagDefault
7878
8032
  } catch (error) {
7879
8033
  registryError = error instanceof Error ? error.message : String(error)
7880
8034
  }
7881
- let current = null
7882
- try {
7883
- const localRequire = createRequire(ctx.baseUrl ?? 'file:///')
7884
- const pkg = JSON.parse(readFileSync(localRequire.resolve('@deepseek-ai/dsh/package.json'), 'utf8'))
7885
- current = typeof pkg.version === 'string' ? pkg.version : null
7886
- } catch {}
7887
- const target = latest !== null && current !== null && isFrameworkVersionNewer(latest, current)
7888
- ? latest
7889
- : (next !== null && current !== null && isFrameworkVersionNewer(next, current) ? next : null)
7890
- fwCheckCache = { at: now, data: { current, latest, next, target, registryError } }
8035
+ const target = pickFrameworkTarget({ current, latest, next }).target
8036
+ fwCheckCache = { at: now, data: { current, latest, next, alpha, target, versions, tagDefault, registryError } }
7891
8037
  }
7892
8038
  sendJson(res, 200, { ok: true, ...fwCheckCache.data, checkedAt: fwCheckCache.at })
7893
8039
  return
@@ -8187,20 +8333,31 @@ async function handle(ctx, req, res) {
8187
8333
  }
8188
8334
  let latest = null
8189
8335
  let next = null
8336
+ let alpha = null
8337
+ let versions = []
8338
+ let tagDefault = null
8190
8339
  let registryError = null
8191
8340
  try {
8192
8341
  const data = await fetchJsonUrl('https://registry.npmmirror.com/@deepseek-ai%2fdsh')
8193
8342
  latest = data?.['dist-tags']?.latest ?? null
8194
8343
  next = data?.['dist-tags']?.next ?? null
8344
+ alpha = data?.['dist-tags']?.alpha ?? null
8345
+ const cand = frameworkUpgradeCandidates(data, current)
8346
+ versions = cand.versions
8347
+ tagDefault = cand.tagDefault
8195
8348
  } catch (error) {
8196
8349
  // 网络黑洞/超时:区分「检测失败」与「无更新」,避免误导用户以为已是最新
8197
8350
  registryError = error instanceof Error ? error.message : String(error)
8198
8351
  }
8199
- // 升级目标:稳定版 latest 优先;latest 不高于当前而 next(预发布渠道)确实更新时,目标取 next。
8200
- // 必须用版本号比较而不是字符串不等(避免 current=0.1.1 稳定版时被 next=0.1.1-rc.3 反向降级)。
8201
- const target = latest !== null && current !== null && isFrameworkVersionNewer(latest, current)
8202
- ? latest
8203
- : (next !== null && current !== null && isFrameworkVersionNewer(next, current) ? next : null)
8352
+ // 升级目标:① 用户在面板里显式选了版本(body.version)→ 以候选列表为准校验
8353
+ //(只接受"比当前新"的真实发布版本;乱填/降级/不存在的版本一律 400,不生成升级脚本);
8354
+ // ② 没选 → 沿用原行为(helper 内注释说明:latest 优先,否则 next)。
8355
+ const picked = pickFrameworkTarget({ current, latest, next, wanted: body.version, candidates: versions })
8356
+ if (picked.rejected !== null) {
8357
+ sendJson(res, 400, { ok: false, error: `升级目标 ${picked.rejected} 不在可选列表内(只接受比当前 ${current} 更新的已发布版本;降级请用「回滚到上一版」)` })
8358
+ return
8359
+ }
8360
+ const target = picked.target
8204
8361
  const profileDir = dirname(findPatchPath(ctx))
8205
8362
  // 1) 升级前打包备份现有配置(patch / profile package.json / 插件清单)
8206
8363
  let backupDir = null
@@ -8265,7 +8422,7 @@ async function handle(ctx, req, res) {
8265
8422
  // 避免"kill 后升级/拉起双双失败,服务永久 down"
8266
8423
  if (!existsSync(corepackJs) || binPath === null || !existsSync(binPath)) {
8267
8424
  steps.push(`升级已取消:无法定位 ${!existsSync(corepackJs) ? 'corepack(pnpm 通道)' : 'dsh bin.js'}(框架安装可能不完整),服务保持运行;请先修复框架再升级`)
8268
- sendJson(res, 200, { ok: true, current, latest, next, target, hasUpdate, upgraded: false, backupDir, configMigrations, preflight, steps, hints: steps })
8425
+ sendJson(res, 200, { ok: true, current, latest, next, alpha, target, versions, tagDefault, hasUpdate, upgraded: false, backupDir, configMigrations, preflight, steps, hints: steps })
8269
8426
  return
8270
8427
  }
8271
8428
  const profileDir2 = profileDir
@@ -8458,6 +8615,29 @@ async function handle(ctx, req, res) {
8458
8615
  ` if ($v -ne '${target}') { Log ('pnpm 退出码 0 但顶层可见版本未更新(顶层 ' + $v + ',目标 ${target}),按失败处理'); $code = 1 }`,
8459
8616
  ` } catch { Log 'pnpm 退出码 0 但无法读取顶层版本,按失败处理'; $code = 1 }`,
8460
8617
  ` }`,
8618
+ // 安装后「结构完整性」校验(2026-09-24 事故后补):版本号对 ≠ 装完整。
8619
+ // 当天真实事故:一次 0.1.7-rc.1 升级的 pnpm 安装被中断,顶层 @deepseek-ai\dsh 整个目录消失、
8620
+ // .pnpm 实体只剩 lib 里几个硬链接、package.json 没了。更隐蔽的形态(package.json 写成功但
8621
+ // lib 文件没落全)会骗过"只对版本号"的校验,等下次重启才发现服务起不来 —— 所以逐项验结构:
8622
+ // package.json / lib/bin.js 都在 + CLI 自报版本等于目标;任一不过 = 本次升级失败 → 走回滚。
8623
+ ` if ($code -eq 0) {`,
8624
+ ` try {`,
8625
+ ` $fwDsh = ${ps(join(fwRoot, '@deepseek-ai', 'dsh'))}`,
8626
+ ` $binA = Join-Path $fwDsh 'lib\\bin.js'`,
8627
+ ` $binB = $null`,
8628
+ ` $ent = Get-ChildItem -Path (Join-Path ${ps(fwRoot)} '.pnpm') -Directory -Filter '@deepseek-ai+dsh@${target}*' -ErrorAction SilentlyContinue | Select-Object -First 1`,
8629
+ ` if ($ent) { $binB = Join-Path $ent.FullName 'node_modules\\@deepseek-ai\\dsh\\lib\\bin.js' }`,
8630
+ ` $bin = $null`,
8631
+ ` if (Test-Path -LiteralPath $binA) { $bin = $binA } elseif ($binB -and (Test-Path -LiteralPath $binB)) { $bin = $binB }`,
8632
+ ` if (-not (Test-Path -LiteralPath (Join-Path $fwDsh 'package.json'))) { Log '结构校验失败:顶层 @deepseek-ai\\dsh\\package.json 不存在(安装不完整)'; $code = 1 }`,
8633
+ ` elseif (-not $bin) { Log '结构校验失败:找不到 dsh 的 lib/bin.js(顶层与 .pnpm 都没有)——装完也拉不起来,按失败处理'; $code = 1 }`,
8634
+ ` else {`,
8635
+ ` $probe = (& ${ps(nodePath)} $bin --version 2>&1 | Out-String).Trim()`,
8636
+ ` if ($probe -ne '${target}') { Log ('结构校验失败:CLI 自报版本 ' + $probe + ',目标 ${target}(安装不完整或被其它副本顶替),按失败处理'); $code = 1 }`,
8637
+ ` else { Log ('结构校验通过:package.json + bin.js 就位,CLI 自报 ' + $probe) }`,
8638
+ ` }`,
8639
+ ` } catch { Log ('结构校验异常,按失败处理:' + $_.Exception.Message); $code = 1 }`,
8640
+ ` }`,
8461
8641
  ` return $code`,
8462
8642
  `}`,
8463
8643
  // 回滚函数(2026-09-04 事故后升级为「全树优先」):robocopy 支持长路径(Copy-Item 对 >260
@@ -8802,7 +8982,7 @@ async function handle(ctx, req, res) {
8802
8982
  '升级完成后请重启 DSH 服务生效——重启后 Hub 自动:备份新版本配置快照、重打框架补丁(issue #5 容错)、给出适配提示。',
8803
8983
  '官方配套组件(@deepseek-ai/*)随框架一起升级,请勿在市场单独更新。',
8804
8984
  ]
8805
- sendJson(res, 200, { ok: true, current, latest, next, target, hasUpdate, upgraded, backupDir, registryError, configMigrations, preflight, steps, hints })
8985
+ sendJson(res, 200, { ok: true, current, latest, next, alpha, target, versions, tagDefault, hasUpdate, upgraded, backupDir, registryError, configMigrations, preflight, steps, hints })
8806
8986
  return
8807
8987
  }
8808
8988
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.3.65",
3
+ "version": "0.3.67",
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",