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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ // L2 · routes —— **安全启动材料**恢复入口(0.5.32 加法 D3-①;服务在跑时用这条)
2
+ // POST /plugin-console/safe-boot { action: 'list' | 'restore-last-good' | 'disable-suspects' | 'mark-good', ... }
3
+ //
4
+ // 为什么要有它(用户 2026-09-29 诉求:「能挽救改完打不开的局面」):
5
+ // 走了别的更新通道(官方桌面端更新器 / 手动 pnpm / npx 缓存变化)把框架换坏之后,
6
+ // 用户需要**在服务还能应答的时候**有一条一键出路;服务起不来时则走 `scripts/safe-boot.mjs`
7
+ // (同一个 domain 模块,同一套判据)。
8
+ //
9
+ // 白名单动作(**只认这四个字符串**,绝不接受任何命令串/路径):
10
+ // · list 列快照(不返回补丁正文)
11
+ // · restore-last-good 把补丁恢复成快照(改前再备份一份当前状态 + 读回核实 + 严格 YAML 闸门)
12
+ // · disable-suspects **只**给点名的可疑行追加 `disabled: true`(不删任何行);点名来源:
13
+ // 请求里的 rowIds,或 D1 自动预检落下的隔离计划(`?usePlan=1` 默认)
14
+ // · mark-good 把某份快照标记为「良好」(永不随保留策略删除)
15
+ //
16
+ // 一律返回**可复制的命令**与影响面摘要;不做任何未点名的改动。
17
+
18
+ import { readFileSync } from 'node:fs'
19
+ import { readStore } from '../domain/auto-preflight.js'
20
+ import { readCompatMode } from '../domain/compat-state.js'
21
+ import { CORE_PATCH_ROW_IDS } from '../domain/patch.js'
22
+ import { readSafeBootState, disableSuspects, listSnapshots, markSnapshotGood, restoreLastGood, snapshotView, validatePatchYaml } from '../domain/safe-boot.js'
23
+ import { sendError, sendJson } from '../infra/httpd.js'
24
+ import { findPatchPath, pluginRoot } from '../infra/paths.js'
25
+
26
+ const ACTIONS = ['list', 'restore-last-good', 'disable-suspects', 'mark-good']
27
+ const PREFLIGHT_FILE_HINT = 'D1 自动预检的结论(同一份记录,面板与离线脚本共用)'
28
+
29
+ function patchPathOf(rc) {
30
+ try { return findPatchPath(rc.ctx) } catch { return null }
31
+ }
32
+
33
+ function pluginVersionOf() {
34
+ try { return JSON.parse(readFileSync(`${pluginRoot()}/package.json`, 'utf8')).version ?? null } catch { return null }
35
+ }
36
+
37
+ /** 最近一次「done」的自动预检记录(按当前指纹取;取不到就退回最新一条 done)。 */
38
+ function latestPlan() {
39
+ try {
40
+ const records = readStore().records
41
+ return records.find((r) => r.state === 'done' && Array.isArray(r.suspects) && r.suspects.length > 0)
42
+ ?? records.find((r) => r.state === 'done')
43
+ ?? null
44
+ } catch { return null }
45
+ }
46
+
47
+ /** 可复制命令(用户能直接粘到终端;服务起不来时就是这一条救回来)。 */
48
+ function commands({ id = null } = {}) {
49
+ const script = 'node scripts/safe-boot.mjs'
50
+ return {
51
+ list: `${script} --list`,
52
+ restore: id === null ? `${script} --restore-last-good` : `${script} --restore-last-good --id ${id}`,
53
+ disable: `${script} --disable-suspects`,
54
+ markGood: id === null ? `${script} --mark-good` : `${script} --mark-good --id ${id}`,
55
+ note: { zh: '服务打不开时在插件目录下执行(无需服务在跑)', en: 'Run this in the plugin directory when the service cannot start (no running server needed)' },
56
+ }
57
+ }
58
+
59
+ async function routeSafeBoot(req, res, rc) {
60
+ const body = rc.body ?? {}
61
+ const action = typeof body.action === 'string' ? body.action.trim() : ''
62
+ const patchPath = patchPathOf(rc)
63
+ if (!ACTIONS.includes(action)) {
64
+ sendError(res, 400, `未知动作 ${action === '' ? '(空)' : action}(可选:${ACTIONS.join(' / ')})—— 本接口只认白名单动作,不接受命令串`)
65
+ return
66
+ }
67
+ const state = readSafeBootState()
68
+ if (action === 'list') {
69
+ const plan = latestPlan()
70
+ sendJson(res, 200, {
71
+ ok: true,
72
+ action,
73
+ patchPath,
74
+ patch: patchPath === null ? null : { exists: true, check: (() => { try { return validatePatchYaml(readFileSync(patchPath, 'utf8')) } catch { return null } })() },
75
+ snapshots: listSnapshots(),
76
+ lastGoodId: state.lastGoodId,
77
+ lastAutoId: state.lastAutoId,
78
+ mode: readCompatMode().mode,
79
+ lastPlan: plan === null ? null : { at: plan.at, key: plan.key, state: plan.state, suspects: (plan.suspects ?? []).map((s) => s.rowId), affectedCount: (plan.affected ?? []).length, note: PREFLIGHT_FILE_HINT },
80
+ commands: commands(),
81
+ pluginVersion: pluginVersionOf(),
82
+ })
83
+ return
84
+ }
85
+ if (action === 'mark-good') {
86
+ const id = typeof body.id === 'string' && body.id !== '' ? body.id : null
87
+ const result = markSnapshotGood({ id })
88
+ if (result.ok !== true) { sendError(res, 404, result.error); return }
89
+ sendJson(res, 200, { ok: true, action, snapshot: result.snapshot, note: result.note, commands: commands({ id: result.snapshot?.id ?? null }) })
90
+ return
91
+ }
92
+ if (action === 'restore-last-good') {
93
+ const id = typeof body.id === 'string' && body.id !== '' ? body.id : null
94
+ const result = restoreLastGood({ patchPath, id })
95
+ if (result.ok !== true) { sendError(res, 409, result.error, { code: result.code, snapshot: result.snapshot ?? null, commands: commands({ id }) }); return }
96
+ sendJson(res, 200, {
97
+ ok: true, action, ...result,
98
+ impact: {
99
+ patchPath: result.patchPath,
100
+ changed: result.changed === true,
101
+ backupPath: result.backupPath ?? null,
102
+ sha256: result.sha256 ?? null,
103
+ snapshotId: result.snapshot?.id ?? null,
104
+ snapshotAt: result.snapshot?.at ?? null,
105
+ enabledCount: result.snapshot?.enabledCount ?? null,
106
+ disabledCount: result.snapshot?.disabledCount ?? null,
107
+ },
108
+ commands: commands({ id: result.snapshot?.id ?? null }),
109
+ next: { zh: '重启服务让补丁生效(观察者模式下本控制台不代你重启)', en: 'Restart the service for the patch to take effect (Observer mode does not restart it for you)' },
110
+ })
111
+ return
112
+ }
113
+ // disable-suspects
114
+ const plan = latestPlan()
115
+ const explicit = Array.isArray(body.rowIds) ? body.rowIds.filter((x) => typeof x === 'string') : null
116
+ const usePlan = body.usePlan !== false
117
+ const rowIds = explicit !== null && explicit.length > 0 ? explicit : (usePlan && plan !== null ? (plan.suspects ?? []).map((s) => s.rowId) : [])
118
+ if (rowIds.length === 0) {
119
+ sendJson(res, 200, {
120
+ ok: true, action, changed: false, added: [], skipped: [], backupPath: null,
121
+ reason: 'no-suspects',
122
+ note: { zh: '没有点名的可疑行(自动预检尚未跑出结论,或它没点名任何行)—— 需要的话请在 rowIds 里显式给出', en: 'No named suspect rows (auto-preflight has no conclusion yet, or named none) — pass rowIds explicitly if needed' },
123
+ coreRowIds: [...CORE_PATCH_ROW_IDS].slice(0, 8),
124
+ commands: commands(),
125
+ })
126
+ return
127
+ }
128
+ const result = disableSuspects({ patchPath, rowIds })
129
+ if (result.ok !== true) { sendError(res, 409, result.error, { code: result.code, problems: result.problems ?? null, commands: commands() }); return }
130
+ sendJson(res, 200, {
131
+ ok: true, action, ...result,
132
+ source: explicit !== null && explicit.length > 0 ? 'request' : 'auto-preflight-plan',
133
+ planAt: plan?.at ?? null,
134
+ impact: { requested: rowIds.length, added: result.added ?? [], skipped: result.skipped ?? [], backupPath: result.backupPath ?? null, prefixUnchanged: result.prefixSame === true },
135
+ commands: commands(),
136
+ })
137
+ }
138
+
139
+ export { ACTIONS, commands, latestPlan, routeSafeBoot }
@@ -5,7 +5,8 @@ import { readFileSync, existsSync } from 'node:fs'
5
5
  import { dirname, join } from 'node:path'
6
6
  import { detectAdoptablePending, detectCompat, gatingSummary, readCompatGate, readCompatPending } from '../domain/compat.js'
7
7
  import { compUiUrl, findComponents } from '../domain/components.js'
8
- import { detectFrameworkUpgrade } from '../domain/framework.js'
8
+ import { currentFrameworkVersion, detectFrameworkUpgrade } from '../domain/framework.js'
9
+ import { peerVetoFor } from '../domain/peer-veto.js'
9
10
  import { readExtraBundleRows } from '../domain/install-job.js'
10
11
  import { installJobView, readGithubAuth } from '../domain/install.js'
11
12
  import { readPluginDetails } from '../domain/market.js'
@@ -46,6 +47,7 @@ async function routeStateGet(req, res, rc) {
46
47
  setPatchHealReport(patchHeal)
47
48
  }
48
49
  const extraRows = await readExtraBundleRows(dirname(patchPath))
50
+ const profileDir = dirname(patchPath)
49
51
  const compatPending = readCompatPending()
50
52
  const compatPendingRows = new Set((compatPending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => p.rowId))
51
53
  // 兼容门总开关 + 自动检测(只提示不自动开)
@@ -58,11 +60,21 @@ async function routeStateGet(req, res, rc) {
58
60
  rollbackRec = { from: rr.from ?? null, to: rr.to ?? null, at: rr.at ?? null, applicable: true }
59
61
  }
60
62
  } catch {}
63
+ // 0.5.33 加法 D-⑧(诊断缺口):**补丁要求启用、运行时却没挂载**(fiberPhase === null)的行,
64
+ // 本地复算框架启动时的 peer 预检,补出短句原因 + 出路。框架那一侧只在**内存里**把
65
+ // row.disabled 置 true(不写用户补丁)、原因只 process.stderr.write —— 面板此前一个字都看不出。
66
+ // 判据与 dsh-app-boot 的 evaluatePluginCompatibility / preflight 对齐(见 domain/peer-veto.js)。
67
+ const vetoFrameworkVersion = currentFrameworkVersion(ctx)
61
68
  const entries = listEntries(ctx).map((entry) => {
62
69
  const meta = entryPkgMeta(entry.moduleName, ctx.baseUrl ?? 'file:///', profileDirOf(ctx))
70
+ const disabledByPatch = patch.disables.includes(entry.rowId)
71
+ let veto = null
72
+ if (!disabledByPatch && entry.fiberPhase === null && vetoFrameworkVersion !== null) {
73
+ try { veto = peerVetoFor({ moduleName: entry.moduleName, profileDir, frameworkVersion: vetoFrameworkVersion }) } catch { veto = null }
74
+ }
63
75
  return {
64
76
  ...entry,
65
- userDisabled: patch.disables.includes(entry.rowId),
77
+ userDisabled: disabledByPatch,
66
78
  userForced: patch.forced.includes(entry.rowId),
67
79
  extra: patch.inserts.includes(entry.rowId) || extraRows.has(entry.moduleName) || extraRows.has(entry.rowId),
68
80
  installDate: meta?.installDate ?? null,
@@ -70,9 +82,11 @@ async function routeStateGet(req, res, rc) {
70
82
  repository: meta?.repository ?? null,
71
83
  // v0.3.45:只有"补丁里此刻确实还禁着它"的行才显示【待适配】——
72
84
  // 记录与开关脱节时(用户手动启用过 / 补丁块被清掉)不能继续挂着「待适配」误导人
73
- pendingCompat: compatPendingRows.has(entry.rowId) && patch.disables.includes(entry.rowId),
85
+ pendingCompat: compatPendingRows.has(entry.rowId) && disabledByPatch,
74
86
  // 自动检测结果(只提示,不自动开):该待适配行现在是否已适配(插件已更新 + 扫描通过)
75
87
  adoptable: adoptable.get(entry.rowId) ?? null,
88
+ // 框架 peer 预检的本地复算结果(null = 无话可说;只在这类行上下发,不新增常驻面板行)
89
+ veto,
76
90
  }
77
91
  })
78
92
  const compat = await detectCompat(ctx.baseUrl ?? 'file:///')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.31",
3
+ "version": "0.5.33",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",