@xiaoyuyu6420/dsh-backup 0.9.1 → 0.11.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 +20 -1
- package/README.zh.md +20 -1
- package/lib/client.js +39 -17
- package/lib/index.js +473 -91
- package/package.json +2 -1
- package/rescue/rescue.mjs +54 -14
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiaoyuyu6420/dsh-backup",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Backup, restore, download and GitHub-sync DeepSeek Harness user data (~/.dsh): /backup, scheduled auto-backup that survives restarts, sha256 checksums, integrity verify, rotation, credential redaction with a local vault (plaintext never leaves the machine), cross-machine restore preflight with github pull, and a visual Settings panel. Cross-platform (macOS/Linux/Windows). 一键备份与恢复 DSH 数据:定时自动备份、完整性校验、凭据默认脱敏(明文只存本机 vault)、跨机恢复预检与 github pull 拉取,附 Settings 可视面板。",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"packageManager": "pnpm@11.24.0",
|
|
6
7
|
"main": "lib/index.js",
|
|
7
8
|
"exports": {
|
|
8
9
|
".": "./lib/index.js",
|
package/rescue/rescue.mjs
CHANGED
|
@@ -104,7 +104,7 @@ async function listBackups(root) {
|
|
|
104
104
|
}
|
|
105
105
|
const backups = [];
|
|
106
106
|
for (const d of dirents) {
|
|
107
|
-
if (!d.name.startsWith('dsh-') || d.name.startsWith('dsh-pre-restore-') || !d.name.endsWith('.tar.gz')) continue;
|
|
107
|
+
if (!d.name.startsWith('dsh-') || d.name.startsWith('dsh-pre-restore-') || d.name.startsWith('dsh-t-') || !d.name.endsWith('.tar.gz')) continue;
|
|
108
108
|
let size;
|
|
109
109
|
try {
|
|
110
110
|
size = (await fs.stat(`${root}/${d.name}`)).size;
|
|
@@ -133,9 +133,9 @@ async function verifyOne(root, name) {
|
|
|
133
133
|
try {
|
|
134
134
|
expected = (await fs.readFile(`${root}/${name}.sha256`, 'utf8')).trim().split(/\s+/)[0];
|
|
135
135
|
} catch { /* 边车缺失 */ }
|
|
136
|
-
if (!/^[0-9a-f]{64}$/.test(expected)) return { name, ok: false, note: '
|
|
136
|
+
if (!/^[0-9a-f]{64}$/.test(expected)) return { name, ok: false, note: '缺少或无效的配套校验文件(.sha256)——无法确认这份备份是否完好' };
|
|
137
137
|
const actual = await sha256File(`${root}/${name}`);
|
|
138
|
-
return { name, ok: actual === expected, note: actual === expected ? '完整' : '
|
|
138
|
+
return { name, ok: actual === expected, note: actual === expected ? '完整' : '校验和不匹配(归档或校验文件之一可能损坏)' };
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
async function pickArchive(root, selector) {
|
|
@@ -238,12 +238,9 @@ function walkZstdFrames(buf) {
|
|
|
238
238
|
frames.push([off, pos]);
|
|
239
239
|
off = pos;
|
|
240
240
|
} else if (magic >= 0x184d2a50 && magic <= 0x184d2a5f) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (end > buf.length) throw new Error('skippable 帧数据越界');
|
|
245
|
-
frames.push([off, end]);
|
|
246
|
-
off = end;
|
|
241
|
+
// 宿主 scanZstdFrames 拒绝一切非数据帧魔数——容忍 skippable 会造成
|
|
242
|
+
// 「doctor 报 OK、宿主整体拒载」(#66,与 lib/index.js 同步)
|
|
243
|
+
throw new Error('遇 skippable 帧——宿主读端不支持任何非数据帧魔数,整个文件将被拒载');
|
|
247
244
|
} else {
|
|
248
245
|
throw new Error(`偏移 ${off} 处不是 zstd 帧魔数`);
|
|
249
246
|
}
|
|
@@ -251,18 +248,32 @@ function walkZstdFrames(buf) {
|
|
|
251
248
|
return frames;
|
|
252
249
|
}
|
|
253
250
|
|
|
251
|
+
/**
|
|
252
|
+
* 解出 .zstd 会话日志的全部逻辑行;任一帧损坏即抛错。同时对首个数据帧做
|
|
253
|
+
* 宿主容器契约判定 headerFrameOk——宿主 assertZstdHeaderFrame 是字节精确的:
|
|
254
|
+
* 首帧解出必须非空、且首个换行符恰在最后一个字节(「恰好一行、单个 \n 结尾」)。
|
|
255
|
+
* 行流拼接校验覆盖不到这条(#66,与 lib/index.js 同步)。
|
|
256
|
+
*/
|
|
254
257
|
function decodeZstdLog(buf) {
|
|
255
258
|
const parts = [];
|
|
256
259
|
let frameNo = 0;
|
|
260
|
+
let headerFrameOk;
|
|
257
261
|
for (const [s, e] of walkZstdFrames(buf)) {
|
|
258
262
|
frameNo += 1;
|
|
259
263
|
try {
|
|
260
|
-
|
|
264
|
+
const out = zlib.zstdDecompressSync(buf.subarray(s, e));
|
|
265
|
+
parts.push(out);
|
|
266
|
+
if (headerFrameOk === undefined) {
|
|
267
|
+
headerFrameOk = out.length > 0 && out.indexOf(10) === out.length - 1;
|
|
268
|
+
}
|
|
261
269
|
} catch (err) {
|
|
262
270
|
throw new Error(`第 ${frameNo} 帧解压失败:${String(err && err.message ? err.message : err)}`);
|
|
263
271
|
}
|
|
264
272
|
}
|
|
265
|
-
return
|
|
273
|
+
return {
|
|
274
|
+
lines: Buffer.concat(parts).toString('utf8').split('\n').filter((l) => l.length > 0),
|
|
275
|
+
headerFrameOk,
|
|
276
|
+
};
|
|
266
277
|
}
|
|
267
278
|
|
|
268
279
|
function validateSessionLines(lines) {
|
|
@@ -329,8 +340,13 @@ async function validateSessionFile(f) {
|
|
|
329
340
|
return { state: 'skipped', reason: `文件 ${Math.floor(info.size / 1048576)}MB 超过深度校验上限` };
|
|
330
341
|
}
|
|
331
342
|
let lines;
|
|
343
|
+
let headerFrameOk;
|
|
332
344
|
if (f.abs.endsWith('.zstd')) {
|
|
333
|
-
lines = decodeZstdLog(await fs.readFile(f.abs));
|
|
345
|
+
({ lines, headerFrameOk } = decodeZstdLog(await fs.readFile(f.abs)));
|
|
346
|
+
// 容器契约(#66/#1047):首帧必须解出「恰好一行 header、单个换行结尾」
|
|
347
|
+
if (headerFrameOk === false) {
|
|
348
|
+
return { state: 'bad', reason: '首帧不是恰好一行 header——违反容器契约(首帧应解出一行 SessionHeader 且以单个换行结尾;单帧重写/多余空行/缺行尾的典型形态,宿主读端会拒载)' };
|
|
349
|
+
}
|
|
334
350
|
} else {
|
|
335
351
|
lines = (await fs.readFile(f.abs, 'utf8')).split('\n').filter((l) => l.length > 0);
|
|
336
352
|
}
|
|
@@ -430,6 +446,11 @@ async function restoreArchive(root, selector, apply) {
|
|
|
430
446
|
const parent = dshHome.slice(0, -(base.length + 1)) || '/';
|
|
431
447
|
const entries = safeEntries(run(['tar', '-tvzf', picked.name], root), base);
|
|
432
448
|
const { meta, redactedFiles } = await readArchiveMeta(root, picked.name);
|
|
449
|
+
// 分类型归档是子集,整包恢复会挪旁 ~/.dsh 后只解压子集 → 丢失未包含的类型
|
|
450
|
+
// 数据。灾时请用全量归档(dsh-)整包恢复;分类型恢复走 dsh 的 --types。
|
|
451
|
+
if (meta && Array.isArray(meta.types) && meta.types.length) {
|
|
452
|
+
throw new Error(`${picked.name} 是分类型归档(${meta.types.join(', ')}),整包恢复会丢失其他类型数据。请选一份全量归档(dsh- 开头)整包恢复,或用 dsh 的「/backup restore ${picked.name} --types ${meta.types.join(',')}」分类型恢复。`);
|
|
453
|
+
}
|
|
433
454
|
const home = userHome();
|
|
434
455
|
const preflight = [];
|
|
435
456
|
if (meta && typeof meta.home === 'string' && home && meta.home !== home) {
|
|
@@ -547,8 +568,27 @@ dialog::backdrop{background:rgba(0,0,0,.4)}
|
|
|
547
568
|
<button class="danger" id="confirmGo">确认恢复</button></div></dialog>
|
|
548
569
|
<p class="note">恢复是整体替换:现有数据会先自动快照再挪到一旁(.dsh.pre-restore-*),凭据从本机 vault 补回。只监听本机回环地址。</p>
|
|
549
570
|
<script>
|
|
550
|
-
const
|
|
571
|
+
const ERR_TEXT = {
|
|
572
|
+
'not-found': '接口不存在(可能是浏览器缓存了旧页面),请刷新后重试',
|
|
573
|
+
'missing-rescue-header': '页面安全校验未通过,请刷新页面后重试',
|
|
574
|
+
'confirm-required': '该操作需要先在确认框里确认',
|
|
575
|
+
'unknown-op': '未知的操作类型,请刷新页面后重试',
|
|
576
|
+
'bad-json': '请求内容无法解析,请刷新页面后重试',
|
|
577
|
+
'too-large': '请求内容过大',
|
|
578
|
+
'no-archive': '备份目录里没有可用的归档,请先确认备份目录位置',
|
|
579
|
+
};
|
|
580
|
+
// 失败结果按"出了什么问题 + 怎么办"展示;未知错误再回退 JSON 细节
|
|
581
|
+
const show = (r) => {
|
|
582
|
+
if (r.ok !== false) return typeof r === 'string' ? r : JSON.stringify(r, null, 2);
|
|
583
|
+
// 校验类失败(verify/restore 拦截)没有 error 码,note/summary 本身就是人话主文案
|
|
584
|
+
const note = typeof (r.note || r.summary) === 'string' ? (r.note || r.summary) : null;
|
|
585
|
+
const human = ERR_TEXT[r.error] || note || '操作失败';
|
|
586
|
+
const detail = r.summary || r.note || r.error;
|
|
587
|
+
return '❌ ' + human + (detail && detail !== r.error && detail !== human ? '\\n技术细节: ' + (typeof detail === 'string' ? detail : JSON.stringify(detail)) : '');
|
|
588
|
+
};
|
|
589
|
+
const log = (t) => { document.getElementById('log').textContent = typeof t === 'string' ? t : show(t); };
|
|
551
590
|
const dlg = document.getElementById('confirm');
|
|
591
|
+
const fmtSize = (n) => typeof n !== 'number' ? '?' : n >= 1048576 ? (n/1048576).toFixed(1) + 'MB' : Math.max(1, Math.round(n/1024)) + 'KB';
|
|
552
592
|
async function api(op, body) {
|
|
553
593
|
const res = await fetch('/api/' + op, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dsh-Rescue': '1' }, body: JSON.stringify(body || {}) });
|
|
554
594
|
return res.json();
|
|
@@ -556,7 +596,7 @@ async function api(op, body) {
|
|
|
556
596
|
async function renderList() {
|
|
557
597
|
const r = await api('list');
|
|
558
598
|
if (!r.ok) { log(r); return; }
|
|
559
|
-
const rows = r.backups.map(b => '<tr><td>' + b.name + '</td><td>' + (b.size
|
|
599
|
+
const rows = r.backups.map(b => '<tr><td>' + b.name + '</td><td>' + fmtSize(b.size) +
|
|
560
600
|
'</td><td><button onclick="verify(\\'' + b.name + '\\')">校验</button> ' +
|
|
561
601
|
'<button class="danger" onclick="restore(\\'' + b.name + '\\')">恢复…</button></td></tr>').join('');
|
|
562
602
|
document.getElementById('list').innerHTML = r.backups.length
|