@noob-stupid/dsh-plugin-console 0.5.48 → 0.5.50

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,186 @@
1
+ #!/usr/bin/env node
2
+ // scripts/fw-preflight.mjs —— **服务起不来时的预检与治包**(0.5.50 加法)
3
+ //
4
+ // 为什么要在**服务之外**再来一条命令(真机停机事故 2026-10-14):
5
+ // 那次的坏法是「框架包被掏空」—— `cordis` 顶层 junction 还在、它指向的 `.pnpm` 实体被清空;
6
+ // `dsh-llm` / `dsh-tools` / `dsh-web` 直接成了空目录。后果是 dsh 启动即
7
+ // `54 个插件 failed to import`、web 服务 exit code=1。
8
+ //
9
+ // 而既有的 safe-boot 快照恢复的是 **`cordis.patch.yml` 的文本**(配置层)—— 那次补丁好端端的,
10
+ // 恢复它等于"把一份没坏的配置换成另一份没坏的配置",四个空目录纹丝不动;
11
+ // 指纹数的是 `.pnpm` 里的**目录个数** —— "壳还在、里面被掏空"⇒ 计数不变 ⇒ 不触发。
12
+ // 最根本的是:**控制台插件是跑在 dsh 里的**,dsh 起不来它就从未加载(事故日志里出现 0 次)。
13
+ // ⇒ 这类事故必须在**服务之外**有一条出路。这就是本脚本。
14
+ //
15
+ // 三条硬约束(与 scripts/safe-boot.mjs 同族):
16
+ // · **零依赖**:只用 Node 内置模块 + 本插件自己的 infra/domain 纯函数(不装包、**不联网**);
17
+ // · **服务未运行也能跑**:不读端口、不连 webServer、不需要 profile 正被使用;
18
+ // · **绝不 kill / spawn / 抢端口 / 建计划任务**:只做文件级修复。
19
+ // (2026-10-10 的教训:那套"按端口 kill 再拉起"会与宿主争端口 ⇒ `listen EADDRINUSE`,
20
+ // 或让桌面端外壳弹出「应用无法启动或已意外停止」。跑完要不要重启,由你自己决定。)
21
+ //
22
+ // 用法(在插件目录下,或 `node <插件目录>/scripts/fw-preflight.mjs`):
23
+ // node scripts/fw-preflight.mjs --check 只诊断,列出坏掉的包(**一个字节都不写**)
24
+ // node scripts/fw-preflight.mjs --repair 从本地 npm 缓存把缺的包装回来,再复验
25
+ // node scripts/fw-preflight.mjs --check --json 机器可读输出
26
+ // 可选:--fw-root <dir> 指定框架树(默认自动探测:从本插件解析 @deepseek-ai/dsh 反推)
27
+ // --cache <dir> 指定 npm 缓存根(默认自动找 npm_config_cache / %LOCALAPPDATA%\npm-cache / 常见位置)
28
+ // --help 显示本帮助
29
+
30
+ import { isAbsolute, resolve } from 'node:path'
31
+ import { dirname } from 'node:path'
32
+ import { fileURLToPath } from 'node:url'
33
+ import { detectFrameworkRoot } from '../lib/server/infra/fw-root-detect.js'
34
+ import { runFwPreflight } from '../lib/server/domain/fw-preflight.js'
35
+
36
+ const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
37
+
38
+ const HELP = `框架闭包预检与治包(fw-preflight)—— 服务起不来时用这个,不需要服务在跑。
39
+
40
+ 用法:
41
+ node scripts/fw-preflight.mjs --check
42
+ 只诊断:从 dsh 的 lib/bin.js 出发走一遍 @deepseek-ai/* 依赖闭包,
43
+ 列出**解析不了**的包(= 真正的损坏面)。一个字节都不写。
44
+
45
+ node scripts/fw-preflight.mjs --repair
46
+ 把解析不了的包装回来:按 package-lock.json 的 integrity 从**本地 npm 缓存**取 tarball 解开。
47
+ 改前若目标已有内容,先改名成 <name>.preflight-bak-<时间戳>(绝不直接删)。
48
+ 装完**重新走一遍闭包复验**,不靠"我装完了"自证。
49
+
50
+ 可选参数:
51
+ --fw-root <dir> 框架树根(含 @deepseek-ai/ 与 .pnpm/)。默认自动探测。
52
+ --cache <dir> npm 缓存根(含 content-v2/)。默认自动探测,可重复。
53
+ --json 机器可读输出(面板/子代理可直接解析)。
54
+ --help 显示本帮助。
55
+
56
+ 判据为什么是"能不能解析"而不是"目录空不空":
57
+ 这次事故的形态是「目录壳还在、里面被掏空」—— 数目录个数完全看不见
58
+ (2026-10-14 实测:按"目录空不空"粗扫本机会误报 237 个;换成闭包判据后 287 个包精准通过,
59
+ 清空一个立刻只点名那一个)。
60
+
61
+ 它不做什么:
62
+ 不 kill 进程、不 spawn 服务、不抢端口、不建计划任务、不联网。
63
+ 修完请自己重启服务(桌面端:完全退出再打开;独立 dsh web:终端里 Ctrl+C 后重跑)。
64
+ `
65
+
66
+ /** 解析命令行(只认本脚本声明的这几个开关;未知参数如实报错,不静默忽略)。 */
67
+ function parseArgv(argv) {
68
+ const out = { mode: null, fwRoot: null, caches: [], json: false, help: false, bad: [] }
69
+ for (let i = 0; i < argv.length; i += 1) {
70
+ const a = argv[i]
71
+ if (a === '--check') out.mode = 'check'
72
+ else if (a === '--repair') out.mode = 'repair'
73
+ else if (a === '--json') out.json = true
74
+ else if (a === '--help' || a === '-h') out.help = true
75
+ else if (a === '--fw-root') { out.fwRoot = argv[i + 1] ?? null; i += 1 }
76
+ else if (a === '--cache') { const v = argv[i + 1]; if (v) out.caches.push(v); i += 1 }
77
+ else out.bad.push(a)
78
+ }
79
+ return out
80
+ }
81
+
82
+ /**
83
+ * 探测框架树根:多基准(见 lib/server/infra/fw-root-detect.js 的头注释)。
84
+ * 这里只做转发,判据收在 infra 一处 —— 脚本与(日后的)路由不会分叉。
85
+ */
86
+ function detectFwRoot() {
87
+ return detectFrameworkRoot({ pluginDir: PLUGIN_ROOT })
88
+ }
89
+
90
+ /** 把探测结果 + 修复结果渲染成人读报告。 */
91
+ function renderReport(report, { attempted }) {
92
+ const lines = []
93
+ const bad = report.before?.broken ?? []
94
+ lines.push(`框架树 : ${report.fwRoot ?? '(未探测到)'}`)
95
+ lines.push(`解析入口: ${report.entry ?? '(无)'}${report.entryVia === null || report.entryVia === undefined ? '' : ` [${report.entryVia}]`}`)
96
+ lines.push(`npm 缓存: ${(report.cacheRoots ?? []).join(' ; ') || '(未找到 —— 只能诊断,不能修复)'}`)
97
+ lines.push('')
98
+ if (report.error !== undefined && report.error !== null) {
99
+ lines.push(`✗ ${report.error}`)
100
+ return lines.join('\n')
101
+ }
102
+ const walked = report.before?.walked?.length ?? 0
103
+ lines.push(attempted ? `① 修复前:走了 ${walked} 个包,${bad.length} 个解析不了` : `走了 ${walked} 个包,${bad.length} 个解析不了`)
104
+ for (const b of bad) lines.push(` ✗ ${b.name} ← ${b.from} (${b.reason})`)
105
+ if (bad.length === 0) lines.push(' ✓ 依赖闭包完整 —— 框架树是好的,起不来的原因不在这里')
106
+
107
+ if (!attempted) {
108
+ if (bad.length > 0) lines.push('\n用 --repair 试着把它们装回来(只动文件,不碰任何进程)。')
109
+ return lines.join('\n')
110
+ }
111
+
112
+ lines.push('')
113
+ lines.push(`② 本次修复:成功 ${report.repaired.length} 个,失败 ${report.failed.length} 个,不可修 ${report.skipped.length} 个`)
114
+ for (const r of report.repaired) lines.push(` ✓ ${r.name}@${r.version} → ${r.via} ${r.note}`)
115
+ for (const f of report.failed) lines.push(` ✗ ${f.name} (${f.action}) ${f.note}`)
116
+ for (const s of report.skipped) {
117
+ const why = { 'no-lock-entry': 'package-lock.json 里没有它的记录 —— 不猜版本', 'no-integrity': '记录里没有 integrity 校验和', 'no-target': '定不出该装到哪个目录' }[s.reason] ?? s.reason
118
+ lines.push(` · ${s.name} ${why}`)
119
+ }
120
+
121
+ lines.push('')
122
+ const after = report.after?.broken ?? []
123
+ lines.push(`③ 复验:走了 ${report.after?.walked?.length ?? 0} 个包,${after.length} 个仍解析不了`)
124
+ for (const b of after) lines.push(` ✗ ${b.name} ← ${b.from}`)
125
+ if (report.okAfter === true) {
126
+ lines.push(' ✓ 闭包已完整')
127
+ lines.push('')
128
+ lines.push('接下来:重启服务让修复生效(本脚本**不会**替你 kill / 拉起进程)。')
129
+ lines.push(' · 桌面端:完全退出 DeepSeek Harness,再重新打开')
130
+ lines.push(' · 独立 dsh web:在启动它的终端里 Ctrl+C,然后重新运行 dsh web')
131
+ } else {
132
+ lines.push('')
133
+ lines.push('仍有包解析不了。可能原因:本地 npm 缓存里也没有那些 tarball(本脚本不联网)。')
134
+ lines.push('可对该包单独装一次(会联网):dsh plugin --profile <名> add <包名>')
135
+ }
136
+ return lines.join('\n')
137
+ }
138
+
139
+ async function main() {
140
+ const args = parseArgv(process.argv.slice(2))
141
+ if (args.help) { process.stdout.write(HELP); return 0 }
142
+ if (args.bad.length > 0) {
143
+ process.stderr.write(`未知参数:${args.bad.join(' ')}\n\n${HELP}`)
144
+ return 2
145
+ }
146
+ if (args.mode === null) {
147
+ process.stderr.write(`请指定 --check 或 --repair(或 --help)。\n\n${HELP}`)
148
+ return 2
149
+ }
150
+
151
+ let fwRoot = args.fwRoot
152
+ let via = 'explicit'
153
+ let tried = []
154
+ if (typeof fwRoot !== 'string' || fwRoot === '') {
155
+ const d = detectFwRoot()
156
+ fwRoot = d.fwRoot
157
+ via = d.via
158
+ tried = d.tried ?? []
159
+ } else {
160
+ fwRoot = isAbsolute(fwRoot) ? fwRoot : resolve(process.cwd(), fwRoot)
161
+ }
162
+
163
+ if (fwRoot === null || fwRoot === undefined || fwRoot === '') {
164
+ const msg = '探测不到框架树根 —— 请用 --fw-root <dir> 显式指定(该目录下应有 @deepseek-ai/ 与 .pnpm/)。'
165
+ const detail = tried.map((t) => ` · 试过 ${t.via}: ${t.base}`).join('\n')
166
+ if (args.json) process.stdout.write(`${JSON.stringify({ ok: false, error: msg, tried }, null, 2)}\n`)
167
+ else process.stderr.write(`✗ ${msg}\n${detail}\n`)
168
+ return 1
169
+ }
170
+
171
+ const cacheRoots = args.caches.length > 0 ? args.caches.map((c) => (isAbsolute(c) ? c : resolve(process.cwd(), c))) : null
172
+ const report = runFwPreflight({ fwRoot, repair: args.mode === 'repair', cacheRoots })
173
+ const payload = { ...report, fwRootVia: via, mode: args.mode }
174
+
175
+ if (args.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
176
+ else process.stdout.write(`${renderReport(payload, { attempted: args.mode === 'repair' })}\n`)
177
+
178
+ // 退出码:诊断模式 = 闭包是否完整;修复模式 = 修复后是否完整(供脚本/CI 判断)
179
+ const good = args.mode === 'repair' ? report.okAfter === true : report.ok === true
180
+ return good ? 0 : 1
181
+ }
182
+
183
+ main().then((code) => { process.exitCode = code }, (error) => {
184
+ process.stderr.write(`fw-preflight 异常:${error?.stack ?? error}\n`)
185
+ process.exitCode = 1
186
+ })
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env node
2
+ // scripts/safe-boot.mjs —— **服务起不来时的唯一出路**(0.5.32 加法 D3-②)
3
+ //
4
+ // 为什么要一个独立脚本(用户 2026-09-29 诉求:「能挽救改完打不开的局面」):
5
+ // 框架被别的通道(官方桌面端更新器 / 手动 pnpm / npx 缓存变化)换坏之后,**服务本身已经打不开**,
6
+ // 此时任何"控制台里有按钮"的方案都等于没有 —— 需要在服务之外直接跑一条命令。
7
+ //
8
+ // 三条硬约束:
9
+ // · **零依赖**:只用 Node 内置模块 + 本插件自己的 domain 纯函数(不装任何包、不联网、不 spawn);
10
+ // · **服务未运行也能跑**:不读端口、不连 webServer、不需要 profile 正在被使用(只读写补丁文件与快照目录);
11
+ // · **可解释**:`--help` 里写清"打不开时怎么用",每个动作都打印影响面与备份路径。
12
+ //
13
+ // 用法(在插件目录下,或 `node <插件目录>/scripts/safe-boot.mjs`):
14
+ // node scripts/safe-boot.mjs --list 列快照(含可复制命令)
15
+ // node scripts/safe-boot.mjs --restore-last-good 恢复到最近一份"良好/最新"快照(改前再备份)
16
+ // node scripts/safe-boot.mjs --disable-suspects 只给可疑行追加 disabled: true(不删任何行)
17
+ // node scripts/safe-boot.mjs --mark-good 把某份快照标记为"良好"(永不随保留策略删除)
18
+ // 可选:--id <snapshot-id> 指定快照;--home <DSH_HOME>;--profile <profile 名或目录>;--json
19
+ //
20
+ // 判据与路由(POST /plugin-console/safe-boot)**共用同一个 domain 模块** —— 两处不会分叉。
21
+
22
+ import { existsSync, readFileSync } from 'node:fs'
23
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
24
+ import { fileURLToPath } from 'node:url'
25
+ import { dshHome } from '../lib/server/infra/paths.js'
26
+ import { readStore } from '../lib/server/domain/auto-preflight.js'
27
+ import { readCompatMode } from '../lib/server/domain/compat-state.js'
28
+ import { CORE_PATCH_ROW_IDS } from '../lib/server/domain/patch.js'
29
+ import { validatePatchYaml } from '../lib/server/domain/patch-yaml-check.js'
30
+ import { applyRetention, disableSuspects, listSnapshots, markSnapshotGood, readSafeBootState, restoreLastGood, safeBootDir } from '../lib/server/domain/safe-boot.js'
31
+
32
+ const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
33
+ const COMMANDS = {
34
+ list: 'node scripts/safe-boot.mjs --list',
35
+ restore: 'node scripts/safe-boot.mjs --restore-last-good',
36
+ disable: 'node scripts/safe-boot.mjs --disable-suspects',
37
+ markGood: 'node scripts/safe-boot.mjs --mark-good',
38
+ }
39
+
40
+ const HELP = `安全启动材料(safe-boot)—— 服务打不开时用这个脚本,不需要服务在跑。
41
+
42
+ 用法:
43
+ node scripts/safe-boot.mjs --list
44
+ 列出全部 last-known-good 快照(时间 / 框架版本 / 行数 / 备份命令)。
45
+ node scripts/safe-boot.mjs --restore-last-good [--id <id>]
46
+ 把 profile 补丁恢复成快照的**逐字节**内容:改前先把当前补丁另存备份(*.bak-safe-boot-<时间戳>),
47
+ 写入用「临时文件 + rename」原子落盘,写后读回核实(sha256 + 内容比对)。
48
+ 不带 --id 时:优先「标记为良好」的那份,否则用最新一份。
49
+ node scripts/safe-boot.mjs --disable-suspects [--row <rowId>]... [--all-plan]
50
+ 只给**点名**的可疑行追加 \`disabled: true\`(沿用补丁既有写法,**不删任何行**,核心行永不写)。
51
+ 点名来源:① \`--row\` 显式给出 ② 不带 --row 时用自动预检(D1)落下的隔离计划。
52
+ node scripts/safe-boot.mjs --mark-good [--id <id>]
53
+ 把某份快照标记为「良好」—— 这份永不随保留策略(默认只留最近 3 份)删除。
54
+ node scripts/safe-boot.mjs --list --json
55
+ 机器可读输出(面板/子代理可直接解析)。
56
+
57
+ 可选参数:
58
+ --home <dir> DSH_HOME(默认取环境变量 DSH_HOME,再退到用户主目录下的 .dsh)
59
+ --profile <name> profile 名(web / desktop)或 profile 目录;默认取环境变量 DSH_PROFILE,
60
+ 再退到 <DSH_HOME>/profiles/web
61
+ --row <rowId> 只对这一行执行 --disable-suspects(可重复)
62
+ --all-plan 配合 --disable-suspects:忽略显式 --row,直接用自动预检计划里的全部点名行
63
+ --dry-run 只打印将要发生什么,**一个字节都不写**
64
+ --help 显示本帮助
65
+
66
+ "改完打不开"的三步:
67
+ ① node scripts/safe-boot.mjs --list 看清有哪些快照、哪份是好状态
68
+ ② node scripts/safe-boot.mjs --restore-last-good 整份恢复(推荐先试这个)
69
+ 或 node scripts/safe-boot.mjs --disable-suspects 只关掉被点名的可疑行(保守做法)
70
+ ③ 重启服务,确认能打开
71
+
72
+ 说明:**完全自动**救活(服务崩了自动恢复)需要服务之外的看门狗(计划任务 / 启动器钩子)——
73
+ 本脚本不安装任何计划任务、不改启动器,是否要那个能力请用户单独决定(见 README「改完打不开怎么办」)。
74
+ `
75
+
76
+ /** 极简参数解析(零依赖;只认白名单形状,未知参数直接报错退出)。 */
77
+ function parseArgs(argv) {
78
+ const out = { rows: [], flags: new Set(), values: new Map() }
79
+ const known = new Set(['home', 'profile', 'id', 'row'])
80
+ for (let i = 0; i < argv.length; i += 1) {
81
+ const arg = argv[i]
82
+ if (!arg.startsWith('--')) throw new Error(`无法识别的参数:${arg}(用 --help 看用法)`)
83
+ const [rawKey, inline] = arg.slice(2).split('=')
84
+ const key = rawKey.trim()
85
+ if (known.has(key)) {
86
+ const value = inline !== undefined ? inline : argv[i + 1]
87
+ if (value === undefined || value.startsWith('--')) throw new Error(`参数 --${key} 需要一个值`)
88
+ if (inline === undefined) i += 1
89
+ if (key === 'row') out.rows.push(value)
90
+ else out.values.set(key, value)
91
+ continue
92
+ }
93
+ out.flags.add(key)
94
+ }
95
+ return out
96
+ }
97
+
98
+ /** profile 目录推导(**只看环境变量与我们自己的派生**,不写死任何本机路径)。
99
+ * `--profile` 优先;给的是目录(绝对路径 / 带分隔符)就直接用。 */
100
+ function profileDirOf({ home, profile, envProfileDir }) {
101
+ const name = profile ?? process.env.DSH_PROFILE ?? null
102
+ if (name !== null && name !== '' && (isAbsolute(name) || name.includes('/') || name.includes('\\'))) return resolve(name)
103
+ if (profile === undefined && typeof envProfileDir === 'string' && envProfileDir.trim() !== '') return resolve(envProfileDir.trim())
104
+ return join(home, 'profiles', (name !== null && name !== '' ? name : 'web'))
105
+ }
106
+
107
+ /** 最近一次「done」的自动预检记录(同一份记录面板也在用)。 */
108
+ function latestPlan() {
109
+ try {
110
+ const records = readStore().records
111
+ return records.find((r) => r.state === 'done' && Array.isArray(r.suspects)) ?? null
112
+ } catch { return null }
113
+ }
114
+
115
+ const line = (text = '') => process.stdout.write(`${text}\n`)
116
+ const zh = (pair) => (pair === null || pair === undefined ? '' : pair.zh)
117
+
118
+ function main() {
119
+ const args = parseArgs(process.argv.slice(2))
120
+ if (args.flags.has('help') || args.flags.size === 0) { process.stdout.write(HELP); return 0 }
121
+ const ACTIONS = ['list', 'restore-last-good', 'disable-suspects', 'mark-good']
122
+ const picked = ACTIONS.filter((a) => args.flags.has(a))
123
+ if (picked.length === 0) { process.stdout.write(HELP); line(`\n没有可执行的动作(可选:${ACTIONS.join(' / ')})`); return 1 }
124
+ if (picked.length > 1) { line(`一次只能做一个动作(收到:${picked.join(' + ')})`); return 1 }
125
+ const home = resolve(args.values.get('home') ?? dshHome())
126
+ // ★ 必须在**读取任何状态之前**把 DSH_HOME 对齐到本次的 --home:本脚本自身的 domain 模块
127
+ // (auto-preflight / safe-boot 的默认路径)走 dshHome() 读环境变量,不对齐就会去读真实用户目录。
128
+ process.env.DSH_HOME = home
129
+ const profileDir = profileDirOf({ home, profile: args.values.get('profile'), envProfileDir: process.env.DSH_PROFILE_DIR })
130
+ const patchPath = join(profileDir, 'cordis.patch.yml')
131
+ const asJson = args.flags.has('json')
132
+ const dryRun = args.flags.has('dry-run')
133
+
134
+ if (args.flags.has('list')) {
135
+ const snapshots = listSnapshots({ home })
136
+ const state = readSafeBootState({ home })
137
+ const plan = latestPlan()
138
+ const retention = applyRetention(snapshots.map((s) => ({ id: s.id, at: s.at, kind: s.kind })))
139
+ if (asJson) {
140
+ line(JSON.stringify({
141
+ ok: true, action: 'list', home, safeBootDir: safeBootDir(home), profileDir, patchPath,
142
+ patchExists: existsSync(patchPath), snapshots, lastGoodId: state.lastGoodId, lastAutoId: state.lastAutoId,
143
+ retention, plan: plan === null ? null : { at: plan.at, state: plan.state, suspects: (plan.suspects ?? []).map((s) => s.rowId) },
144
+ mode: readCompatMode().mode, commands: COMMANDS,
145
+ }, null, 2))
146
+ return 0
147
+ }
148
+ line(`安全启动材料目录:${safeBootDir(home)}`)
149
+ line(`profile:${profileDir}`)
150
+ line(`补丁:${patchPath}${existsSync(patchPath) ? '' : '(**文件不存在**)'}`)
151
+ if (existsSync(patchPath)) {
152
+ const check = validatePatchYaml(readFileSync(patchPath, 'utf8'))
153
+ line(`当前补丁结构校验:${check.ok ? '通过' : `**不通过** —— ${check.problems.slice(0, 3).join(';')}`}`)
154
+ }
155
+ line(`运行模式:${readCompatMode().mode}`)
156
+ line('')
157
+ if (snapshots.length === 0) {
158
+ line('还没有任何快照(服务从未在"正常 + 环境未变"的状态下记录过)。')
159
+ line('能做的:先把服务拉起来(或用上一版框架),起来后本控制台会自动记录第一份安全启动材料。')
160
+ } else {
161
+ line(`快照(新 → 旧,共 ${snapshots.length} 份,保留最近 ${retention.keep.length} 份):`)
162
+ for (const s of snapshots) {
163
+ const badge = s.kind === 'good' ? '【良好·永不删】' : ''
164
+ line(` ${s.id} ${badge}`)
165
+ line(` 时间 ${s.createdAt ?? '?'} · 框架 ${s.frameworkVersion ?? '未知'} · 行 ${s.rowCount}(启用 ${s.enabledCount} / 禁用 ${s.disabledCount})`)
166
+ line(` sha256 ${String(s.patchSha256 ?? '').slice(0, 16)}…${s.patchPath === patchPath ? '' : ` · 补丁 ${s.patchPath ?? '?'}`}`)
167
+ }
168
+ if (state.lastGoodId !== null) line(`\n已标记为良好的快照:${state.lastGoodId}`)
169
+ line(`\n恢复整份:${COMMANDS.restore}`)
170
+ line(`只禁可疑行:${COMMANDS.disable}`)
171
+ }
172
+ if (plan !== null) {
173
+ const suspects = (plan.suspects ?? []).map((s) => s.rowId)
174
+ line(`\n自动预检计划(${new Date(Number(plan.at ?? 0)).toISOString()}):点名 ${suspects.length} 行${suspects.length > 0 ? ` — ${suspects.join('、')}` : ''}`)
175
+ } else {
176
+ line('\n自动预检还没有结论(服务起来后第一次状态查询会自动跑一次)。')
177
+ }
178
+ return 0
179
+ }
180
+
181
+ if (args.flags.has('restore-last-good')) {
182
+ const id = args.values.get('id') ?? null
183
+ if (dryRun) {
184
+ const snapshots = listSnapshots({ home })
185
+ line(`[dry-run] 将恢复:${id ?? '(自动挑选:优先良好,其次最新)'} → ${patchPath}`)
186
+ line(`[dry-run] 可用快照:${snapshots.map((s) => `${s.id}${s.kind === 'good' ? '(good)' : ''}`).join(', ') || '(无)'}`)
187
+ return 0
188
+ }
189
+ const result = restoreLastGood({ patchPath, id, home })
190
+ if (asJson) { line(JSON.stringify(result, null, 2)); return result.ok === true ? 0 : 1 }
191
+ if (result.ok !== true) {
192
+ line(`恢复失败(${result.code ?? 'error'}):${result.error ?? '未知原因'}`)
193
+ line(`先看清有什么:${COMMANDS.list}`)
194
+ return 1
195
+ }
196
+ line(`恢复${result.changed === true ? '完成' : '无需进行(已一致)'}:快照 ${result.snapshot?.id ?? '?'}(${result.snapshot?.createdAt ?? '?'})`)
197
+ line(`补丁:${result.patchPath}`)
198
+ if (result.backupPath !== null && result.backupPath !== undefined) line(`改前备份:${result.backupPath}`)
199
+ line(`sha256:${result.sha256 ?? '?'}`)
200
+ line(zh(result.note))
201
+ line('下一步:重启服务,确认能打开。')
202
+ return 0
203
+ }
204
+
205
+ if (args.flags.has('disable-suspects')) {
206
+ const plan = latestPlan()
207
+ const fromPlan = args.flags.has('all-plan') || args.rows.length === 0
208
+ const rowIds = fromPlan ? ((plan?.suspects ?? []).map((s) => s.rowId)) : args.rows
209
+ if (rowIds.length === 0) {
210
+ line('没有可点名的行:自动预检还没跑出计划,且没有给 --row。')
211
+ line(`可以先跑一次状态查询让自动预检出结论,或显式点名:${COMMANDS.disable} --row <rowId>`)
212
+ return 1
213
+ }
214
+ if (dryRun) {
215
+ line(`[dry-run] 将给以下行追加 disabled: true(不删任何行):${rowIds.join('、')}`)
216
+ line(`[dry-run] 补丁:${patchPath}`)
217
+ return 0
218
+ }
219
+ const result = disableSuspects({ patchPath, rowIds, home })
220
+ if (asJson) { line(JSON.stringify(result, null, 2)); return result.ok === true ? 0 : 1 }
221
+ if (result.ok !== true) {
222
+ line(`禁用失败(${result.code ?? 'error'}):${result.error ?? '未知原因'}`)
223
+ return 1
224
+ }
225
+ if (result.changed !== true) {
226
+ line(zh(result.note))
227
+ for (const s of result.skipped ?? []) line(` 跳过 ${s.rowId}:${s.reason}`)
228
+ return 0
229
+ }
230
+ line(`已只给 ${result.added.length} 行追加 disabled: true(未删任何行):${result.added.join('、')}`)
231
+ for (const s of result.skipped ?? []) line(` 跳过 ${s.rowId}:${s.reason}`)
232
+ line(`改前备份:${result.backupPath}`)
233
+ line(`剩余部分逐字节未变:${result.prefixSame === true ? '是' : '**否(请检查)**'}`)
234
+ line('下一步:重启服务,确认能打开。')
235
+ line(`仍未打开 → 改用整份恢复:${COMMANDS.restore}`)
236
+ return 0
237
+ }
238
+
239
+ if (args.flags.has('mark-good')) {
240
+ const id = args.values.get('id') ?? null
241
+ if (dryRun) { line(`[dry-run] 将把 ${id ?? '(最新一份)'} 标记为良好`); return 0 }
242
+ const result = markSnapshotGood({ id, home })
243
+ if (asJson) { line(JSON.stringify(result, null, 2)); return result.ok === true ? 0 : 1 }
244
+ if (result.ok !== true) { line(`标记失败:${result.error ?? '未知原因'}`); return 1 }
245
+ line(`已标记为良好:${result.snapshot?.id ?? '?'}(${result.snapshot?.createdAt ?? '?'})`)
246
+ line(zh(result.note))
247
+ return 0
248
+ }
249
+
250
+ process.stdout.write(HELP)
251
+ line(`\n未识别的动作(核心行保护名单示例:${[...CORE_PATCH_ROW_IDS].slice(0, 3).join('、')}…)`)
252
+ return 1
253
+ }
254
+
255
+ try {
256
+ process.exitCode = main()
257
+ } catch (error) {
258
+ line(`safe-boot 执行失败:${error instanceof Error ? error.message : String(error)}`)
259
+ process.exitCode = 2
260
+ }