@noob-stupid/dsh-plugin-console 0.5.49 → 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.
- package/README.md +24 -0
- package/README.zh.md +20 -0
- package/lib/server/domain/fw-preflight.js +351 -0
- package/lib/server/infra/exec.js +32 -7
- package/lib/server/infra/fw-closure.js +142 -0
- package/lib/server/infra/fw-root-detect.js +209 -0
- package/lib/server/infra/npm-cache.js +173 -0
- package/lib/server/infra/pnpm-release-gate.js +15 -0
- package/package.json +4 -2
- package/scripts/fw-preflight.mjs +186 -0
- package/scripts/safe-boot.mjs +260 -0
|
@@ -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
|
+
}
|