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

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.
@@ -8,20 +8,45 @@
8
8
  // 触发源不再是我们的升级按钮,而是环境指纹变化 —— 官方桌面端自带升级器 / 官方只发桌面端 /
9
9
  // 手动 pnpm 操作,都会在这里被发现(见 domain/compat-state.js 头注释)。
10
10
  //
11
+ // 0.5.32 加法(用户 2026-09-29 诉求「走别的更新通道改了框架后仍要自动守门 / 能挽救改完打不开」):
12
+ // · D1:指纹变化 → **自动跑一次只读预检**(不下载/不安装/不改补丁),落成「兼容清单 + 隔离计划 +
13
+ // 变更摘要」;同一指纹只自动跑一次(以指纹为 key 持久化),失败/超时如实记录且可重试,
14
+ // **绝不阻塞状态查询**(超时降级为「未完成,可手动重跑」);
15
+ // · D2:每次确认服务正常(状态查询成功 + 指纹未变 + 补丁结构合法)落一份 last-known-good 快照到
16
+ // `dshHome()/plugin-console/safe-boot/`(**不是** profile 目录),原子写 + sha256 + 保留 N 份;
17
+ // · D4:检测到桌面端宿主形态时,把**非用户手动设定**的运行模式自动拨回观察者(用户手动优先)。
18
+ //
11
19
  // declare-installed 存在的理由(2026-09-24 用户实测):第三方插件管理器与 pnpm 都只认清单里的
12
20
  // 依赖,手铺安装的包在它们眼里不存在(看不见 + 会被还原)。老规矩:先 plan 再 apply。
13
21
 
14
- import { dirname } from 'node:path'
15
- import { compareFingerprint, computeFingerprint, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode } from '../domain/compat-state.js'
22
+ import { existsSync, readFileSync } from 'node:fs'
23
+ import { dirname, join } from 'node:path'
24
+ import { applyEvidenceDisable } from '../domain/auto-disable.js'
25
+ import { DEFAULT_TIMEOUT_MS, autoPreflightView, runAutoPreflight } from '../domain/auto-preflight.js'
26
+ import { runAutoPreflightScan } from '../domain/auto-preflight-run.js'
16
27
  import { NOTICE_MODES, detectCompat, readCompatAck, readCompatGate, writeCompatAck, writeCompatGate } from '../domain/compat.js'
17
- import { currentFrameworkVersion } from '../domain/framework.js'
28
+ import { compareFingerprint, computeFingerprint, planDesktopHostSwitch, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode } from '../domain/compat-state.js'
29
+ import { currentFrameworkVersion, detectHostShape } from '../domain/framework.js'
18
30
  import { applyDependencyBackfill, planDependencyBackfill } from '../domain/manifest.js'
19
- import { preferredRegistries } from '../domain/sources.js'
31
+ import { isProtectedModule } from '../domain/runtime.js'
32
+ import { listSnapshots, readSafeBootState, validatePatchYaml, writeSnapshot } from '../domain/safe-boot.js'
20
33
  import { reconcileLockfile } from '../domain/selfupdate.js'
34
+ import { listEntries } from '../domain/runtime.js'
35
+ import { preferredRegistries } from '../domain/sources.js'
21
36
  import { sendError, sendJson } from '../infra/httpd.js'
22
- import { baseDirOf, profileDirOf, resolvePackageJson } from '../infra/paths.js'
23
- import { existsSync } from 'node:fs'
24
- import { join } from 'node:path'
37
+ import { baseDirOf, dshHome, findPatchPath, pluginRoot, profileDirOf, resolvePackageJson } from '../infra/paths.js'
38
+ import { preflightRoots } from './framework-preflight.js'
39
+
40
+ const PREFLIGHT_TIMEOUT_ENV = 'DSH_AUTO_PREFLIGHT_TIMEOUT_MS'
41
+ /** 自动预检的预算:到点降级成「未完成,可手动重跑」,状态查询照常返回(env 可调)。 */
42
+ function preflightTimeoutMs() {
43
+ const raw = Number(process.env[PREFLIGHT_TIMEOUT_ENV])
44
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS
45
+ }
46
+
47
+ function pluginVersionOf() {
48
+ try { return JSON.parse(readFileSync(join(pluginRoot(), 'package.json'), 'utf8')).version ?? null } catch { return null }
49
+ }
25
50
 
26
51
  /** 框架运行时根(含 .pnpm 的那层):换了安装根(npx 缓存 ↔ 桌面端自带运行时)指纹必变。
27
52
  * `@deepseek-ai/dsh` 既可能解析到 `<root>/node_modules/@deepseek-ai/dsh`(顶层投影),
@@ -60,28 +85,217 @@ function currentFingerprint(rc) {
60
85
  })
61
86
  }
62
87
 
88
+ /** 桌面端宿主形态(复用既有判据 `detectHostShape`;`rc.deps` 可注入替身,生产路径即真判据)。 */
89
+ function hostShapeOf(rc) {
90
+ const detect = rc?.deps?.detectHostShape ?? detectHostShape
91
+ try { return detect() } catch { return { hosted: false, kind: 'standalone', reasons: [] } }
92
+ }
93
+
94
+ /** D4 只在「启动 / 首次状态查询」判定一次(与用户要求的触发时机一致);之后的状态查询不重复判定。
95
+ * `resetHostSwitchChecked()` 只给测试用:让"首次判定"这件事可被独立驱动(生产路径不调用它)。 */
96
+ let hostSwitchChecked = false
97
+ function resetHostSwitchChecked() { hostSwitchChecked = false }
98
+
99
+ /** D4:桌面端宿主启动 → 非用户手动设定的模式自动拨回观察者(幂等;已是 observer 时零写盘)。 */
100
+ function maybeSwitchToObserver(rc, { force = false } = {}) {
101
+ if (hostSwitchChecked && force !== true) return { switched: false, reason: 'already-checked', note: null }
102
+ hostSwitchChecked = true
103
+ try {
104
+ const current = readCompatMode()
105
+ const verdict = planDesktopHostSwitch(current, { hosted: hostShapeOf(rc).hosted === true })
106
+ if (verdict.action !== 'switch-to-observer') return { switched: false, reason: verdict.reason, note: null }
107
+ const written = writeCompatMode('observer', { source: 'auto:desktop-host', reason: verdict.note.zh })
108
+ return { switched: written.ok === true && written.changed === true, reason: verdict.reason, note: verdict.note }
109
+ } catch (error) {
110
+ return { switched: false, reason: `判定失败:${error instanceof Error ? error.message : String(error)}`, note: null }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * D1:指纹变化 → 后台跑一次只读预检(**不 await**:状态查询该返回什么还返回什么)。
116
+ * 节流闸门在 `runAutoPreflight` 内部**同步**写下的 pending 占位 —— 同指纹的第二次查询会看到
117
+ * pending/done,因此「同一指纹只自动跑一次」与「绝不阻塞」两件事同时成立。
118
+ */
119
+ function scheduleAutoPreflight(rc, { current, previous, reasons, profileDir, patchPath }) {
120
+ const scan = rc?.deps?.autoPreflightScan ?? runAutoPreflightScan
121
+ let roots = []
122
+ const entries = (() => { try { return listEntries(rc.ctx) } catch { return [] } })()
123
+ try { roots = preflightRoots(rc.ctx, profileDir, { listEntries: () => entries, resolvePackageJson, pluginRoot }) } catch { roots = [] }
124
+ const timeoutMs = preflightTimeoutMs()
125
+ void runAutoPreflight({
126
+ current,
127
+ previous,
128
+ timeoutMs,
129
+ scan: ({ current: cur, previous: prev }) => scan({
130
+ profileDir,
131
+ patchPath,
132
+ current: cur,
133
+ previous: prev,
134
+ presetRoots: [join(dshHome(), '.agent-presets')],
135
+ roots,
136
+ reasons,
137
+ previousPackages: [],
138
+ networkAllowed: false,
139
+ }),
140
+ }).then((result) => {
141
+ // D5:预检**出结论之后**再看要不要自动禁用(有确证证据 + 开关开着 → 禁前快照 → 写盘 → 留记录)
142
+ if (result?.ok === true && result.record !== null && result.record !== undefined) maybeAutoDisable({ rc, current, previous, reasons, patchPath, record: result.record })
143
+ }).catch(() => {})
144
+ return { scheduled: true, timeoutMs, roots: roots.length }
145
+ }
146
+
147
+ /**
148
+ * D5 的接线:把 D1 预检结论里的**确证证据**交给自动禁用入口(`domain/auto-disable.js`)。
149
+ * 开关(`compat-gate.json#autoDisableOnEvidence`,默认启用)关闭时只记录候选、不写盘。
150
+ * 结果**只留在内存里给下一次状态查询用**(一次性提示;不新增任何常驻 UI/文件)。
151
+ */
152
+ let lastAutoDisable = null
153
+ function maybeAutoDisable({ rc, current, previous, reasons, patchPath, record }) {
154
+ try {
155
+ const evidence = record.evidence ?? null
156
+ const hasEvidence = Array.isArray(evidence?.rows) && evidence.rows.some((r) => r.code === 'package-unresolvable' || r.code === 'file-target-missing')
157
+ const hasBoot = Array.isArray(evidence?.bootRows) && evidence.bootRows.length > 0
158
+ if (!hasEvidence && !hasBoot) return null
159
+ if (typeof patchPath !== 'string' || patchPath === '' || !existsSync(patchPath)) return null
160
+ const gate = readCompatGate()
161
+ const plan = rc?.deps?.autoDisableApply ?? applyEvidenceDisable
162
+ const result = plan({
163
+ patchPath,
164
+ affected: Array.isArray(evidence?.rows) ? evidence.rows : [],
165
+ bootRows: Array.isArray(evidence?.bootRows) ? evidence.bootRows : [],
166
+ enabled: gate.autoDisableOnEvidence === true,
167
+ uncertainty: evidence?.uncertainty ?? null,
168
+ fingerprint: current,
169
+ frameworkVersion: current?.frameworkVersion ?? null,
170
+ reasons,
171
+ isProtected: isProtectedModule,
172
+ })
173
+ lastAutoDisable = { at: Date.now(), snapshotId: result?.snapshot?.id ?? null, ...result }
174
+ return result
175
+ } catch (error) {
176
+ lastAutoDisable = { at: Date.now(), ok: false, disabled: [], error: error instanceof Error ? error.message : String(error) }
177
+ return null
178
+ }
179
+ }
180
+
181
+ /** 一次性提示文案(中英双语短句;**不新增常驻 UI**:由客户端沿用既有的瞬时 note 通道显示)。
182
+ * ★ 开关是**读时判据**:关掉之后,之前那次"已自动禁用 N 行"的提示**不许再冒出来**
183
+ * (否则用户会以为刚又被禁了一次;实测就是这么发现的)。 */
184
+ function autoDisableNotice(entry = lastAutoDisable, { enabled = null } = {}) {
185
+ const on = enabled === null ? readCompatGate().autoDisableOnEvidence === true : enabled === true
186
+ if (entry === null || entry === undefined) return null
187
+ if (on !== true && entry.code === 'auto-disabled') return null
188
+ const added = Array.isArray(entry.disabled) ? entry.disabled : []
189
+ const restore = 'node scripts/safe-boot.mjs --restore-last-good'
190
+ if (added.length > 0) {
191
+ return {
192
+ at: entry.at ?? null,
193
+ zh: `已自动禁用 ${added.length} 行(确证解析不到 / 上次把服务搞挂):${added.join('、')};如需恢复:${restore}`,
194
+ en: `Auto-disabled ${added.length} row(s) (confirmed unresolvable / crashed the service last time): ${added.join(', ')}; to restore: ${restore}`,
195
+ disabled: added, snapshotId: entry.snapshotId ?? null, restoreCommand: restore, kind: 'auto-disabled',
196
+ }
197
+ }
198
+ if (entry.code === 'switch-off') {
199
+ return {
200
+ at: entry.at ?? null,
201
+ zh: `检测到需禁用的行,但自动禁用开关已关闭:${(entry.candidates ?? []).join('、')}(保持原样,等你手动处理)`,
202
+ en: `Rows need disabling, but the auto-disable switch is off: ${(entry.candidates ?? []).join(', ')} (left untouched)`,
203
+ disabled: [], candidates: entry.candidates ?? [], restoreCommand: restore, kind: 'switch-off',
204
+ }
205
+ }
206
+ return null
207
+ }
208
+
209
+ /**
210
+ * D2:确认服务正常后落一份 last-known-good 快照。
211
+ * 「确认正常」的判据(不吹牛):状态查询跑到了这一步 **且** 指纹与基线一致(环境没变)
212
+ * **且** 补丁通过结构校验(不是写坏的)。任一不满足就如实说明,不写快照。
213
+ */
214
+ function maybeWriteSnapshot({ current, changed, patchPath }) {
215
+ try {
216
+ if (typeof patchPath !== 'string' || patchPath === '' || !existsSync(patchPath)) return { written: false, reason: 'no-patch-file' }
217
+ const patchText = readFileSync(patchPath, 'utf8')
218
+ if (changed === true) return { written: false, reason: 'environment-changed' }
219
+ const check = validatePatchYaml(patchText)
220
+ if (check.ok !== true) return { written: false, reason: 'patch-not-yaml-valid', problems: check.problems.slice(0, 3) }
221
+ const result = writeSnapshot({
222
+ patchPath,
223
+ patchText,
224
+ fingerprint: current,
225
+ frameworkVersion: current?.frameworkVersion ?? null,
226
+ reasons: [],
227
+ kind: 'auto',
228
+ pluginVersion: pluginVersionOf(),
229
+ note: `自动快照(模式 ${readCompatMode().mode},服务响应正常,环境未变)`,
230
+ })
231
+ if (result.ok !== true) return { written: false, reason: 'write-failed', error: result.error }
232
+ return { written: result.written === true, reason: result.written === true ? null : (result.reason ?? 'unchanged'), snapshot: result.snapshot ?? null }
233
+ } catch (error) {
234
+ return { written: false, reason: 'error', error: error instanceof Error ? error.message : String(error) }
235
+ }
236
+ }
237
+
63
238
  async function routeCompatStatusGet(req, res, rc) {
239
+ // D4:桌面端宿主启动 → 自动拨回观察者(只在启动/首次查询判定一次;用户手动拨过的机器一动不动)
240
+ const auto = maybeSwitchToObserver(rc)
64
241
  const mode = readCompatMode()
65
242
  const current = currentFingerprint(rc)
66
243
  const previous = readFingerprint()
67
244
  const { changed, reasons, baselineAt } = compareFingerprint(current, previous)
245
+ const patchPath = (() => { try { return findPatchPath(rc.ctx) } catch { return null } })()
246
+
247
+ // D1:变了就自动跑一次只读预检(后台,不阻塞;同一指纹只跑一次)
248
+ const autoRun = changed === true ? scheduleAutoPreflight(rc, { current, previous, reasons, profileDir: profileDirOf(rc.ctx), patchPath }) : null
249
+
250
+ // D2:确认服务正常 → 落 last-known-good 快照(节流:指纹与补丁都没变时零写盘)
251
+ const snapshot = maybeWriteSnapshot({ current, changed, patchPath })
252
+
253
+ const autoPreflight = autoPreflightView({ changed, current })
68
254
  sendJson(res, 200, {
69
255
  ok: true,
70
256
  mode: mode.mode,
71
- modeSource: mode.source,
257
+ modeSource: mode.fileSource,
72
258
  modeChangedAt: mode.at,
73
259
  modeReason: mode.reason ?? null,
260
+ // D4 加法字段:本次是否自动拨回了观察者 + 一句短句(中英双语);没自动改就是 null
261
+ modeAuto: auto.switched === true ? { switched: true, note: auto.note, at: Date.now() } : null,
74
262
  frameworkVersion: current.frameworkVersion,
75
263
  fingerprint: { current, previous, changed, reasons, baselineAt },
76
264
  rollback: readRollbackRecord(),
265
+ // D1 加法字段:自动预检的状态/结论(面板只放一句短句,长文进详情)
266
+ autoPreflight: { ...autoPreflight, scheduled: autoRun !== null, scheduledAt: autoRun === null ? null : Date.now(), timeoutMs: autoRun?.timeoutMs ?? preflightTimeoutMs() },
267
+ // D5 加法字段:自动禁用的开关状态 + **本次一次性提示**(客户端走既有瞬时 note 通道;
268
+ // 服务端不新增任何常驻面板元素/按钮/文件)
269
+ autoDisable: {
270
+ onEvidence: readCompatGate().autoDisableOnEvidence === true,
271
+ switch: { file: 'compat-gate.json', key: 'autoDisableOnEvidence', route: '/plugin-console/compat-ack', body: { autoDisableOnEvidence: false } },
272
+ notice: autoDisableNotice(),
273
+ restore: { route: { path: '/plugin-console/safe-boot', body: { action: 'restore-last-good' } }, command: 'node scripts/safe-boot.mjs --restore-last-good' },
274
+ logHint: '记录落在 dshHome()/plugin-console/auto-disable.log(追加式 JSONL)',
275
+ },
276
+ // D2 加法字段:安全启动材料(last-known-good 快照 + 恢复入口提示)
277
+ safeBoot: {
278
+ snapshot: { written: snapshot.written === true, reason: snapshot.reason ?? null, id: snapshot.snapshot?.id ?? null, at: snapshot.snapshot?.at ?? null, sha256: snapshot.snapshot?.patchSha256 ?? null },
279
+ snapshots: listSnapshots().slice(0, 5),
280
+ lastGoodId: readSafeBootState().lastGoodId,
281
+ command: { list: 'node scripts/safe-boot.mjs --list', restore: 'node scripts/safe-boot.mjs --restore-last-good', disable: 'node scripts/safe-boot.mjs --disable-suspects' },
282
+ route: { action: 'restore-last-good | disable-suspects | list | mark-good', path: '/plugin-console/safe-boot' },
283
+ },
77
284
  })
78
285
  }
79
286
 
80
287
  async function routeCompatMode(req, res, rc) {
81
288
  const wanted = rc.body?.mode
82
- const result = writeCompatMode(wanted)
289
+ // 用户手动拨动 → 来源记 `manual`(此后**永不自动改**:D4 的"不抢用户意图"就靠这一笔)
290
+ const result = writeCompatMode(wanted, { source: 'manual', reason: '用户手动切换' })
83
291
  if (!result.ok) { sendError(res, 400, result.error); return }
84
- sendJson(res, 200, { ok: true, mode: result.mode, note: result.mode === 'observer' ? '观察者模式:只做预检与门控,不接管框架升级' : '托管模式:框架升级与回滚由本控制台负责' })
292
+ sendJson(res, 200, {
293
+ ok: true,
294
+ mode: result.mode,
295
+ source: result.source,
296
+ changed: result.changed === true,
297
+ note: result.mode === 'observer' ? '观察者模式:只做预检与门控,不接管框架升级' : '托管模式:框架升级与回滚由本控制台负责',
298
+ })
85
299
  }
86
300
 
87
301
  async function routeCompatStamp(req, res, rc) {
@@ -143,6 +357,21 @@ async function routeDeclareInstalled(req, res, rc) {
143
357
  */
144
358
  async function routeCompatAck(req, res, rc) {
145
359
  const body = rc.body ?? {}
360
+ // D5 加法:自动禁用(有确证证据时)的开关——同一把锁(compat-gate.json)里再记一个布尔。
361
+ // 它与既有的 `autoDisable`(我们升级时)是两个独立开关;这里只动 `autoDisableOnEvidence`,
362
+ // **不碰** 既有字段(一个字都不改旧语义)。
363
+ if (typeof body.autoDisableOnEvidence === 'boolean') {
364
+ const gate = writeCompatGate({ autoDisableOnEvidence: body.autoDisableOnEvidence })
365
+ sendJson(res, 200, {
366
+ ok: true,
367
+ autoDisableOnEvidence: gate.autoDisableOnEvidence,
368
+ autoDisable: gate.autoDisable,
369
+ note: gate.autoDisableOnEvidence === true
370
+ ? '指纹变化 / 启动失败记录给出确证证据时,会自动禁用被点名的行(禁前先存快照,可一条命令回滚)'
371
+ : '已关闭自动禁用:只在面板/接口里报告点名行,改由你手动点「只禁可疑行」',
372
+ })
373
+ return
374
+ }
146
375
  if (typeof body.mode === 'string') {
147
376
  const mode = body.mode.trim().toLowerCase()
148
377
  if (!NOTICE_MODES.includes(mode)) {
@@ -167,4 +396,4 @@ async function routeCompatAck(req, res, rc) {
167
396
  const ack = writeCompatAck(version)
168
397
  sendJson(res, 200, { ok: true, ack, noticeMode: readCompatGate().noticeMode })
169
398
  }
170
- export { routeCompatAck, routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled }
399
+ export { hostShapeOf, maybeSwitchToObserver, maybeWriteSnapshot, preflightTimeoutMs, resetHostSwitchChecked, routeCompatAck, routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled, scheduleAutoPreflight }
@@ -24,6 +24,14 @@ import { baseDirOf, dshHome, findPatchPath, pluginRoot, profileDirOf, resolvePac
24
24
 
25
25
  const REGISTRY = 'https://registry.npmmirror.com'
26
26
 
27
+ /** `preflightRoots` 的默认依赖(0.5.32:把这段抽成可注入形式,让"指纹变化自动预检"与离线脚本
28
+ * 复用**同一套扫描面**推导,而不是各写一份 —— 两套推导迟早会分叉,而分叉的表现是"自动预检看不见
29
+ * 用户装的那个包",属于静默漏报)。生产路径不传 deps,行为与抽取前一字不差。 */
30
+ const defaultListEntries = listEntries
31
+ const defaultResolvePackageJson = resolvePackageJson
32
+ const defaultIsFrameworkOwned = isFrameworkOwnedPackage
33
+ const defaultPluginRoot = pluginRoot
34
+
27
35
  /** 预检的两个扫描面:用户预设目录 + profile 内每个已装插件包。
28
36
  * 框架自带包(解析到 profile 之外,即 npx 缓存里的 @deepseek-ai/*)标成 kind='framework':
29
37
  * 仍然扫(能暴露框架自身的契约不一致),但**只报告不改**——改它等于污染框架安装树。
@@ -36,7 +44,12 @@ function userPresetsRoot(home = undefined) {
36
44
  return join(home ?? dshHome(), '.agent-presets')
37
45
  }
38
46
 
39
- function preflightRoots(ports, profileDir) {
47
+ function preflightRoots(ports, profileDir, deps = {}) {
48
+ const userPresetsRoot = deps.userPresetsRoot ?? ((home = undefined) => join(home ?? dshHome(), '.agent-presets'))
49
+ const listEntries = deps.listEntries ?? defaultListEntries
50
+ const resolvePackageJson = deps.resolvePackageJson ?? defaultResolvePackageJson
51
+ const isFrameworkOwnedPackage = deps.isFrameworkOwnedPackage ?? defaultIsFrameworkOwned
52
+ const pluginRoot = deps.pluginRoot ?? defaultPluginRoot
40
53
  const roots = [{ root: userPresetsRoot(), kind: 'preset' }]
41
54
  // 控制台自身排除在外:它是工具、不是会话消息生产方,而它的报错文案/文档里本身就含
42
55
  // `kind: 'plugin'`、`.volatile(` 这类字样 —— 发版门槛自扫时正是这些字串报出了无意义条目。
@@ -17,6 +17,7 @@ import { routeAiConsent, routeAiEmpowerCancel, routeAiEmpowerList, routeAiEmpowe
17
17
  import { routeRunSuggested } from './actions.js'
18
18
  import { routeComponentAutostart, routeComponentStart, routeComponentStatus, routeComponentStop, routeComponents, routeRepoClone, routeRepoLandConfig, routeRepoList, routeRepoOpen, routeRepoRemove } from './components.js'
19
19
  import { routeCompatAck, routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled } from './compat.js'
20
+ import { routeSafeBoot } from './safe-boot.js'
20
21
  import { routePatchAuditGet } from './patch-audit.js'
21
22
  import { routePresetAuditGet } from './preset-audit.js'
22
23
  import { routeFrameworkUpgrade } from './framework-upgrade.js'
@@ -114,6 +115,9 @@ const ROUTES = [
114
115
  { methods: ['POST'], path: `${ROUTE_PREFIX}/compat-stamp`, handler: routeCompatStamp },
115
116
  { methods: ['POST'], path: `${ROUTE_PREFIX}/compat-ack`, handler: routeCompatAck },
116
117
  { methods: ['POST'], path: `${ROUTE_PREFIX}/declare-installed`, handler: routeDeclareInstalled },
118
+ // 安全启动材料恢复入口(2026-09-29 加法 D3-①,0.5.32):白名单动作 list / restore-last-good /
119
+ // disable-suspects / mark-good;只认动作名,绝不接受命令串;服务起不来时走 scripts/safe-boot.mjs
120
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/safe-boot`, handler: routeSafeBoot },
117
121
  { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-rollback`, handler: routeFrameworkRollback },
118
122
  { methods: ['POST'], path: `${ROUTE_PREFIX}/restart`, handler: routeRestart },
119
123
  { methods: ['POST'], path: `${ROUTE_PREFIX}/toggle`, handler: routeToggle },
@@ -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 }
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.32",
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",