@noob-stupid/dsh-plugin-console 0.5.98 → 0.5.100

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.
@@ -0,0 +1,123 @@
1
+ // L2 · routes —— 门控的「按需检查」(0.5.100 加法):`POST /plugin-console/compat-gate {action:'scan-current'}`
2
+ //
3
+ // 用户 2026-11-02 原话:「我们一般情况下都不预检吧」——compat-pending 只在升级前预扫与启动失败
4
+ // 隔离时写入,平时门控一条记录都没有,而"0 条"会被误读成"全部兼容"。这个动作让用户随时查一次:
5
+ // 对当前补丁里的行逐行跑适配判定,把**判为不适配**的结论记进 compat-pending(`source: 'manual-scan'`),
6
+ // 并回一份统计(检查了几行 / 通过 / 不适配 / 无法判定)。
7
+ //
8
+ // 三条硬边界(判据在 domain/compat-scan.js,写盘纪律在 domain/compat-pending-records.js):
9
+ // · 判据复用 `checkPluginFrameworkCompat`(与框架升级预扫同一条链),本文件不新写任何判据;
10
+ // · **绝不禁用任何行、绝不碰补丁文件** —— 禁用是用户的动作(/toggle);
11
+ // · 写盘:备份 → 原子写 → 读回核实 → 失败回滚;坏文件 ⇒ 409 拒绝覆盖(保住用户数据)。
12
+ import { dirname } from 'node:path'
13
+ import { planCurrentScan, recordsFromScan } from '../domain/compat-scan.js'
14
+ import { applyPendingWrite, countPendingRecords, liveRowIdsFromPatch, planScanMerge, readPendingRecords } from '../domain/compat-pending-records.js'
15
+ import { currentFrameworkVersion } from '../domain/framework.js'
16
+ import { readPatchState } from '../domain/patch.js'
17
+ import { listEntries } from '../domain/runtime.js'
18
+ import { sendError, sendJson } from '../infra/httpd.js'
19
+ import { findPatchPath } from '../infra/paths.js'
20
+
21
+ /** 读回核实的判据:本次写进去的每条 fail 记录都必须真的在盘上,且 check === 'fail'。 */
22
+ function verifyScanWrite(expected) {
23
+ return (back) => {
24
+ const rows = (Array.isArray(back?.pending) ? back.pending : []).filter((r) => r !== null && typeof r === 'object')
25
+ const missing = expected.filter((id) => !rows.some((r) => r.rowId === id && r.check === 'fail'))
26
+ if (missing.length > 0) return `这些记录没真正落盘:${missing.slice(0, 5).join('、')}`
27
+ return null
28
+ }
29
+ }
30
+
31
+ async function routeCompatScanCurrent(req, res, rc) {
32
+ const at = Date.now()
33
+ let profileDir = null
34
+ let patchRowIds = new Set()
35
+ try {
36
+ const patchPath = findPatchPath(rc.ctx)
37
+ profileDir = dirname(patchPath)
38
+ patchRowIds = liveRowIdsFromPatch(await readPatchState(patchPath))
39
+ } catch {
40
+ sendError(res, 503, '读不到 profile 目录/补丁文件,无法确定要检查的行;本次扫描没有写任何东西', { ok: false, code: 'patch-unreadable' })
41
+ return
42
+ }
43
+ let entries = []
44
+ try { entries = listEntries(rc.ctx) } catch { entries = [] }
45
+ let frameworkVersion = null
46
+ try { frameworkVersion = currentFrameworkVersion(rc.ctx) } catch { frameworkVersion = null }
47
+ const scan = planCurrentScan({ entries, profileDir, frameworkVersion })
48
+ const records = recordsFromScan(scan, { at })
49
+ const read = readPendingRecords()
50
+ const merge = planScanMerge({
51
+ read,
52
+ records,
53
+ frameworkVersion,
54
+ lastScan: {
55
+ at,
56
+ frameworkVersion: scan.frameworkVersion,
57
+ checked: scan.checked,
58
+ pass: scan.pass,
59
+ fail: scan.fail,
60
+ unknown: scan.unknown,
61
+ skipped: scan.skipped.length,
62
+ source: 'manual-scan',
63
+ },
64
+ })
65
+ const counts = (() => {
66
+ try { return countPendingRecords(read.records, patchRowIds) } catch { return null }
67
+ })()
68
+ const base = {
69
+ ok: true,
70
+ action: 'scan-current',
71
+ frameworkVersion: scan.frameworkVersion,
72
+ // 扫描统计(面板只放一句短句;明细在 skipped/results 里)
73
+ checked: scan.checked,
74
+ pass: scan.pass,
75
+ fail: scan.fail,
76
+ unknown: scan.unknown,
77
+ skipped: scan.skipped,
78
+ results: scan.results,
79
+ // 落盘情况
80
+ recorded: records.length,
81
+ added: merge.added ?? [],
82
+ updated: merge.updated ?? [],
83
+ changed: false,
84
+ file: read.file,
85
+ backup: null,
86
+ verified: false,
87
+ counts,
88
+ error: scan.error,
89
+ note: null,
90
+ }
91
+ if (merge.ok !== true) {
92
+ // 坏文件:结论不写盘,但扫描统计照样如实回(用户至少知道"现在查出来是什么")
93
+ sendError(res, merge.status ?? 500, merge.error ?? '扫描结果写入失败', { ...base, error: merge.error ?? scan.error })
94
+ return
95
+ }
96
+ if (merge.changed !== true) {
97
+ sendJson(res, 200, {
98
+ ...base,
99
+ changed: false,
100
+ note: scan.checked === 0
101
+ ? '没有可检查的行(补丁里没有可判定的插件行),未写任何记录'
102
+ : `检查 ${scan.checked} 行:通过 ${scan.pass} / 不适配 ${scan.fail} / 无法判定 ${scan.unknown};记录没有变化(无需写盘)`,
103
+ })
104
+ return
105
+ }
106
+ const write = applyPendingWrite({ file: read.file, next: merge.next, verify: verifyScanWrite(records.map((r) => r.rowId)), tag: 'scan' })
107
+ const payload = {
108
+ ...base,
109
+ ok: write.ok === true,
110
+ changed: write.ok === true,
111
+ backup: write.backup ?? null,
112
+ verified: write.verified === true,
113
+ error: write.error ?? scan.error,
114
+ note: write.ok === true
115
+ ? `检查 ${scan.checked} 行:通过 ${scan.pass} / 不适配 ${scan.fail} / 无法判定 ${scan.unknown}`
116
+ + (records.length > 0 ? `;已记下 ${records.length} 条待适配结论(只记录,未禁用任何行)` : ';没有不适配的行,未新增记录')
117
+ : null,
118
+ }
119
+ if (write.ok === true) sendJson(res, 200, payload)
120
+ else sendError(res, write.status ?? 500, write.error ?? '扫描结果写入失败', payload)
121
+ }
122
+
123
+ export { routeCompatScanCurrent }
@@ -29,6 +29,8 @@ import { compareFingerprint, computeFingerprint, planDesktopHostSwitch, readComp
29
29
  // 0.5.98 加法:适配门**记录**的删除(真待适配判据 + 写盘纪律都在该模块;本路由只映射状态码)
30
30
  import { applyPendingForget, countPendingRecords, liveRowIdsFromPatch, planForgetRecord, planForgetStale, readPendingRecords } from '../domain/compat-pending-records.js'
31
31
  import { readPatchState } from '../domain/patch.js'
32
+ // 0.5.100 加法:门控的「按需检查」(只记录结论,绝不禁用任何行)
33
+ import { routeCompatScanCurrent } from './compat-scan.js'
32
34
  import { currentFrameworkVersion, detectHostShape } from '../domain/framework.js'
33
35
  import { applyDependencyBackfill, planDependencyBackfill } from '../domain/manifest.js'
34
36
  import { isProtectedModule } from '../domain/runtime.js'
@@ -458,14 +460,28 @@ async function routeCompatAck(req, res, rc) {
458
460
  const ack = writeCompatAck(version)
459
461
  sendJson(res, 200, { ok: true, ack, noticeMode: readCompatGate().noticeMode })
460
462
  }
463
+ /**
464
+ * POST /plugin-console/compat-gate 的**动作分派**(0.5.98 起;0.5.100 多一个 `scan-current`)。
465
+ *
466
+ * `{ action: 'forget', rowId }` 删一条记录 → 见下(routeCompatPendingForget 的本体)
467
+ * `{ action: 'forget-stale' }` 清掉失效记录 → 同上
468
+ * `{ action: 'scan-current' }` **按需检查**(0.5.100 加法,用户「平时不预检」的痛点)
469
+ * → routes/compat-scan.js(只记录结论,绝不禁用任何行)
470
+ * 没有 `action`(或不是已知动作)⇒ **逐字**走原来的总开关逻辑(既有契约零变化)
471
+ *
472
+ * 为什么都挤在**既有的 /compat-gate** 上而不新开路由:门控面板的所有写入口必须同源
473
+ * (否则又要登记一份路由清单,两处判据迟早分叉)。装配层的分流见 routes/index.js#routeCompatGateAny。
474
+ */
475
+ async function routeCompatGateAction(req, res, rc) {
476
+ const action = typeof rc.body?.action === 'string' ? rc.body.action.trim() : ''
477
+ if (action === 'scan-current') return routeCompatScanCurrent(req, res, rc)
478
+ return routeCompatPendingForget(req, res, rc)
479
+ }
480
+
461
481
  /**
462
482
  * POST /plugin-console/compat-gate `{ action: 'forget' | 'forget-stale' }`
463
483
  * —— 适配门**记录**的删除(0.5.98 加法,用户 2026-10-28 明确要求)。
464
484
  *
465
- * 为什么挂在**既有的 /compat-gate** 上而不是新开一条路由:门控面板的所有写入口必须同源
466
- * (否则又要登记一份路由清单,两处判据迟早分叉)。body 里没有 `action` 时**逐字**走原来的
467
- * 总开关逻辑(既有契约零变化,装配层的分流见 routes/index.js#routeCompatGateAny)。
468
- *
469
485
  * 语义(三条硬边界,用户定案):
470
486
  * · 只删 **记录**:不动插件、不动补丁文件、不动 bundles 清单;
471
487
  * · `forget` 删指定 rowId;`forget-stale` 只清「status=pending 且该行已不在补丁里」的失效记录,
@@ -528,4 +544,4 @@ async function routeCompatPendingForget(req, res, rc) {
528
544
  else sendError(res, result.status ?? 400, result.error ?? '删除记录失败', payload)
529
545
  }
530
546
 
531
- export { hostShapeOf, maybeSwitchToObserver, maybeWriteSnapshot, preflightTimeoutMs, resetHostSwitchChecked, routeCompatAck, routeCompatMode, routeCompatPendingForget, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled, scheduleAutoPreflight }
547
+ export { hostShapeOf, maybeSwitchToObserver, maybeWriteSnapshot, preflightTimeoutMs, resetHostSwitchChecked, routeCompatAck, routeCompatGateAction, routeCompatMode, routeCompatPendingForget, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled, scheduleAutoPreflight }
@@ -16,7 +16,7 @@ import { readBody, sendError } from '../infra/httpd.js'
16
16
  import { routeAiConsent, routeAiEmpowerCancel, routeAiEmpowerList, routeAiEmpowerPlan, routeAiEmpowerRun, routeAiEmpowerStatus } from './ai.js'
17
17
  import { routeRunSuggested } from './actions.js'
18
18
  import { routeComponentAutostart, routeComponentStart, routeComponentStatus, routeComponentStop, routeComponents, routeRepoClone, routeRepoLandConfig, routeRepoList, routeRepoOpen, routeRepoRemove } from './components.js'
19
- import { routeCompatAck, routeCompatMode, routeCompatPendingForget, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled } from './compat.js'
19
+ import { routeCompatAck, routeCompatGateAction, routeCompatMode, routeCompatPendingForget, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled } from './compat.js'
20
20
  import { routeSafeBoot } from './safe-boot.js'
21
21
  // issue #22 ②③ 加法:用户备注 / 用户徽章 / 用户自建分组(独立文件,与插件列表完全解耦)
22
22
  import { routeAnnotationsGet, routeAnnotationsPost } from './annotations.js'
@@ -90,11 +90,11 @@ function routeDeps() {
90
90
  * 于是"分流"留在装配层,"动作实现"落在 routes/compat.js(适配门记录那一族)。
91
91
  *
92
92
  * 契约:body 里没有 `action`(或不是非空字符串)⇒ **逐字**走原来的总开关逻辑,
93
- * 既有请求/响应一字不变;带 `action` 才进删除记录那一族(forget / forget-stale)。
93
+ * 既有请求/响应一字不变;带 `action` 才进动作分派那一族(0.5.98 删记录 / 0.5.100 按需检查)。
94
94
  */
95
95
  async function routeCompatGateAny(req, res, rc) {
96
96
  const action = typeof rc.body?.action === 'string' ? rc.body.action : ''
97
- if (action !== '') return routeCompatPendingForget(req, res, rc)
97
+ if (action !== '') return routeCompatGateAction(req, res, rc)
98
98
  return routeCompatGate(req, res, rc)
99
99
  }
100
100
 
@@ -276,11 +276,25 @@ async function routeStateGet(req, res, rc) {
276
276
  // ★ 0.5.98 加法:门控明细行补两个**只增不改**的字段 —— `rowMissing`(这一行的 rowId 已不在
277
277
  // 当前补丁里)与 `actionable`(与计数同一个判据)。面板据此如实标注「该行已不存在」并给删除入口;
278
278
  // 既有字段(pendingCount / rows 的全部旧字段)一个不少、语义不变。
279
+ // ★ 0.5.101 加法(用户 2026-11-02):**运行期加载失败**的行(`fiberPhase === 'failed'`)。
280
+ // 真机现场:`experimental-inspector` 因 `@deepseek-ai/cordis` 解析失败而挂载失败,而门控**完全
281
+ // 看不到它** —— 因为这条失败不经过预扫/隔离链。这里只补一个**视图**字段:
282
+ // **绝不写 compat-pending.json**(那是适配裁决记录,不能被运行期故障污染)、不改任何既有字段。
283
+ const loadFailed = entries
284
+ .filter((e) => e.fiberPhase === 'failed' && typeof e.rowId === 'string' && e.rowId !== '')
285
+ .map((e) => ({ rowId: e.rowId, moduleName: e.moduleName ?? null, version: e.version ?? null, fiberPhase: 'failed', enabled: e.enabled === true }))
279
286
  const gatingOut = (() => {
280
287
  const g = gatingSummary(ctx, compatPending, entries, compatVerdicts)
281
- return { ...g, pending: annotateGateRows(g.pending, patchRowIds), adopted: annotateGateRows(g.adopted, patchRowIds) }
288
+ return {
289
+ ...g,
290
+ pending: annotateGateRows(g.pending, patchRowIds),
291
+ adopted: annotateGateRows(g.adopted, patchRowIds),
292
+ // 只增字段:加载失败的行 + 计数(面板据此决定分区显不显示)
293
+ loadFailed,
294
+ loadFailedCount: loadFailed.length,
295
+ }
282
296
  })()
283
- sendJson(res, 200, { ok: true, entries: allEntries, 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, lockAutofix, 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: gatingOut, 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 }, actionableCount: pendingCounts.actionableCount, staleCount: pendingCounts.staleCount }, 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 })) })
297
+ sendJson(res, 200, { ok: true, entries: allEntries, 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, lockAutofix, 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: gatingOut, 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 }, actionableCount: pendingCounts.actionableCount, staleCount: pendingCounts.staleCount, lastScan: compatPending.lastScan ?? 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 })) })
284
298
  return
285
299
  }
286
300
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.98",
3
+ "version": "0.5.100",
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",