@xiaoyuyu6420/dsh-backup 0.7.2 → 0.9.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 +8 -2
- package/README.zh.md +8 -2
- package/lib/client.js +120 -32
- package/lib/index.js +1052 -90
- package/package.json +8 -2
- package/rescue/rescue.mjs +728 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-rescue —— dsh-backup 的进程外救援通道(零依赖,单文件)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么存在:dsh-backup 插件跑在 DSH 宿主进程里,宿主起不来时插件也死;
|
|
6
|
+
* 而 DSH 依赖 Node 才能跑,所以"DSH 坏了"的场景里 Node 一定活着。本脚本
|
|
7
|
+
* 只用 Node 内置能力(fs/crypto/zlib/child_process)+ 系统 tar,不依赖
|
|
8
|
+
* 任何 @deepseek-ai 包——`node rescue.mjs` 在任何损坏等级下都能恢复。
|
|
9
|
+
*
|
|
10
|
+
* 每次备份时插件会把本文件复制进备份目录;备份目录在哪,救援工具就在哪。
|
|
11
|
+
* 根目录缺省 = 本文件所在目录。
|
|
12
|
+
*
|
|
13
|
+
* 用法:
|
|
14
|
+
* node rescue.mjs # 启动救援网页(本机回环,推荐)
|
|
15
|
+
* node rescue.mjs list # 列出备份
|
|
16
|
+
* node rescue.mjs verify [前缀|all] # 校验完整性(缺省最新一份)
|
|
17
|
+
* node rescue.mjs restore <前缀|latest> # 恢复预览;加 --yes 执行
|
|
18
|
+
* node rescue.mjs doctor # 会话日志体检
|
|
19
|
+
* node rescue.mjs doctor --repair [前缀] # 从备份定点修复损坏的会话日志
|
|
20
|
+
* node rescue.mjs --root <目录> … # 指定备份目录(缺省脚本所在目录)
|
|
21
|
+
*
|
|
22
|
+
* 恢复语义与插件对齐(#28/#26):先校验 → 路径穿越防护 → 现有数据自动快照
|
|
23
|
+
* 后挪旁(.dsh.pre-restore-*)→ 解压(失败自动回滚)→ vault 凭据还原。
|
|
24
|
+
* 老归档(无边车 v0.6.x)静默降级,与插件一致。
|
|
25
|
+
*/
|
|
26
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
27
|
+
import { createHash } from 'node:crypto';
|
|
28
|
+
import { createReadStream } from 'node:fs';
|
|
29
|
+
import fs from 'node:fs/promises';
|
|
30
|
+
import http from 'node:http';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
import * as zlib from 'node:zlib';
|
|
34
|
+
|
|
35
|
+
const IS_WIN = process.platform === 'win32';
|
|
36
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const SENSITIVE_DEFAULTS = ['.credentials.yaml', '.env', 'qq-bridge/config.json'];
|
|
38
|
+
const VAULT_DIR = 'vault';
|
|
39
|
+
const ZSTD_MAGIC = 0xfd2fb528;
|
|
40
|
+
const SESSION_LOG_NAMES = new Set(['session.jsonl.zstd', 'session.jsonl']);
|
|
41
|
+
const SCAN_SKIP_DIRS = new Set(['node_modules', '.system', '.git', 'vault']);
|
|
42
|
+
const SCAN_MAX_FILES = 4000;
|
|
43
|
+
const SCAN_MAX_DEPTH = 8;
|
|
44
|
+
const HASH_MAX_BYTES = 256 * 1024 * 1024;
|
|
45
|
+
// 命名空间导入 + 运行时探测:zstd 导出在 Node <22.15/23.8 不存在,具名导入
|
|
46
|
+
// 会让本脚本直接加载失败。缺能力时 .zstd 体检降级为 skipped(与插件一致)。
|
|
47
|
+
const HAS_NODE_ZSTD = typeof zlib.zstdDecompressSync === 'function';
|
|
48
|
+
|
|
49
|
+
// ---------- 基础解析 ----------
|
|
50
|
+
|
|
51
|
+
function toFwd(p) {
|
|
52
|
+
return p && p.includes('\\') ? p.split('\\').join('/') : p;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveRoot(explicit) {
|
|
56
|
+
if (explicit) return toFwd(path.resolve(explicit));
|
|
57
|
+
return toFwd(HERE);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveDshHome() {
|
|
61
|
+
if (process.env.DSH_HOME) return toFwd(process.env.DSH_HOME.replace(/\/+$/, ''));
|
|
62
|
+
const home = toFwd(IS_WIN ? process.env.USERPROFILE : process.env.HOME);
|
|
63
|
+
if (!home) throw new Error('无法解析 HOME/USERPROFILE(可用 DSH_HOME 环境变量指定)');
|
|
64
|
+
return `${home}/.dsh`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function userHome() {
|
|
68
|
+
const h = toFwd(IS_WIN ? process.env.USERPROFILE : process.env.HOME);
|
|
69
|
+
return h ? h.replace(/\/+$/, '') : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function stampNow() {
|
|
73
|
+
const now = new Date();
|
|
74
|
+
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
75
|
+
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}${pad(now.getMilliseconds(), 3)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function run(argv, cwd) {
|
|
79
|
+
const r = spawnSync(argv[0], argv.slice(1), { cwd, encoding: 'buffer', maxBuffer: 256 * 1024 * 1024 });
|
|
80
|
+
if (r.status !== 0) {
|
|
81
|
+
const text = Buffer.concat([r.stdout, r.stderr]).toString('utf8').slice(0, 800);
|
|
82
|
+
throw new Error(`${argv[0]} ${argv.slice(1).join(' ')} 失败 (exit ${r.status}): ${text}`);
|
|
83
|
+
}
|
|
84
|
+
return r.stdout.toString('utf8');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function sha256File(absPath) {
|
|
88
|
+
const info = await fs.stat(absPath);
|
|
89
|
+
if (info.size > HASH_MAX_BYTES) throw new Error(`文件 ${Math.floor(info.size / 1048576)}MB 超过哈希上限`);
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const h = createHash('sha256');
|
|
92
|
+
createReadStream(absPath).on('data', (d) => h.update(d)).on('error', reject).on('end', () => resolve(h.digest('hex')));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------- 备份清单 / 校验(与插件 listBackups/verifyOne 同语义) ----------
|
|
97
|
+
|
|
98
|
+
async function listBackups(root) {
|
|
99
|
+
let dirents;
|
|
100
|
+
try {
|
|
101
|
+
dirents = await fs.readdir(root, { withFileTypes: true });
|
|
102
|
+
} catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
const backups = [];
|
|
106
|
+
for (const d of dirents) {
|
|
107
|
+
if (!d.name.startsWith('dsh-') || d.name.startsWith('dsh-pre-restore-') || !d.name.endsWith('.tar.gz')) continue;
|
|
108
|
+
let size;
|
|
109
|
+
try {
|
|
110
|
+
size = (await fs.stat(`${root}/${d.name}`)).size;
|
|
111
|
+
} catch {
|
|
112
|
+
size = undefined;
|
|
113
|
+
}
|
|
114
|
+
backups.push({ name: d.name, size });
|
|
115
|
+
}
|
|
116
|
+
return backups.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function readArchiveMeta(root, archiveName) {
|
|
120
|
+
let meta = null;
|
|
121
|
+
let redactedFiles = null;
|
|
122
|
+
try {
|
|
123
|
+
meta = JSON.parse(await fs.readFile(`${root}/${archiveName}.meta.json`, 'utf8'));
|
|
124
|
+
} catch { /* 老归档无边车 */ }
|
|
125
|
+
try {
|
|
126
|
+
redactedFiles = JSON.parse(await fs.readFile(`${root}/${archiveName}.redacted.json`, 'utf8'))?.files ?? [];
|
|
127
|
+
} catch { /* 未脱敏或老归档 */ }
|
|
128
|
+
return { meta, redactedFiles };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function verifyOne(root, name) {
|
|
132
|
+
let expected = '';
|
|
133
|
+
try {
|
|
134
|
+
expected = (await fs.readFile(`${root}/${name}.sha256`, 'utf8')).trim().split(/\s+/)[0];
|
|
135
|
+
} catch { /* 边车缺失 */ }
|
|
136
|
+
if (!/^[0-9a-f]{64}$/.test(expected)) return { name, ok: false, note: '缺少或无效的 .sha256 边车文件' };
|
|
137
|
+
const actual = await sha256File(`${root}/${name}`);
|
|
138
|
+
return { name, ok: actual === expected, note: actual === expected ? '完整' : 'sha256 不匹配(归档已损坏)' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function pickArchive(root, selector) {
|
|
142
|
+
const all = await listBackups(root);
|
|
143
|
+
if (!all.length) throw new Error('暂无备份');
|
|
144
|
+
if (!selector || selector === 'latest') return all[0];
|
|
145
|
+
const exact = all.filter((b) => b.name === selector);
|
|
146
|
+
const hits = exact.length ? exact : all.filter((b) => b.name.startsWith(selector));
|
|
147
|
+
if (hits.length === 1) return hits[0];
|
|
148
|
+
if (!hits.length) throw new Error(`没有匹配 "${selector}" 的备份`);
|
|
149
|
+
throw new Error(`"${selector}" 匹配多份备份,请加长前缀`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------- tar 条目解析与安全校验(与插件 parseTarEntry/safeArchiveEntries 同语义) ----------
|
|
153
|
+
|
|
154
|
+
function parseTarEntry(line) {
|
|
155
|
+
const f = line.trim().split(/\s+/);
|
|
156
|
+
if (f.length < 6) return null;
|
|
157
|
+
let nameIdx = -1;
|
|
158
|
+
for (let i = 1; i < f.length; i++) {
|
|
159
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(f[i])) { nameIdx = i + 2; break; }
|
|
160
|
+
}
|
|
161
|
+
if (nameIdx < 0) {
|
|
162
|
+
for (let i = f.length - 1; i >= 1; i--) {
|
|
163
|
+
if (/^\d{1,2}:\d{2}$/.test(f[i])) { nameIdx = i + 1; break; }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (nameIdx < 0) {
|
|
167
|
+
for (let i = f.length - 1; i >= 1; i--) {
|
|
168
|
+
if (/^\d{4}$/.test(f[i])) { nameIdx = i + 1; break; }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (nameIdx < 0 || nameIdx >= f.length) return null;
|
|
172
|
+
return { type: f[0][0], name: f.slice(nameIdx).join(' ').replace(/\/$/, '') };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function safeEntries(listedText, base) {
|
|
176
|
+
const entries = [];
|
|
177
|
+
const bad = [];
|
|
178
|
+
for (const line of listedText.split('\n')) {
|
|
179
|
+
const s = line.trim();
|
|
180
|
+
if (!s) continue;
|
|
181
|
+
const parsed = parseTarEntry(s);
|
|
182
|
+
const name = parsed ? parsed.name.replace(/\\/g, '/').replace(/\/$/, '') : null;
|
|
183
|
+
if (
|
|
184
|
+
!parsed || !name
|
|
185
|
+
|| (parsed.type !== '-' && parsed.type !== 'd')
|
|
186
|
+
|| name.startsWith('/') || /^[A-Za-z]:/.test(name)
|
|
187
|
+
|| name.split('/').includes('..')
|
|
188
|
+
|| (name !== base && !name.startsWith(`${base}/`))
|
|
189
|
+
) {
|
|
190
|
+
bad.push(parsed ? name : s);
|
|
191
|
+
} else {
|
|
192
|
+
entries.push(name);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (bad.length) throw new Error(`归档包含不安全条目(拒绝恢复):${bad.slice(0, 3).join(', ')}`);
|
|
196
|
+
return entries;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------- 会话日志体检(与插件 doctor 同语义) ----------
|
|
200
|
+
|
|
201
|
+
function snippet(text, max = 60) {
|
|
202
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function walkZstdFrames(buf) {
|
|
206
|
+
const frames = [];
|
|
207
|
+
let off = 0;
|
|
208
|
+
while (off < buf.length) {
|
|
209
|
+
if (buf.length - off < 4) throw new Error(`尾部截断(余 ${buf.length - off} 字节不成帧)`);
|
|
210
|
+
const magic = buf.readUInt32LE(off);
|
|
211
|
+
if (magic === ZSTD_MAGIC) {
|
|
212
|
+
let pos = off + 4;
|
|
213
|
+
if (pos >= buf.length) throw new Error('帧头描述符缺失');
|
|
214
|
+
const desc = buf[pos++];
|
|
215
|
+
if (desc & 0x08) throw new Error('帧头保留位置位');
|
|
216
|
+
const singleSegment = Boolean(desc & 0x20);
|
|
217
|
+
const checksum = Boolean(desc & 0x04);
|
|
218
|
+
const dictSize = [0, 1, 2, 4][desc & 0x03];
|
|
219
|
+
const fcsSize = singleSegment ? [1, 2, 4, 8][desc >> 6] : [0, 2, 4, 8][desc >> 6];
|
|
220
|
+
if (!singleSegment) pos += 1;
|
|
221
|
+
pos += dictSize + fcsSize;
|
|
222
|
+
if (pos > buf.length) throw new Error('帧头越界');
|
|
223
|
+
let last = false;
|
|
224
|
+
while (!last) {
|
|
225
|
+
if (buf.length - pos < 3) throw new Error('块头截断');
|
|
226
|
+
const bh = buf.readUIntLE(pos, 3);
|
|
227
|
+
last = Boolean(bh & 0x01);
|
|
228
|
+
const btype = (bh >> 1) & 0x03;
|
|
229
|
+
const bsize = bh >> 3;
|
|
230
|
+
if (btype === 3) throw new Error('保留块类型');
|
|
231
|
+
pos += 3 + (btype === 1 ? 1 : bsize);
|
|
232
|
+
if (pos > buf.length) throw new Error('块数据越界');
|
|
233
|
+
}
|
|
234
|
+
if (checksum) {
|
|
235
|
+
if (buf.length - pos < 4) throw new Error('帧校验和截断');
|
|
236
|
+
pos += 4;
|
|
237
|
+
}
|
|
238
|
+
frames.push([off, pos]);
|
|
239
|
+
off = pos;
|
|
240
|
+
} else if (magic >= 0x184d2a50 && magic <= 0x184d2a5f) {
|
|
241
|
+
if (buf.length - off < 8) throw new Error('skippable 帧头截断');
|
|
242
|
+
const size = buf.readUInt32LE(off + 4);
|
|
243
|
+
const end = off + 8 + size;
|
|
244
|
+
if (end > buf.length) throw new Error('skippable 帧数据越界');
|
|
245
|
+
frames.push([off, end]);
|
|
246
|
+
off = end;
|
|
247
|
+
} else {
|
|
248
|
+
throw new Error(`偏移 ${off} 处不是 zstd 帧魔数`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return frames;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function decodeZstdLog(buf) {
|
|
255
|
+
const parts = [];
|
|
256
|
+
let frameNo = 0;
|
|
257
|
+
for (const [s, e] of walkZstdFrames(buf)) {
|
|
258
|
+
frameNo += 1;
|
|
259
|
+
try {
|
|
260
|
+
parts.push(zlib.zstdDecompressSync(buf.subarray(s, e)));
|
|
261
|
+
} catch (err) {
|
|
262
|
+
throw new Error(`第 ${frameNo} 帧解压失败:${String(err && err.message ? err.message : err)}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return Buffer.concat(parts).toString('utf8').split('\n').filter((l) => l.length > 0);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function validateSessionLines(lines) {
|
|
269
|
+
let header;
|
|
270
|
+
try {
|
|
271
|
+
header = JSON.parse(lines[0]);
|
|
272
|
+
} catch {
|
|
273
|
+
return `首行不是 JSON(${snippet(String(lines[0]))})`;
|
|
274
|
+
}
|
|
275
|
+
if (!header || typeof header !== 'object' || header.type !== 'session' || typeof header.id !== 'string') {
|
|
276
|
+
return '首行不是有效的 SessionHeader(缺 type:"session" 或 id)';
|
|
277
|
+
}
|
|
278
|
+
let nextSeq = 0;
|
|
279
|
+
for (let i = 1; i < lines.length; i++) {
|
|
280
|
+
let rec;
|
|
281
|
+
try {
|
|
282
|
+
rec = JSON.parse(lines[i]);
|
|
283
|
+
} catch {
|
|
284
|
+
return `第 ${i + 1} 行不是合法 JSON(${snippet(String(lines[i]))})`;
|
|
285
|
+
}
|
|
286
|
+
if (!rec || typeof rec !== 'object') return `第 ${i + 1} 行记录不是对象`;
|
|
287
|
+
if (rec.type === 'text-chunks' || rec.type === 'reasoning-chunks' || rec.type === 'tool-call-chunks') {
|
|
288
|
+
const data = rec.data ?? {};
|
|
289
|
+
const members = Array.isArray(data.texts) ? data.texts.length : Array.isArray(data.args) ? data.args.length : 0;
|
|
290
|
+
if (!Number.isSafeInteger(rec.seq0) || members <= 0) return `第 ${i + 1} 行 packed 行缺少 seq0 或成员列表`;
|
|
291
|
+
if (rec.seq0 !== nextSeq) return `第 ${i + 1} 行 packed 行 seq0=${rec.seq0} 与游标 ${nextSeq} 不接续`;
|
|
292
|
+
nextSeq += members;
|
|
293
|
+
} else if (typeof rec.seq === 'number') {
|
|
294
|
+
if (rec.seq !== nextSeq) return `第 ${i + 1} 行 seq=${rec.seq} 撞号/跳号(期望 ${nextSeq})`;
|
|
295
|
+
nextSeq = rec.seq + 1;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function collectSessionLogs(dir, relPrefix, depth, out) {
|
|
302
|
+
if (depth > SCAN_MAX_DEPTH || out.length >= SCAN_MAX_FILES) return;
|
|
303
|
+
let dirents;
|
|
304
|
+
try {
|
|
305
|
+
dirents = await fs.readdir(dir, { withFileTypes: true });
|
|
306
|
+
} catch {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
for (const d of dirents) {
|
|
310
|
+
if (out.length >= SCAN_MAX_FILES) return;
|
|
311
|
+
const rel = relPrefix ? `${relPrefix}/${d.name}` : d.name;
|
|
312
|
+
if (d.isDirectory()) {
|
|
313
|
+
if (SCAN_SKIP_DIRS.has(d.name)) continue;
|
|
314
|
+
await collectSessionLogs(`${dir}/${d.name}`, rel, depth + 1, out);
|
|
315
|
+
} else if (SESSION_LOG_NAMES.has(d.name)) {
|
|
316
|
+
out.push({ abs: `${dir}/${d.name}`, rel });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function validateSessionFile(f) {
|
|
322
|
+
try {
|
|
323
|
+
const info = await fs.stat(f.abs);
|
|
324
|
+
if (info.size === 0) return { state: 'bad', reason: '空文件(0 字节)' };
|
|
325
|
+
if (f.abs.endsWith('.zstd') && !HAS_NODE_ZSTD) {
|
|
326
|
+
return { state: 'skipped', reason: `运行时 Node(${process.version})无内置 zstd 解码,跳过深度校验` };
|
|
327
|
+
}
|
|
328
|
+
if (info.size > HASH_MAX_BYTES) {
|
|
329
|
+
return { state: 'skipped', reason: `文件 ${Math.floor(info.size / 1048576)}MB 超过深度校验上限` };
|
|
330
|
+
}
|
|
331
|
+
let lines;
|
|
332
|
+
if (f.abs.endsWith('.zstd')) {
|
|
333
|
+
lines = decodeZstdLog(await fs.readFile(f.abs));
|
|
334
|
+
} else {
|
|
335
|
+
lines = (await fs.readFile(f.abs, 'utf8')).split('\n').filter((l) => l.length > 0);
|
|
336
|
+
}
|
|
337
|
+
if (!lines.length) return { state: 'bad', reason: '解出的逻辑行为空' };
|
|
338
|
+
const defect = validateSessionLines(lines);
|
|
339
|
+
return defect === null ? { state: 'ok' } : { state: 'bad', reason: defect };
|
|
340
|
+
} catch (err) {
|
|
341
|
+
return { state: 'bad', reason: String(err && err.message ? err.message : err) };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function runDoctorScan(dshHome) {
|
|
346
|
+
const found = [];
|
|
347
|
+
await collectSessionLogs(dshHome, '', 0, found);
|
|
348
|
+
const files = [];
|
|
349
|
+
for (const f of found) {
|
|
350
|
+
const verdict = await validateSessionFile(f);
|
|
351
|
+
files.push({ rel: f.rel, ok: verdict.state === 'ok', skipped: verdict.state === 'skipped', reason: verdict.reason });
|
|
352
|
+
}
|
|
353
|
+
const corrupt = files.filter((x) => !x.ok && !x.skipped);
|
|
354
|
+
return { scanned: files.length, files, corrupt, corruptCount: corrupt.length, skippedCount: files.filter((x) => x.skipped).length };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function summarizeDoctorScan(r) {
|
|
358
|
+
if (!r.files.length) return '未找到任何会话日志。';
|
|
359
|
+
const healthy = r.scanned - r.corruptCount - r.skippedCount;
|
|
360
|
+
let head = `扫描 ${r.scanned} 个会话日志:${healthy} 健康、${r.corruptCount} 损坏`;
|
|
361
|
+
if (r.skippedCount) head += `、${r.skippedCount} 个未能深度校验`;
|
|
362
|
+
if (!r.corruptCount) return `✅ ${head}`;
|
|
363
|
+
const shown = r.corrupt.slice(0, 15);
|
|
364
|
+
const more = r.corrupt.length > shown.length ? `\n …另有 ${r.corrupt.length - shown.length} 个` : '';
|
|
365
|
+
return `${head}\n${shown.map((c) => ` ❌ ${c.rel}\n ${c.reason}`).join('\n')}${more}`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---------- 定点修复(与插件 runDoctorRepair 同语义,含失败回滚) ----------
|
|
369
|
+
|
|
370
|
+
async function doctorRepair(root, dshHome, selector) {
|
|
371
|
+
const scan = await runDoctorScan(dshHome);
|
|
372
|
+
if (!scan.corruptCount) return { repaired: [], unrecoverable: [], stillBad: [], summary: summarizeDoctorScan(scan) };
|
|
373
|
+
const picked = await pickArchive(root, selector || 'latest');
|
|
374
|
+
const v = await verifyOne(root, picked.name);
|
|
375
|
+
if (!v.ok) throw new Error(`归档校验未通过(${v.note}),定点修复已中止`);
|
|
376
|
+
const base = dshHome.split('/').pop();
|
|
377
|
+
const parent = dshHome.slice(0, -(base.length + 1)) || '/';
|
|
378
|
+
const entries = safeEntries(run(['tar', '-tvzf', picked.name], root), base);
|
|
379
|
+
const entrySet = new Set(entries);
|
|
380
|
+
const targets = [];
|
|
381
|
+
const unrecoverable = [];
|
|
382
|
+
for (const c of scan.corrupt) {
|
|
383
|
+
const entry = `${base}/${c.rel}`;
|
|
384
|
+
if (entrySet.has(entry)) targets.push({ ...c, entry });
|
|
385
|
+
else unrecoverable.push(c.rel);
|
|
386
|
+
}
|
|
387
|
+
const stamp = stampNow();
|
|
388
|
+
const kept = [];
|
|
389
|
+
for (const t of targets) {
|
|
390
|
+
const keep = `${dshHome}/${t.rel}.corrupt-${stamp}`;
|
|
391
|
+
await fs.mkdir(path.dirname(keep), { recursive: true });
|
|
392
|
+
await fs.copyFile(`${dshHome}/${t.rel}`, keep);
|
|
393
|
+
kept.push(keep);
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
run(['tar', '-xzf', picked.name, '-C', parent, ...targets.map((t) => t.entry)], root);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
const reason = err && err.message ? err.message : String(err);
|
|
399
|
+
const restored = new Set();
|
|
400
|
+
for (let i = 0; i < targets.length; i++) {
|
|
401
|
+
try {
|
|
402
|
+
await fs.copyFile(kept[i], `${dshHome}/${targets[i].rel}`);
|
|
403
|
+
restored.add(targets[i].rel);
|
|
404
|
+
} catch { /* 如实报告 */ }
|
|
405
|
+
}
|
|
406
|
+
if (restored.size === targets.length) throw new Error(`定点修复失败(${reason})。已从留档还原全部损坏现场,数据保持修复前状态`);
|
|
407
|
+
const missing = targets.filter((t) => !restored.has(t.rel)).map((t) => t.rel);
|
|
408
|
+
throw new Error(`定点修复失败(${reason}),且部分现场未自动还原: ${missing.join(', ')};*.corrupt-${stamp} 留档在原目录旁,可手工拷回`);
|
|
409
|
+
}
|
|
410
|
+
const stillBad = [];
|
|
411
|
+
for (const t of targets) {
|
|
412
|
+
const verdict = await validateSessionFile({ abs: `${dshHome}/${t.rel}`, rel: t.rel });
|
|
413
|
+
if (verdict.state === 'bad') stillBad.push(`${t.rel}(${verdict.reason})`);
|
|
414
|
+
}
|
|
415
|
+
const lines = [`🩹 定点修复完成(来源: ${picked.name})`, ` 已恢复 ${targets.length} 个文件${stillBad.length ? '' : ',复检全部健康'}`];
|
|
416
|
+
for (const k of kept) lines.push(` 损坏现场已留档: ${k}`);
|
|
417
|
+
if (unrecoverable.length) lines.push(` ⚠️ 归档中无对应副本: ${unrecoverable.join(', ')}(改用 restore 全量恢复)`);
|
|
418
|
+
if (stillBad.length) lines.push(` ❌ 修复后复检仍异常: ${stillBad.join('; ')}`);
|
|
419
|
+
return { repaired: targets.map((t) => t.rel), unrecoverable, stillBad, summary: lines.join('\n') };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ---------- 恢复(与插件 restoreArchive 同语义:快照/挪旁/回滚/vault) ----------
|
|
423
|
+
|
|
424
|
+
async function restoreArchive(root, selector, apply) {
|
|
425
|
+
const dshHome = resolveDshHome();
|
|
426
|
+
const picked = await pickArchive(root, selector);
|
|
427
|
+
const v = await verifyOne(root, picked.name);
|
|
428
|
+
if (!v.ok) throw new Error(`校验未通过(${v.note}),恢复已中止`);
|
|
429
|
+
const base = dshHome.split('/').pop();
|
|
430
|
+
const parent = dshHome.slice(0, -(base.length + 1)) || '/';
|
|
431
|
+
const entries = safeEntries(run(['tar', '-tvzf', picked.name], root), base);
|
|
432
|
+
const { meta, redactedFiles } = await readArchiveMeta(root, picked.name);
|
|
433
|
+
const home = userHome();
|
|
434
|
+
const preflight = [];
|
|
435
|
+
if (meta && typeof meta.home === 'string' && home && meta.home !== home) {
|
|
436
|
+
preflight.push(`⚠️ 备份来自另一台机器/用户目录(${meta.host ?? '未知'},${meta.home}),settings 内的绝对路径可能需要调整`);
|
|
437
|
+
}
|
|
438
|
+
if (Array.isArray(redactedFiles)) {
|
|
439
|
+
preflight.push(`🔐 该归档已脱敏:${redactedFiles.length} 个凭据文件恢复时从本机 vault 还原(跨机需重填)`);
|
|
440
|
+
}
|
|
441
|
+
const profileDirs = [...new Set(entries
|
|
442
|
+
.filter((n) => n.startsWith(`${base}/profiles/`) && n.endsWith('/package.json'))
|
|
443
|
+
.map((n) => n.slice(`${base}/profiles/`.length).split('/')[0]))];
|
|
444
|
+
|
|
445
|
+
if (!apply) {
|
|
446
|
+
return { dryRun: true, archive: picked.name, files: entries.length, sample: entries.slice(0, 12), preflight, profileDirs };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// 现有数据:快照 + 挪旁;缺失(新机/全失)则直接解压
|
|
450
|
+
let targetExists = true;
|
|
451
|
+
try {
|
|
452
|
+
await fs.stat(dshHome);
|
|
453
|
+
} catch (err) {
|
|
454
|
+
if (err && err.code === 'ENOENT') targetExists = false;
|
|
455
|
+
else throw err;
|
|
456
|
+
}
|
|
457
|
+
let snapshotName = null;
|
|
458
|
+
if (targetExists) {
|
|
459
|
+
snapshotName = `dsh-pre-restore-${stampNow()}.tar.gz`;
|
|
460
|
+
// 快照排除与插件一致:可重装的 node_modules/.system + 敏感文件(明文留本机 vault)
|
|
461
|
+
const sensitiveNow = new Set(SENSITIVE_DEFAULTS);
|
|
462
|
+
if (Array.isArray(redactedFiles)) for (const rel of redactedFiles) sensitiveNow.add(rel);
|
|
463
|
+
const redactFlags = [...sensitiveNow].flatMap((rel) => [`--exclude=${base}/${rel}`, `--exclude=*/${rel}`]);
|
|
464
|
+
run(['tar', '--exclude=*node_modules*', '--exclude=.system', ...redactFlags, '-czf', snapshotName, '-C', parent, base], root);
|
|
465
|
+
// 清掉上一次的 pre-restore 快照(只保留本次),防累积
|
|
466
|
+
for (const name of await fs.readdir(root)) {
|
|
467
|
+
if (name.startsWith('dsh-pre-restore-') && name !== snapshotName) {
|
|
468
|
+
await fs.rm(`${root}/${name}`, { force: true });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const sha = await sha256File(`${root}/${snapshotName}`);
|
|
472
|
+
await fs.writeFile(`${root}/${snapshotName}.sha256`, `${sha} ${root}/${snapshotName}\n`, 'utf8');
|
|
473
|
+
}
|
|
474
|
+
const asideName = `${base}.pre-restore-${stampNow()}`;
|
|
475
|
+
let aside = null;
|
|
476
|
+
if (targetExists) {
|
|
477
|
+
await fs.rename(dshHome, `${parent}/${asideName}`);
|
|
478
|
+
aside = `${parent}/${asideName}`;
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
run(['tar', '-xzf', picked.name, '-C', parent], root);
|
|
482
|
+
} catch (err) {
|
|
483
|
+
const reason = err && err.message ? err.message : String(err);
|
|
484
|
+
let rolledBack = false;
|
|
485
|
+
try {
|
|
486
|
+
await fs.rm(dshHome, { recursive: true, force: true });
|
|
487
|
+
if (targetExists) await fs.rename(`${parent}/${asideName}`, dshHome);
|
|
488
|
+
rolledBack = true;
|
|
489
|
+
} catch { /* 人工指引分支 */ }
|
|
490
|
+
if (rolledBack) throw new Error(`解压归档失败(${reason})。已自动还原到恢复前状态,数据未丢失`);
|
|
491
|
+
throw new Error(aside
|
|
492
|
+
? `解压归档失败(${reason}),且自动还原未成功。原数据在 ${aside},改名回 "${dshHome}" 即可`
|
|
493
|
+
: `解压归档失败(${reason}),且清理未成功。半截产物在 ${dshHome},删除后重试`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// vault 还原
|
|
497
|
+
const vaultRestored = [];
|
|
498
|
+
const vaultMissing = [];
|
|
499
|
+
if (Array.isArray(redactedFiles) && redactedFiles.length) {
|
|
500
|
+
for (const rel of redactedFiles) {
|
|
501
|
+
const src = `${root}/${VAULT_DIR}/${rel}`;
|
|
502
|
+
const have = await fs.stat(src).then(() => true, (e) => (e.code === 'ENOENT' ? false : Promise.reject(e)));
|
|
503
|
+
if (!have) { vaultMissing.push(rel); continue; }
|
|
504
|
+
const dst = `${dshHome}/${rel}`;
|
|
505
|
+
await fs.mkdir(path.dirname(dst), { recursive: true });
|
|
506
|
+
await fs.copyFile(src, dst);
|
|
507
|
+
vaultRestored.push(rel);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const lines = ['✅ 恢复完成', ` 已替换: .dsh 全部内容 ← ${picked.name}(${entries.length} 项)`];
|
|
511
|
+
if (aside) lines.push(` 旧数据没丢: 在 ${aside}(确认正常后可自行删除)`);
|
|
512
|
+
if (snapshotName) lines.push(` 保险快照: ${snapshotName}(只保留最近一次)`);
|
|
513
|
+
for (const p of preflight) if (p.startsWith('⚠️')) lines.push(` ${p}`);
|
|
514
|
+
if (vaultRestored.length) lines.push(` 凭据: 已从本机 vault 补回 ${vaultRestored.length} 个`);
|
|
515
|
+
if (vaultMissing.length) lines.push(` ⚠️ 凭据缺失需手动重填: ${vaultMissing.join(', ')}`);
|
|
516
|
+
if (profileDirs.length) lines.push(` 插件依赖: 进各 profile 目录手动 pnpm install(或宿主内用 --sync-deps)`);
|
|
517
|
+
lines.push(' ⏸️ 待办: 重启 dsh,会话和配置才会切换到恢复的内容');
|
|
518
|
+
return { dryRun: false, archive: picked.name, files: entries.length, aside, snapshotName, vaultRestored, vaultMissing, summary: lines.join('\n') };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---------- 救援网页(loopback + 自定义头防 CSRF) ----------
|
|
522
|
+
|
|
523
|
+
const PAGE = `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
|
524
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
525
|
+
<title>dsh-rescue · 救援控制台</title><style>
|
|
526
|
+
body{font-family:system-ui,-apple-system,sans-serif;max-width:760px;margin:40px auto;padding:0 16px;color:#1f2937}
|
|
527
|
+
h1{font-size:22px}h1 small{font-weight:400;color:#6b7280;font-size:14px}
|
|
528
|
+
table{border-collapse:collapse;width:100%;margin-top:12px}th,td{text-align:left;padding:8px;border-bottom:1px solid #e5e7eb;font-size:14px}
|
|
529
|
+
button{margin:2px;padding:5px 12px;border:1px solid #d1d5db;border-radius:6px;background:#fff;cursor:pointer;font-size:13px}
|
|
530
|
+
button:hover{background:#f3f4f6}button.danger{background:#dc2626;color:#fff;border-color:#dc2626}
|
|
531
|
+
#log{white-space:pre-wrap;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:12px;margin-top:16px;font:12px/1.6 ui-monospace,monospace;min-height:80px}
|
|
532
|
+
.bar{display:flex;gap:8px;margin-top:16px}
|
|
533
|
+
.note{color:#6b7280;font-size:13px;margin-top:8px}
|
|
534
|
+
dialog{border:1px solid #d1d5db;border-radius:10px;padding:20px;max-width:420px}
|
|
535
|
+
dialog::backdrop{background:rgba(0,0,0,.4)}
|
|
536
|
+
</style></head><body>
|
|
537
|
+
<h1>🩺 dsh-rescue <small>进程外救援控制台——不依赖 DSH 运行状态</small></h1>
|
|
538
|
+
<div class="bar">
|
|
539
|
+
<button onclick="api('list')">刷新备份列表</button>
|
|
540
|
+
<button onclick="doctor(false)">会话体检</button>
|
|
541
|
+
<button onclick="doctor(true)" class="danger">体检并修复</button>
|
|
542
|
+
</div>
|
|
543
|
+
<div id="list"></div>
|
|
544
|
+
<div id="log">(操作结果会显示在这里)</div>
|
|
545
|
+
<dialog id="confirm"><p id="confirmText"></p>
|
|
546
|
+
<div class="bar"><button onclick="dlg.close(false)">取消</button>
|
|
547
|
+
<button class="danger" id="confirmGo">确认恢复</button></div></dialog>
|
|
548
|
+
<p class="note">恢复是整体替换:现有数据会先自动快照再挪到一旁(.dsh.pre-restore-*),凭据从本机 vault 补回。只监听本机回环地址。</p>
|
|
549
|
+
<script>
|
|
550
|
+
const log = (t) => { document.getElementById('log').textContent = typeof t === 'string' ? t : JSON.stringify(t, null, 2); };
|
|
551
|
+
const dlg = document.getElementById('confirm');
|
|
552
|
+
async function api(op, body) {
|
|
553
|
+
const res = await fetch('/api/' + op, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dsh-Rescue': '1' }, body: JSON.stringify(body || {}) });
|
|
554
|
+
return res.json();
|
|
555
|
+
}
|
|
556
|
+
async function renderList() {
|
|
557
|
+
const r = await api('list');
|
|
558
|
+
if (!r.ok) { log(r); return; }
|
|
559
|
+
const rows = r.backups.map(b => '<tr><td>' + b.name + '</td><td>' + (b.size ? (b.size/1048576).toFixed(1) + 'MB' : '?') +
|
|
560
|
+
'</td><td><button onclick="verify(\\'' + b.name + '\\')">校验</button> ' +
|
|
561
|
+
'<button class="danger" onclick="restore(\\'' + b.name + '\\')">恢复…</button></td></tr>').join('');
|
|
562
|
+
document.getElementById('list').innerHTML = r.backups.length
|
|
563
|
+
? '<table><tr><th>备份</th><th>大小</th><th></th></tr>' + rows + '</table>'
|
|
564
|
+
: '<p>备份目录里没有找到归档。</p>';
|
|
565
|
+
log(r.summary || '');
|
|
566
|
+
}
|
|
567
|
+
async function verify(name) { log(await api('verify', { selector: name })); }
|
|
568
|
+
async function restore(name) {
|
|
569
|
+
const pre = await api('restore', { selector: name, dryRun: true });
|
|
570
|
+
log(pre);
|
|
571
|
+
if (!pre.ok) return;
|
|
572
|
+
document.getElementById('confirmText').textContent = pre.summary + '\\n\\n确认用这份归档整体替换 ' + (pre.targetExists === false ? '(目标不存在,直接写入)' : '当前 .dsh?');
|
|
573
|
+
document.getElementById('confirmGo').onclick = async () => { dlg.close(true); log(await api('restore', { selector: name, confirm: true })); renderList(); };
|
|
574
|
+
dlg.showModal();
|
|
575
|
+
}
|
|
576
|
+
async function doctor(repair) {
|
|
577
|
+
if (repair) {
|
|
578
|
+
if (!confirm('将对损坏的会话日志执行定点修复(损坏文件会留档 *.corrupt-*)。继续?')) return;
|
|
579
|
+
}
|
|
580
|
+
log(await api('doctor', { repair }));
|
|
581
|
+
}
|
|
582
|
+
renderList();
|
|
583
|
+
</script></body></html>`;
|
|
584
|
+
|
|
585
|
+
/** 跨平台尽力打开浏览器(serve --open):失败静默——URL 已打印在终端。 */
|
|
586
|
+
function openBrowser(url) {
|
|
587
|
+
try {
|
|
588
|
+
const cmd = IS_WIN ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
589
|
+
const args = IS_WIN ? ['/c', 'start', '', url] : [url];
|
|
590
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
|
591
|
+
} catch { /* 打不开就手输 URL */ }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function serve(root, port, autoOpen) {
|
|
595
|
+
const server = http.createServer(async (req, res) => {
|
|
596
|
+
const send = (status, data, type = 'application/json') => {
|
|
597
|
+
res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' });
|
|
598
|
+
res.end(typeof data === 'string' && type === 'text/html' ? data : JSON.stringify(data));
|
|
599
|
+
};
|
|
600
|
+
try {
|
|
601
|
+
if (req.method === 'GET' || req.method === 'HEAD') return send(200, PAGE, 'text/html');
|
|
602
|
+
if (req.method !== 'POST' || !req.url.startsWith('/api/')) return send(404, { ok: false, error: 'not-found' });
|
|
603
|
+
// 防 CSRF:浏览器跨站表单/简单请求带不了自定义头;页面内 fetch 一律带
|
|
604
|
+
if (req.headers['x-dsh-rescue'] !== '1') return send(403, { ok: false, error: 'missing-rescue-header' });
|
|
605
|
+
const op = req.url.slice(5);
|
|
606
|
+
const body = await new Promise((resolve) => {
|
|
607
|
+
let raw = '';
|
|
608
|
+
req.on('data', (c) => { raw += c; });
|
|
609
|
+
req.on('end', () => { try { resolve(JSON.parse(raw || '{}')); } catch { resolve({}); } });
|
|
610
|
+
});
|
|
611
|
+
const dshHome = resolveDshHome();
|
|
612
|
+
if (op === 'list') {
|
|
613
|
+
const backups = await listBackups(root);
|
|
614
|
+
return send(200, { ok: true, backups, dshHome, root, node: process.version, zstd: HAS_NODE_ZSTD });
|
|
615
|
+
}
|
|
616
|
+
if (op === 'verify') {
|
|
617
|
+
const sel = body.selector || 'latest';
|
|
618
|
+
const names = sel === 'all' ? (await listBackups(root)).map((b) => b.name) : [(await pickArchive(root, sel)).name];
|
|
619
|
+
const results = [];
|
|
620
|
+
for (const n of names) results.push(await verifyOne(root, n));
|
|
621
|
+
return send(200, { ok: results.every((r) => r.ok), summary: results.map((r) => `${r.ok ? '✅' : '❌'} ${r.name} — ${r.note}`).join('\n') || '暂无备份。', results });
|
|
622
|
+
}
|
|
623
|
+
if (op === 'restore') {
|
|
624
|
+
if (body.dryRun) {
|
|
625
|
+
const r = await restoreArchive(root, body.selector || 'latest', false);
|
|
626
|
+
return send(200, { ok: true, ...r, summary: r.summary ?? `📦 恢复预览:${r.files} 项\n${(r.preflight || []).join('\n')}` });
|
|
627
|
+
}
|
|
628
|
+
if (body.confirm !== true) return send(400, { ok: false, error: 'confirm-required' });
|
|
629
|
+
return send(200, await restoreArchive(root, body.selector || 'latest', true).then((r) => ({ ok: true, ...r })));
|
|
630
|
+
}
|
|
631
|
+
if (op === 'doctor') {
|
|
632
|
+
if (body.repair) {
|
|
633
|
+
const r = await doctorRepair(root, dshHome, body.selector);
|
|
634
|
+
return send(200, { ok: r.stillBad.length === 0 && r.unrecoverable.length === 0, ...r });
|
|
635
|
+
}
|
|
636
|
+
const r = await runDoctorScan(dshHome);
|
|
637
|
+
return send(200, { ok: r.corruptCount === 0, corruptCount: r.corruptCount, skippedCount: r.skippedCount, scanned: r.scanned, summary: summarizeDoctorScan(r), corrupt: r.corrupt });
|
|
638
|
+
}
|
|
639
|
+
return send(404, { ok: false, error: 'unknown-op' });
|
|
640
|
+
} catch (err) {
|
|
641
|
+
return send(500, { ok: false, error: String(err && err.message ? err.message : err) });
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
server.listen(port, '127.0.0.1', () => {
|
|
645
|
+
console.log(`🩺 dsh-rescue 救援控制台已启动: http://127.0.0.1:${port}`);
|
|
646
|
+
console.log(` 备份目录: ${root}`);
|
|
647
|
+
console.log(` DSH_HOME: ${resolveDshHome()} (Ctrl+C 退出)`);
|
|
648
|
+
if (autoOpen) openBrowser(`http://127.0.0.1:${port}`);
|
|
649
|
+
});
|
|
650
|
+
return server;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ---------- CLI ----------
|
|
654
|
+
|
|
655
|
+
const HELP = `dsh-rescue —— dsh-backup 的进程外救援通道(零依赖)
|
|
656
|
+
|
|
657
|
+
用法:
|
|
658
|
+
node rescue.mjs 启动救援网页(本机回环)
|
|
659
|
+
node rescue.mjs list 列出备份
|
|
660
|
+
node rescue.mjs verify [前缀|all] 校验完整性(缺省最新一份)
|
|
661
|
+
node rescue.mjs restore <前缀|latest> 恢复预览;确认无误后加 --yes 执行
|
|
662
|
+
node rescue.mjs doctor 会话日志体检
|
|
663
|
+
node rescue.mjs doctor --repair [前缀] 从备份定点修复损坏的会话日志
|
|
664
|
+
node rescue.mjs serve [--port N] 同无参数:启动救援网页
|
|
665
|
+
|
|
666
|
+
选项:
|
|
667
|
+
--root <目录> 备份目录(缺省 = 本文件所在目录)
|
|
668
|
+
--yes restore 的执行确认
|
|
669
|
+
--port N 网页端口(缺省 13190)`;
|
|
670
|
+
|
|
671
|
+
async function main() {
|
|
672
|
+
const argv = process.argv.slice(2);
|
|
673
|
+
const rootArgIdx = argv.indexOf('--root');
|
|
674
|
+
const root = resolveRoot(rootArgIdx >= 0 ? argv[rootArgIdx + 1] : undefined);
|
|
675
|
+
const portIdx = argv.indexOf('--port');
|
|
676
|
+
const port = Number(portIdx >= 0 ? argv[portIdx + 1] : 13190);
|
|
677
|
+
// 只剔除选项及其取值位置;--root/--port 缺席时 indexOf 返回 -1,
|
|
678
|
+
// 若直接用 i !== idx+1 会把第 0 位误删(首命令变 serve)
|
|
679
|
+
const skipIdx = new Set();
|
|
680
|
+
if (rootArgIdx >= 0) { skipIdx.add(rootArgIdx); skipIdx.add(rootArgIdx + 1); }
|
|
681
|
+
if (portIdx >= 0) { skipIdx.add(portIdx); skipIdx.add(portIdx + 1); }
|
|
682
|
+
const pos = argv.filter((a, i) => !skipIdx.has(i) && a !== '--yes' && a !== '--repair');
|
|
683
|
+
const cmd = pos[0] || 'serve';
|
|
684
|
+
const dshHome = resolveDshHome();
|
|
685
|
+
if (cmd === '--help' || cmd === '-h' || cmd === 'help') return console.log(HELP);
|
|
686
|
+
if (cmd === 'serve') return serve(root, port, argv.includes('--open'));
|
|
687
|
+
if (cmd === 'list') {
|
|
688
|
+
const all = await listBackups(root);
|
|
689
|
+
const total = all.reduce((s, b) => s + (b.size || 0), 0);
|
|
690
|
+
console.log(all.length ? `备份目录 ${root}(${all.length} 份,共 ${(total / 1048576).toFixed(1)}MB):\n${all.map((b) => ` ${b.name}${b.size !== undefined ? ` ${(b.size / 1048576).toFixed(1)}MB` : ''}`).join('\n')}` : `备份目录 ${root} 里没有归档。`);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (cmd === 'verify') {
|
|
694
|
+
const sel = pos[1] || 'latest';
|
|
695
|
+
const names = sel === 'all' ? (await listBackups(root)).map((b) => b.name) : [(await pickArchive(root, sel)).name];
|
|
696
|
+
const results = [];
|
|
697
|
+
for (const n of names) results.push(await verifyOne(root, n));
|
|
698
|
+
console.log(results.map((r) => `${r.ok ? '✅' : '❌'} ${r.name} — ${r.note}`).join('\n') || '暂无备份可校验。');
|
|
699
|
+
if (results.some((r) => !r.ok)) process.exitCode = 1;
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (cmd === 'restore') {
|
|
703
|
+
const apply = argv.includes('--yes');
|
|
704
|
+
const r = await restoreArchive(root, pos[1] || 'latest', apply);
|
|
705
|
+
console.log(r.summary ?? `📦 恢复预览(未写入)\n 归档: ${r.archive}\n 条目: ${r.files} 项\n${(r.preflight || []).join('\n')}\n${(r.sample || []).map((s) => ` ${s}`).join('\n')}`);
|
|
706
|
+
if (!apply) console.log(`\n确认无误后执行: node rescue.mjs restore ${pos[1] || 'latest'} --yes`);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (cmd === 'doctor') {
|
|
710
|
+
if (argv.includes('--repair')) {
|
|
711
|
+
const r = await doctorRepair(root, dshHome, pos[1]);
|
|
712
|
+
console.log(r.summary);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
const r = await runDoctorScan(dshHome);
|
|
716
|
+
console.log(summarizeDoctorScan(r));
|
|
717
|
+
if (r.corruptCount) console.log('\n修复: node rescue.mjs doctor --repair [前缀|latest]');
|
|
718
|
+
if (r.corruptCount) process.exitCode = 1;
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
console.error(`未知命令: ${cmd}\n\n${HELP}`);
|
|
722
|
+
process.exitCode = 1;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
main().catch((err) => {
|
|
726
|
+
console.error(`❌ ${err && err.message ? err.message : err}`);
|
|
727
|
+
process.exitCode = 1;
|
|
728
|
+
});
|