@noob-stupid/dsh-plugin-console 0.5.49 → 0.5.51

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,246 @@
1
+ #!/usr/bin/env node
2
+ // fw-watchdog.mjs —— DSH 框架闭包看门狗(不改官方代码的"崩溃自愈")
3
+ //
4
+ // ── 为什么需要它 ────────────────────────────────────────────────────────────
5
+ // 真机事故(2026-10-14):框架树里 `@deepseek-ai/cordis` 的 junction 还在、但 `.pnpm`
6
+ // 实体被掏空;`dsh-llm`/`dsh-tools`/`dsh-web` 成了空目录 ⇒ dsh 启动即 54 个插件
7
+ // failed to import ⇒ 服务进程 exit code=1 ⇒ 桌面端弹「应用无法启动或已意外停止」。
8
+ //
9
+ // 官方启动链(已只读取证)**没有**"启动前执行外部命令"的钩子:
10
+ // DeepSeek Harness.exe → dsh-desktop-host/lib/cli.js → runCli()(dsh/lib/bin.js:207)
11
+ // → case "profile": runProfile() → 失败 → reportStartupFailure() → process.exit(1)
12
+ // · bin.js 全文 254 行,读的环境变量:零;
13
+ // · dsh-app-boot 只有 DSH_SNAPSHOT=replay(换配置)与 loadEnv()(读 .env),
14
+ // 而 .env 能设的名字有黑名单(PATH/HOME/NODE_OPTIONS/…)——只能设配置,不能"启动前跑脚本";
15
+ // · 失败路径不 spawn 任何外部命令。
16
+ // ⇒ 想在"每次 dsh 启动前自动修"必须改官方外壳(且一升级就被覆盖)。
17
+ // 所以本脚本走另一条路:**观测一个合法事实** —— 外壳进程还在,而它的 **HOST 服务进程没了**。
18
+ // 官方自己的崩溃恢复只知道"重启",不知道"修包";看门狗补的正是这一段:
19
+ // 它把地面修平,官方下一次重启自然成功 —— 用户观感就是"崩了自己又起来了"。
20
+ //
21
+ // ── 判据(★ 2026-10-14 修正:一开始用"端口无监听",那是错的)────────────────
22
+ // 为什么不能用端口:桌面实例监听 19387,3080 是 web profile 的端口;用固定端口探针
23
+ // 会在正常运行时恒判"服务没起来" ⇒ 每轮白跑一次全量闭包检查、日志刷屏。
24
+ // 现在的判据(进程树事实,与端口无关):
25
+ // ① 外壳进程在跑(`DeepSeek Harness.exe` 且命令行**不含** `--type=`);
26
+ // ② 它的 HOST 服务子进程**不在**(命令行含 `dsh-desktop-host/lib/index.js` 的那个);
27
+ // ③ 连续 N 轮(默认 2 轮 ≈ 40s)都如此 ⇒ 才动手(避开"正在启动/正在重启"的窗口)。
28
+ // 动作:`fw-preflight --check` → 闭包坏 ⇒ `--repair` → `--check` 复验。
29
+ // 修完**不自己拉起服务**:官方崩溃恢复/relaunch 会自己重试,我们只把地面修平。
30
+ // 红线:不杀进程、不抢端口、不 spawn dsh、不改官方任何文件。
31
+ //
32
+ // ── 用法 ──────────────────────────────────────────────────────────────────
33
+ // node fw-watchdog.mjs --daemon 后台守护(**计划任务用这个**):自己脱离、无控制台窗口、幂等
34
+ // node fw-watchdog.mjs --status 只打印当前判据事实,不动作
35
+ // node fw-watchdog.mjs --tick 跑一次完整逻辑(演练 / 手动触发)
36
+ // node fw-watchdog.mjs --once 诊断:判据 + 跑一次 --check 并打印结论
37
+ // node fw-watchdog.mjs 前台常驻(Ctrl-C 退出;排查时用)
38
+ // 日志:`<DSH_HOME>/plugin-console/fw-watchdog.log`(追加式,只在有事时写)
39
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
40
+ import { homedir } from 'node:os'
41
+ import { join } from 'node:path'
42
+ import { spawnSync } from 'node:child_process'
43
+ import { fileURLToPath } from 'node:url'
44
+
45
+ const ARGV = process.argv.slice(2)
46
+ const HAS = (f) => ARGV.includes(f)
47
+ const HOME = homedir()
48
+ const DSH_HOME = process.env.DSH_HOME?.trim() || join(HOME, '.dsh')
49
+ const LOG_DIR = join(DSH_HOME, 'plugin-console')
50
+ const LOG = join(LOG_DIR, 'fw-watchdog.log')
51
+ const INTERVAL_MS = Number(process.env.DSH_WATCHDOG_INTERVAL_MS ?? 20000)
52
+ const STRIKES = Number(process.env.DSH_WATCHDOG_STRIKES ?? 2)
53
+ const EXE = process.platform === 'win32' ? 'DeepSeek Harness.exe' : null
54
+
55
+ /** 找到本机已装的 fw-preflight.mjs。
56
+ * 扫描各个 profile 的 node_modules/<包>/scripts/(不写死 desktop —— 用户可能只装 web profile,
57
+ * 或 profile 名不同)。★ 注意:本注释里**不能**出现星号后跟斜杠那种通配写法,
58
+ * 那会提前闭合块注释(2026-10-14 实测踩到:脚本因此整份 SyntaxError、看门狗从未真正运行过)。 */
59
+ function findPreflight() {
60
+ if (typeof process.env.DSH_WATCHDOG_CONSOLE === 'string' && process.env.DSH_WATCHDOG_CONSOLE !== '') {
61
+ return join(process.env.DSH_WATCHDOG_CONSOLE, 'scripts', 'fw-preflight.mjs')
62
+ }
63
+ // 本脚本自己就在 <包>/scripts/ 下时,优先用同包那份(最不容易错配)
64
+ try {
65
+ const self = fileURLToPath(import.meta.url)
66
+ const samePkg = join(self, '..', 'fw-preflight.mjs')
67
+ if (existsSync(samePkg)) return samePkg
68
+ } catch {}
69
+ const profilesDir = join(DSH_HOME, 'profiles')
70
+ let names = []
71
+ try { names = readdirSync(profilesDir) } catch { return join(profilesDir, 'desktop', 'node_modules', '@noob-stupid', 'dsh-plugin-console', 'scripts', 'fw-preflight.mjs') }
72
+ for (const name of names) {
73
+ const p = join(profilesDir, name, 'node_modules', '@noob-stupid', 'dsh-plugin-console', 'scripts', 'fw-preflight.mjs')
74
+ if (existsSync(p)) return p
75
+ }
76
+ return join(profilesDir, 'desktop', 'node_modules', '@noob-stupid', 'dsh-plugin-console', 'scripts', 'fw-preflight.mjs')
77
+ }
78
+ const PREFLIGHT = findPreflight()
79
+
80
+ const log = (line) => {
81
+ const stamp = new Date().toISOString().replace('T', ' ').slice(0, 19)
82
+ const text = `[${stamp}] ${line}\n`
83
+ try { mkdirSync(LOG_DIR, { recursive: true }); appendFileSync(LOG, text, 'utf8') } catch {}
84
+ process.stdout.write(text)
85
+ }
86
+
87
+ /** 进程树事实(一次 PowerShell 调用取全,避免每条判据各 spawn 一次)。
88
+ * ★ 坑(2026-10-14 实测踩到):PowerShell 里 `$host` 是**只读自动变量**,赋值直接报
89
+ * "Cannot overwrite variable Host because it is read-only or constant" ⇒ 探针恒返回 0
90
+ * ⇒ 看门狗永远以为"外壳没在跑"、静默失效。所以变量名一律用 `$hostN`/`$shellN`。
91
+ * 同族坑:$HOME / $PID / $PSHOME 等一律不许赋值。 */
92
+ function probeProcesses() {
93
+ // 测试注入缝(只为离线演练;生产不设这个变量)
94
+ const fake = process.env.DSH_WATCHDOG_FAKE_PROCS
95
+ if (typeof fake === 'string' && /^\d+\|\d+$/u.test(fake)) {
96
+ const [s, h] = fake.split('|').map(Number)
97
+ return { shell: s, host: h, raw: `${s}|${h} (injected)` }
98
+ }
99
+ if (EXE === null) return { shell: 0, host: 0, raw: 'non-win32' }
100
+ const ps = [
101
+ `$all = @(Get-CimInstance Win32_Process -Filter "Name='${EXE}'" -ErrorAction SilentlyContinue)`,
102
+ `$shellN = @($all | Where-Object { $_.CommandLine -notmatch '--type=' }).Count`,
103
+ `$hostN = @($all | Where-Object { $_.CommandLine -match 'dsh-desktop-host[\\\\/]lib[\\\\/]index\\.js' }).Count`,
104
+ `Write-Output ($shellN.ToString() + '|' + $hostN.ToString())`,
105
+ ].join('; ')
106
+ const r = spawnSync('powershell', ['-NoProfile', '-Command', ps], { encoding: 'utf8', timeout: 20000, windowsHide: true })
107
+ const m = /(\d+)\|(\d+)/u.exec(String(r.stdout ?? ''))
108
+ return { shell: m ? Number(m[1]) : 0, host: m ? Number(m[2]) : 0, raw: String(r.stdout ?? '').trim() }
109
+ }
110
+
111
+ const preflight = (mode) => {
112
+ if (!existsSync(PREFLIGHT)) {
113
+ log(`找不到 fw-preflight:${PREFLIGHT}(控制台未安装或路径变了)—— 本轮跳过`)
114
+ return { ok: false, stdout: '', missing: true }
115
+ }
116
+ const r = spawnSync(process.execPath, [PREFLIGHT, mode], { encoding: 'utf8', timeout: 180000, windowsHide: true })
117
+ return { ok: r.status === 0, stdout: String(r.stdout ?? '') + String(r.stderr ?? ''), missing: false }
118
+ }
119
+
120
+ /** 闭包是否坏。判据取自 fw-preflight 自己那行结论:「走了 N 个包,M 个解析不了」,M>0 即坏。
121
+ * 认不出格式时按"坏"处理 —— 宁可多查一次,也不放过真事故。 */
122
+ function closureBroken(out) {
123
+ const m = /走了\s*(\d+)\s*个包[,,]\s*(\d+)\s*个解析不了/u.exec(out)
124
+ if (m) return Number(m[2]) > 0
125
+ if (/闭包完整/u.test(out)) return false
126
+ return true
127
+ }
128
+
129
+ let strikes = 0
130
+ let repairing = false
131
+
132
+ async function tick() {
133
+ if (repairing) return
134
+ try {
135
+ const p = probeProcesses()
136
+ if (p.shell === 0) { strikes = 0; return } // 外壳没开 = 用户没在用,不管
137
+ if (p.host > 0) { // HOST 在 = 服务活着 = 正常
138
+ if (strikes !== 0) log('HOST 服务进程已回来 —— 恢复正常,看门狗回待命')
139
+ strikes = 0
140
+ return
141
+ }
142
+ strikes += 1
143
+ if (strikes < STRIKES) return
144
+ log(`外壳在跑(${p.shell}) 但 HOST 服务进程不在(连续 ${strikes} 轮)⇒ 开始检查框架闭包`)
145
+ const check = preflight('--check')
146
+ if (check.missing) { strikes = 0; return }
147
+ if (!closureBroken(check.stdout)) {
148
+ log('闭包本身是好的 ⇒ 起不来另有原因(不是包被掏空),本轮不动作,等官方自己的恢复逻辑')
149
+ strikes = 0
150
+ return
151
+ }
152
+ log('闭包确实坏了 ⇒ 执行 --repair(从本地 npm 缓存按 integrity 离线装回,不联网)')
153
+ repairing = true
154
+ try {
155
+ const fix = preflight('--repair')
156
+ log(`--repair 退出码=${fix.ok ? 0 : '非0'}`)
157
+ const re = preflight('--check')
158
+ const good = re.ok && !closureBroken(re.stdout)
159
+ log(good
160
+ ? '✅ 修复成功:闭包复验通过。服务交由官方自己的恢复/relaunch 拉起(本脚本不抢端口、不 spawn dsh)。'
161
+ : '❌ 修复后复验仍未通过 —— 需人工介入(手动跑 --repair 看详细输出;日志在 ~/.dsh/plugin-console/)')
162
+ } finally { repairing = false; strikes = 0 }
163
+ } catch (error) {
164
+ log(`本轮异常(不影响下一轮):${String(error?.message ?? error)}`)
165
+ }
166
+ }
167
+
168
+ // ── 后台守护(计划任务用):自己脱离,不留控制台窗口 ──────────────────────────
169
+ // ★ 为什么要这一模式(2026-10-14 实测):计划任务直接跑 node.exe 会让 Windows 弹出**可见的
170
+ // 控制台窗口**(本机实测桌面出现一个 Windows Terminal);改用 .vbs 启动器(window style 0)
171
+ // 又踩到 wscript 挂住、拉不起子进程。最稳的是让脚本**自己以 detached 方式再拉起一份**,
172
+ // 并让它 `stdio: 'ignore' + windowsHide: true`(子进程没有控制台可挂)⇒ 任务那条命令立刻返回,
173
+ // 真正干活的那份在后台无窗口运行。重复启动由 pid 锁挡住。
174
+ if (HAS('--daemon')) {
175
+ const PID_FILE = join(LOG_DIR, 'fw-watchdog.pid')
176
+ const alive = (pid) => {
177
+ if (!Number.isInteger(pid) || pid <= 0) return false
178
+ try { process.kill(pid, 0); return true } catch { return false }
179
+ }
180
+ let already = null
181
+ try { already = Number(String(readFileSync(PID_FILE, 'utf8')).trim()) } catch {}
182
+ if (alive(already)) {
183
+ log(`后台看门狗已在运行(pid ${already})—— 本次不重复启动`)
184
+ process.exit(0)
185
+ }
186
+ const { spawn } = await import('node:child_process')
187
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url)], {
188
+ detached: true, stdio: 'ignore', windowsHide: true,
189
+ env: { ...process.env },
190
+ })
191
+ child.unref()
192
+ try { mkdirSync(LOG_DIR, { recursive: true }); writeFileSync(PID_FILE, String(child.pid), 'utf8') } catch {}
193
+ log(`已用后台守护模式拉起看门狗(pid ${child.pid},无窗口)`)
194
+ process.exit(0)
195
+ }
196
+
197
+ // ── 单轮完整逻辑(演练 / 计划任务手动触发)──
198
+ if (HAS('--tick')) {
199
+ log('── 单轮完整逻辑(--tick)──')
200
+ await tick()
201
+ log('── 本轮结束 ──')
202
+ process.exit(0)
203
+ }
204
+
205
+ // ── 状态 / 诊断 ──
206
+ if (HAS('--status') || HAS('--once')) {
207
+ const p = probeProcesses()
208
+ log(HAS('--once') ? '── 诊断(--once)──' : '── 状态(--status)──')
209
+ log(`外壳进程: ${p.shell} 个 HOST 服务进程: ${p.host} 个`)
210
+ log(`判定:${p.shell === 0 ? '外壳没在跑(不管)' : p.host > 0 ? '服务活着(正常)' : '★ 外壳在跑但服务不在(事故形态)'}`)
211
+ log(`预检脚本:${PREFLIGHT}`)
212
+ if (HAS('--once')) {
213
+ const check = preflight('--check')
214
+ log(`fw-preflight --check 退出码=${check.ok ? 0 : '非0'}${check.missing ? '(脚本缺失)' : ''}`)
215
+ if (!check.missing) {
216
+ log(check.stdout.trim().split('\n').map((l) => ' ' + l).join('\n'))
217
+ log(`闭包判定:${closureBroken(check.stdout) ? '★ 坏' : '好'}`)
218
+ }
219
+ }
220
+ process.exit(0)
221
+ }
222
+
223
+ // ── 常驻 ──
224
+ // ★ 主入口守卫(2026-10-14 补):没有它时,任何人 `import()` 本文件都会**意外启动常驻看门狗**
225
+ // (验证语法时就踩到过:一个 import 把看门狗跑起来了)。只在真正作为脚本执行时才启动。
226
+ if (import.meta.main !== true) {
227
+ process.exit(0)
228
+ }
229
+
230
+ log(`看门狗启动:间隔=${INTERVAL_MS}ms 连续${STRIKES}轮 预检=${PREFLIGHT}`)
231
+ log('判据:外壳在跑 + HOST 服务进程不在;只修框架闭包,不杀进程、不抢端口、不改官方文件')
232
+ // 记下自己的 pid(`--daemon` 靠它防重复启动;退出时清掉)
233
+ try { mkdirSync(LOG_DIR, { recursive: true }); writeFileSync(join(LOG_DIR, 'fw-watchdog.pid'), String(process.pid), 'utf8') } catch {}
234
+ await tick()
235
+ const timer = setInterval(tick, INTERVAL_MS)
236
+ const stop = () => {
237
+ clearInterval(timer)
238
+ try {
239
+ const f = join(LOG_DIR, 'fw-watchdog.pid')
240
+ if (String(readFileSync(f, 'utf8')).trim() === String(process.pid)) rmSync(f, { force: true })
241
+ } catch {}
242
+ log('看门狗停止')
243
+ process.exit(0)
244
+ }
245
+ process.on('SIGINT', stop)
246
+ process.on('SIGTERM', stop)
@@ -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
+ }