dsh-recency-guard 0.11.4 → 0.16.0
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 +314 -382
- package/assets/icon-256.png +0 -0
- package/bin/rescue.mjs +269 -9
- package/dist/client.js +2504 -500
- package/dist/index.js +4706 -2218
- package/dsh-plugin.json +142 -0
- package/locale/en.json +145 -9
- package/locale/zh.json +145 -9
- package/package.json +34 -20
- package/screenshots.json +3 -0
|
Binary file
|
package/bin/rescue.mjs
CHANGED
|
@@ -16,12 +16,34 @@
|
|
|
16
16
|
* node bin/rescue.mjs decision --profile-dir <profile目录>
|
|
17
17
|
* node bin/rescue.mjs archives --profile-dir <profile目录>
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* 只读:本脚本的**默认行为绝不写任何文件** —— 逃生通道的第一纪律是
|
|
20
|
+
* 「别把现场弄乱」。写操作只在显式 `apply` / `restore` **且带 `--yes`** 时发生,
|
|
21
|
+
* 并且是两阶段(见下),随时可以按原位置精确回插。
|
|
21
22
|
*
|
|
22
|
-
*
|
|
23
|
+
* ## 可写模式(T14-07):为什么逃生通道需要写
|
|
24
|
+
*
|
|
25
|
+
* 宿主崩到起不来时,面板/工具/路由全部不可达 —— 而「把决策里那个插件从
|
|
26
|
+
* profile 的 bundle 列表里摘掉」正是让宿主重新起来的手段。只读逃生通道
|
|
27
|
+
* 在这种情况下只能告诉你「它坏了」,不能救。
|
|
28
|
+
*
|
|
29
|
+
* ## 两阶段 + 中断恢复(借鉴 compat-guardian,但按本插件的产物形态重写)
|
|
30
|
+
*
|
|
31
|
+
* 1. 写 **prepared 记录**(含 beforeHash / afterHash / 每个 bundle 的原 index);
|
|
32
|
+
* 2. 原子改 profile(临时文件 + rename);
|
|
33
|
+
* 3. 记录标 `applied`。
|
|
34
|
+
*
|
|
35
|
+
* 两步之间断电/被杀 ⇒ 下次 `restore --resume` 读到 `prepared` 记录,且
|
|
36
|
+
* 当前 package.json 的哈希**等于 afterHash** ⇒ 说明第 2 步其实已完成,
|
|
37
|
+
* 于是按中断后已生效处理、直接回插。若哈希仍等于 beforeHash ⇒ 第 2 步没发生,
|
|
38
|
+
* 记录作废(不动 profile)。两条都以**哈希**判定,不靠人回忆。
|
|
39
|
+
*
|
|
40
|
+
* 回插按**原 index 升序**执行:一次只改一个位置,index 才不会互相挤位。
|
|
41
|
+
*
|
|
42
|
+
* 退出码:0 = 正常;1 = 参数错误;2 = 发现未恢复的崩溃状态(供脚本联动);
|
|
43
|
+
* 3 = 拒绝(现场已变 / 记录不可信)。
|
|
23
44
|
*/
|
|
24
|
-
import {
|
|
45
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
46
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
25
47
|
import { join, resolve } from 'node:path'
|
|
26
48
|
|
|
27
49
|
const GUARD_DIR = '.recency-guard'
|
|
@@ -38,6 +60,11 @@ function parseArgs(argv) {
|
|
|
38
60
|
if (a === '--profile-dir') args.profileDir = argv[++i]
|
|
39
61
|
else if (a === '--project') args.project = argv[++i]
|
|
40
62
|
else if (a === '--limit') args.limit = Number(argv[++i])
|
|
63
|
+
else if (a === '--bundle') args.bundle = argv[++i]
|
|
64
|
+
else if (a === '--reason') args.reason = argv[++i]
|
|
65
|
+
else if (a === '--id') args.id = argv[++i]
|
|
66
|
+
else if (a === '--resume') args.resume = true
|
|
67
|
+
else if (a === '--yes') args.yes = true
|
|
41
68
|
else if (a === '--help' || a === '-h') args.help = true
|
|
42
69
|
else args._.push(a)
|
|
43
70
|
}
|
|
@@ -46,23 +73,38 @@ function parseArgs(argv) {
|
|
|
46
73
|
|
|
47
74
|
function usage() {
|
|
48
75
|
console.log(`
|
|
49
|
-
recency-guard rescue ——
|
|
76
|
+
recency-guard rescue —— 宿主崩溃后的离线守护产物查询与逃生操作
|
|
50
77
|
|
|
51
78
|
用法:
|
|
52
79
|
node bin/rescue.mjs <command> --profile-dir <profile目录> [选项]
|
|
53
80
|
|
|
54
|
-
|
|
81
|
+
只读命令(默认,绝不写盘):
|
|
55
82
|
status 崩溃状态、守护决策、自动关闭名单
|
|
56
83
|
history journal 事件时间线(--limit N,默认 40)
|
|
57
84
|
decision 预启动决策文件全文(守护行正在执行什么)
|
|
58
85
|
archives 归档清单(带 manifest 摘要)
|
|
86
|
+
rescues 两阶段 rescue 记录清单
|
|
87
|
+
|
|
88
|
+
可写命令(必须显式 --yes;不带只打印计划 = dry-run):
|
|
89
|
+
apply 把决策里的 disable 名单(或 --bundle)从 profile bundles 摘掉
|
|
90
|
+
restore 按记录里的原 index 精确回插(支持中断后恢复)
|
|
59
91
|
|
|
60
92
|
选项:
|
|
61
93
|
--profile-dir <dir> profile 根目录(含 .recency-guard/)
|
|
62
94
|
--project <key|label> 多项目 profile 时指定分区(默认取最近活跃的)
|
|
63
95
|
--limit <n> history 条数
|
|
96
|
+
--bundle a,b apply 的目标(默认取分区 decision.json 的 disable)
|
|
97
|
+
--reason <text> 写入 rescue 记录的原因(可审计)
|
|
98
|
+
--id <uuid> restore 指定记录(默认取最新可回插的一条)
|
|
99
|
+
--resume restore 中断恢复(等价于默认的「最新可回插」)
|
|
100
|
+
--yes 真正落盘(缺省 = dry-run)
|
|
101
|
+
-h, --help 显示本帮助
|
|
102
|
+
|
|
103
|
+
两阶段承诺:apply 先写 prepared 记录(含 beforeHash/afterHash/原 index),
|
|
104
|
+
再原子改 package.json,最后标 applied。任何一步被中断,restore --resume
|
|
105
|
+
按哈希判定「第二步是否已生效」,再按原 index 升序回插。
|
|
64
106
|
|
|
65
|
-
退出码:0 正常;1 参数错误;2
|
|
107
|
+
退出码:0 正常;1 参数错误;2 发现未恢复的崩溃状态;3 拒绝(现场已变)。
|
|
66
108
|
`)
|
|
67
109
|
}
|
|
68
110
|
|
|
@@ -242,6 +284,220 @@ function cmdArchives(partition) {
|
|
|
242
284
|
return 0
|
|
243
285
|
}
|
|
244
286
|
|
|
287
|
+
/* ------------------------------------------------------------------------ *
|
|
288
|
+
* 可写逃生通道(T14-07):apply / restore
|
|
289
|
+
*
|
|
290
|
+
* 设计取舍(与 compat-guardian 的差别)
|
|
291
|
+
* ------------------------------------
|
|
292
|
+
* 参考实现把「隔离」写进 `manifest.dsh.profile.bundles`;本插件的 profile 是
|
|
293
|
+
* 同一个形态,但**默认不写**:`apply` 不带 `--yes` 只打印计划。
|
|
294
|
+
* 理由:宿主起不来时人最容易慌张地乱改,先给一份「将会发生什么」比直接动
|
|
295
|
+
* 文件更接近逃生通道的本分。
|
|
296
|
+
*
|
|
297
|
+
* 记录放 `<profile>/.recency-guard/rescue/<uuid>.json`(profile 级,不是分区级):
|
|
298
|
+
* 它改的是 profile 的 package.json,跨分区共用一份,放分区里会让另一个
|
|
299
|
+
* 工作区看不到「profile 被谁改过」。
|
|
300
|
+
* ------------------------------------------------------------------------ */
|
|
301
|
+
|
|
302
|
+
/** 记录目录(profile 级)。 */
|
|
303
|
+
function rescueDir(profileDir) {
|
|
304
|
+
return join(profileDir, GUARD_DIR, 'rescue')
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** sha256(内置 crypto,零依赖)。 */
|
|
308
|
+
function digest(text) {
|
|
309
|
+
return createHash('sha256').update(text).digest('hex')
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** 读文本(不存在 ⇒ undefined,与「空文件」区分)。 */
|
|
313
|
+
function readTextSafe(file) {
|
|
314
|
+
try {
|
|
315
|
+
return readFileSync(file, 'utf8')
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (error.code === 'ENOENT') return undefined
|
|
318
|
+
throw error
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** 原子写(同目录临时文件 + rename;跨目录 rename 不保证原子)。 */
|
|
323
|
+
function atomicWrite(file, content) {
|
|
324
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.rescue.tmp`
|
|
325
|
+
writeFileSync(temporary, content, 'utf8')
|
|
326
|
+
try {
|
|
327
|
+
renameSync(temporary, file)
|
|
328
|
+
} catch (error) {
|
|
329
|
+
try {
|
|
330
|
+
unlinkSync(temporary)
|
|
331
|
+
} catch {
|
|
332
|
+
/* 清理失败不改变主错误 */
|
|
333
|
+
}
|
|
334
|
+
throw error
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 读 profile 的 bundle 列表(不存在 ⇒ null,不猜)。 */
|
|
339
|
+
function readProfileBundleList(profileDir) {
|
|
340
|
+
const manifestPath = join(profileDir, 'package.json')
|
|
341
|
+
const text = readTextSafe(manifestPath)
|
|
342
|
+
if (text === undefined) return null
|
|
343
|
+
let manifest
|
|
344
|
+
try {
|
|
345
|
+
manifest = JSON.parse(text)
|
|
346
|
+
} catch {
|
|
347
|
+
return null
|
|
348
|
+
}
|
|
349
|
+
const bundles = manifest?.dsh?.profile?.bundles
|
|
350
|
+
if (!Array.isArray(bundles)) return null
|
|
351
|
+
return { manifestPath, text, manifest, bundles }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 目标 bundle:显式 --bundle 优先,否则取该分区决策文件里的 disable 名单。 */
|
|
355
|
+
function resolveTargets(args, profileDir, partition) {
|
|
356
|
+
if (typeof args.bundle === 'string' && args.bundle.trim() !== '') {
|
|
357
|
+
return args.bundle.split(',').map((x) => x.trim()).filter(Boolean)
|
|
358
|
+
}
|
|
359
|
+
const decision = readJsonSafe(join(partition.dir, 'decision.json'))
|
|
360
|
+
const disable = Array.isArray(decision?.disable) ? decision.disable : []
|
|
361
|
+
return disable.filter((x) => typeof x === 'string')
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** 列出 rescue 记录(新 → 旧)。 */
|
|
365
|
+
function listRescueRecords(profileDir) {
|
|
366
|
+
const dir = rescueDir(profileDir)
|
|
367
|
+
if (!existsSync(dir)) return []
|
|
368
|
+
return readdirSync(dir)
|
|
369
|
+
.filter((name) => name.endsWith('.json'))
|
|
370
|
+
.map((name) => ({ path: join(dir, name), record: readJsonSafe(join(dir, name)) }))
|
|
371
|
+
.filter((entry) => entry.record !== null)
|
|
372
|
+
.sort((a, b) => String(b.record.createdAt).localeCompare(String(a.record.createdAt)))
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function cmdApply(args, profileDir, partition) {
|
|
376
|
+
const state = readProfileBundleList(profileDir)
|
|
377
|
+
if (state === null) fail(`profile 没有 dsh.profile.bundles(${join(profileDir, 'package.json')})`, 3)
|
|
378
|
+
const targets = resolveTargets(args, profileDir, partition)
|
|
379
|
+
if (targets.length === 0) {
|
|
380
|
+
console.log('没有要隔离的 bundle:决策文件里 disable 为空,且未指定 --bundle。')
|
|
381
|
+
return 0
|
|
382
|
+
}
|
|
383
|
+
const removed = targets
|
|
384
|
+
.map((name) => ({ name, version: undefined, index: state.bundles.indexOf(name) }))
|
|
385
|
+
.filter((item) => item.index >= 0)
|
|
386
|
+
const unknown = targets.filter((name) => !state.bundles.includes(name))
|
|
387
|
+
if (removed.length === 0) {
|
|
388
|
+
console.log(`指定的 bundle 都不在 profile 的 bundles 列表里:${targets.join(', ')}`)
|
|
389
|
+
return 3
|
|
390
|
+
}
|
|
391
|
+
const selected = state.bundles.filter((name) => !targets.includes(name))
|
|
392
|
+
const nextManifest = { ...state.manifest, dsh: { ...state.manifest.dsh, profile: { ...state.manifest.dsh.profile, bundles: selected } } }
|
|
393
|
+
const after = `${JSON.stringify(nextManifest, null, 2)}\n`
|
|
394
|
+
|
|
395
|
+
console.log(`拟隔离 ${removed.length} 个 bundle(原位置将被记录,可 restore 精确回插):`)
|
|
396
|
+
for (const item of removed) console.log(` - ${item.name}(原 index ${item.index})`)
|
|
397
|
+
if (unknown.length > 0) console.log(`跳过(不在 bundles 列表):${unknown.join(', ')}`)
|
|
398
|
+
|
|
399
|
+
if (args.yes !== true) {
|
|
400
|
+
// 默认只读:打印计划后退出,绝不写盘。
|
|
401
|
+
console.log('\n(dry-run)加 --yes 才会真正修改 profile。')
|
|
402
|
+
return 0
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const record = {
|
|
406
|
+
schemaVersion: 1,
|
|
407
|
+
id: randomUUID(),
|
|
408
|
+
directory: resolve(profileDir),
|
|
409
|
+
createdAt: new Date().toISOString(),
|
|
410
|
+
reason: typeof args.reason === 'string' ? args.reason : '离线逃生通道隔离',
|
|
411
|
+
removed,
|
|
412
|
+
before: state.text,
|
|
413
|
+
beforeHash: digest(state.text),
|
|
414
|
+
afterHash: digest(after),
|
|
415
|
+
status: 'prepared',
|
|
416
|
+
}
|
|
417
|
+
const recordPath = join(rescueDir(profileDir), `${record.id}.json`)
|
|
418
|
+
mkdirSync(rescueDir(profileDir), { recursive: true })
|
|
419
|
+
// 一阶段:先把「我要做什么」落盘。此后任何时刻断电,重启都能从记录恢复。
|
|
420
|
+
atomicWrite(recordPath, `${JSON.stringify(record, null, 2)}\n`)
|
|
421
|
+
// 二阶段:原子改 profile。
|
|
422
|
+
atomicWrite(state.manifestPath, after)
|
|
423
|
+
record.status = 'applied'
|
|
424
|
+
atomicWrite(recordPath, `${JSON.stringify(record, null, 2)}\n`)
|
|
425
|
+
console.log(`\n已隔离并通过两阶段记录:${record.id}`)
|
|
426
|
+
console.log(`恢复:node bin/rescue.mjs restore --profile-dir <profile> --id ${record.id} --yes`)
|
|
427
|
+
return 0
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** `requireFs` 已移除:mkdirSync 直接在顶层 import(上一版残留的占位实现)。 */
|
|
431
|
+
|
|
432
|
+
function cmdRestore(args, profileDir) {
|
|
433
|
+
const state = readProfileBundleList(profileDir)
|
|
434
|
+
if (state === null) fail(`profile 无效(没有 dsh.profile.bundles):${join(profileDir, 'package.json')}`, 3)
|
|
435
|
+
|
|
436
|
+
let chosen
|
|
437
|
+
if (typeof args.id === 'string' && args.id !== '') {
|
|
438
|
+
chosen = listRescueRecords(profileDir).find((entry) => entry.record.id === args.id)
|
|
439
|
+
if (chosen === undefined) fail(`找不到 rescue 记录 ${args.id}`, 3)
|
|
440
|
+
} else {
|
|
441
|
+
// --resume / 默认:最新一条「可回插」的记录。
|
|
442
|
+
chosen = listRescueRecords(profileDir).find((entry) => entry.record.status === 'applied' || entry.record.status === 'prepared')
|
|
443
|
+
if (chosen === undefined) {
|
|
444
|
+
console.log('没有可恢复的 rescue 记录。')
|
|
445
|
+
return 0
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const { record } = chosen
|
|
450
|
+
if (record.status === 'restored') {
|
|
451
|
+
console.log(`记录 ${record.id} 已是 restored,无需重复恢复。`)
|
|
452
|
+
return 0
|
|
453
|
+
}
|
|
454
|
+
if (record.directory !== resolve(profileDir)) fail('记录不属于这个 profile(directory 不一致)', 3)
|
|
455
|
+
if (digest(record.before) !== record.beforeHash) fail('记录内容被篡改(before 与 beforeHash 不一致)', 3)
|
|
456
|
+
const currentHash = digest(state.text)
|
|
457
|
+
// 中断恢复判定:prepared + 当前哈希 == afterHash ⇒ 第二步其实已完成。
|
|
458
|
+
const interruptedAfterWrite = record.status === 'prepared' && currentHash === record.afterHash
|
|
459
|
+
if (record.status !== 'applied' && !interruptedAfterWrite) {
|
|
460
|
+
fail(`记录状态 ${record.status} 且 profile 未被它修改过(哈希不匹配)—— 没有可恢复的内容`, 3)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const selected = [...state.bundles]
|
|
464
|
+
// 按原 index 升序回插:一次只改一个位置,index 才不会互相挤位。
|
|
465
|
+
for (const item of [...record.removed].sort((a, b) => a.index - b.index)) {
|
|
466
|
+
if (selected.includes(item.name)) continue
|
|
467
|
+
selected.splice(Math.min(item.index, selected.length), 0, item.name)
|
|
468
|
+
}
|
|
469
|
+
const restoredManifest = { ...state.manifest, dsh: { ...state.manifest.dsh, profile: { ...state.manifest.dsh.profile, bundles: selected } } }
|
|
470
|
+
const nextText = `${JSON.stringify(restoredManifest, null, 2)}\n`
|
|
471
|
+
|
|
472
|
+
console.log(`记录 ${record.id}(status=${record.status}${interruptedAfterWrite ? ',判定为中断后已生效' : ''})`)
|
|
473
|
+
console.log(`将按原位置回插 ${record.removed.length} 个 bundle:`)
|
|
474
|
+
for (const item of [...record.removed].sort((a, b) => a.index - b.index)) console.log(` + ${item.name} @ ${item.index}`)
|
|
475
|
+
|
|
476
|
+
if (args.yes !== true) {
|
|
477
|
+
console.log('\n(dry-run)加 --yes 才会真正修改 profile。')
|
|
478
|
+
return 0
|
|
479
|
+
}
|
|
480
|
+
atomicWrite(state.manifestPath, nextText)
|
|
481
|
+
record.status = 'restored'
|
|
482
|
+
atomicWrite(chosen.path, `${JSON.stringify(record, null, 2)}\n`)
|
|
483
|
+
console.log('\n已恢复。')
|
|
484
|
+
return 0
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function cmdRescueList(profileDir) {
|
|
488
|
+
const records = listRescueRecords(profileDir)
|
|
489
|
+
if (records.length === 0) {
|
|
490
|
+
console.log('没有 rescue 记录。')
|
|
491
|
+
return 0
|
|
492
|
+
}
|
|
493
|
+
console.log(`rescue 记录(${records.length} 条,新 → 旧)\n`)
|
|
494
|
+
for (const { record } of records) {
|
|
495
|
+
console.log(`- ${record.id} ${record.status} ${fmtTime(record.createdAt)}`)
|
|
496
|
+
console.log(` ${record.removed.map((x) => `${x.name}@${x.index}`).join(', ')}`)
|
|
497
|
+
}
|
|
498
|
+
return 0
|
|
499
|
+
}
|
|
500
|
+
|
|
245
501
|
function main() {
|
|
246
502
|
const args = parseArgs(process.argv.slice(2))
|
|
247
503
|
if (args.help || args._.length === 0) {
|
|
@@ -250,7 +506,8 @@ function main() {
|
|
|
250
506
|
}
|
|
251
507
|
const command = args._[0]
|
|
252
508
|
if (!args.profileDir) fail('缺少 --profile-dir <profile 目录>')
|
|
253
|
-
const
|
|
509
|
+
const profileDir = resolve(args.profileDir)
|
|
510
|
+
const guardDir = join(profileDir, GUARD_DIR)
|
|
254
511
|
if (!existsSync(guardDir)) fail(`守护目录不存在:${guardDir}(这个 profile 装过 dsh-recency-guard 吗?)`)
|
|
255
512
|
const partition = pickPartition(guardDir, args.project)
|
|
256
513
|
const limit = Number.isFinite(args.limit) && args.limit > 0 ? args.limit : 40
|
|
@@ -260,8 +517,11 @@ function main() {
|
|
|
260
517
|
case 'history': code = cmdHistory(partition, limit); break
|
|
261
518
|
case 'decision': code = cmdDecision(partition); break
|
|
262
519
|
case 'archives': code = cmdArchives(partition); break
|
|
520
|
+
case 'rescues': code = cmdRescueList(profileDir); break
|
|
521
|
+
case 'apply': code = cmdApply(args, profileDir, partition); break
|
|
522
|
+
case 'restore': code = cmdRestore(args, profileDir); break
|
|
263
523
|
default:
|
|
264
|
-
fail(`未知命令 ${JSON.stringify(command)}(可用:status / history / decision / archives)`)
|
|
524
|
+
fail(`未知命令 ${JSON.stringify(command)}(可用:status / history / decision / archives / rescues / apply / restore)`)
|
|
265
525
|
}
|
|
266
526
|
process.exit(code)
|
|
267
527
|
}
|