@xiaoyuyu6420/dsh-backup 0.12.1 → 0.13.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.en.md +2 -0
- package/README.md +2 -0
- package/lib/client.js +21 -21
- package/lib/index.js +178 -7
- package/package.json +1 -1
- package/rescue/rescue.mjs +13 -3
package/lib/index.js
CHANGED
|
@@ -272,6 +272,16 @@ function detectHostTrain() {
|
|
|
272
272
|
* 吃的都是同一语法的字符串,直接写常量在两个列车上行为等价。 */
|
|
273
273
|
const NS = 'dsh-backup';
|
|
274
274
|
|
|
275
|
+
/** 本插件自身名称与版本(package.json):更新检查的"当前版本"基准。 */
|
|
276
|
+
const SELF_META = (() => {
|
|
277
|
+
try {
|
|
278
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
279
|
+
return { name: pkg.name, version: pkg.version };
|
|
280
|
+
} catch {
|
|
281
|
+
return { name: '@xiaoyuyu6420/dsh-backup', version: 'unknown' };
|
|
282
|
+
}
|
|
283
|
+
})();
|
|
284
|
+
|
|
275
285
|
/**
|
|
276
286
|
* 设置默认值。SCHEMA 的 .default() 与 resolveBase 的初始值都引用这里,
|
|
277
287
|
* 避免同一字面量散落多处导致迁移逻辑漏改。
|
|
@@ -281,6 +291,7 @@ const DEFAULTS = Object.freeze({
|
|
|
281
291
|
exclude: [],
|
|
282
292
|
redact: [...SENSITIVE_DEFAULTS],
|
|
283
293
|
githubRepo: '',
|
|
294
|
+
updateCheck: false,
|
|
284
295
|
});
|
|
285
296
|
|
|
286
297
|
/**
|
|
@@ -294,9 +305,10 @@ const SCHEMA = z.object({
|
|
|
294
305
|
exclude: z.array(z.string()).default(DEFAULTS.exclude),
|
|
295
306
|
redact: z.union([z.array(z.string()), 'off']).default(DEFAULTS.redact),
|
|
296
307
|
githubRepo: z.string().default(DEFAULTS.githubRepo),
|
|
308
|
+
updateCheck: z.boolean().default(DEFAULTS.updateCheck),
|
|
297
309
|
});
|
|
298
310
|
|
|
299
|
-
const SETTINGS_FIELDS = ['destination', 'keep', 'exclude', 'redact', 'githubRepo'];
|
|
311
|
+
const SETTINGS_FIELDS = ['destination', 'keep', 'exclude', 'redact', 'githubRepo', 'updateCheck'];
|
|
300
312
|
|
|
301
313
|
/** 校验并归一化单个字段到 `out`;非法值忽略。 */
|
|
302
314
|
function normalizeField(out, field, value) {
|
|
@@ -313,6 +325,8 @@ function normalizeField(out, field, value) {
|
|
|
313
325
|
else if (Array.isArray(value)) out.redact = value.filter((v) => typeof v === 'string');
|
|
314
326
|
} else if (field === 'githubRepo') {
|
|
315
327
|
if (typeof value === 'string') out.githubRepo = value;
|
|
328
|
+
} else if (field === 'updateCheck') {
|
|
329
|
+
if (typeof value === 'boolean') out.updateCheck = value;
|
|
316
330
|
}
|
|
317
331
|
}
|
|
318
332
|
|
|
@@ -398,6 +412,8 @@ const PANEL_INVOCATIONS = Object.freeze([
|
|
|
398
412
|
panelDescriptor('doctorRepair', ['selector'], true),
|
|
399
413
|
panelDescriptor('migrateCheck', [], true),
|
|
400
414
|
panelDescriptor('setGithubToken', ['token']),
|
|
415
|
+
panelDescriptor('checkUpdate', [], true),
|
|
416
|
+
panelDescriptor('update', [], true),
|
|
401
417
|
]);
|
|
402
418
|
|
|
403
419
|
/**
|
|
@@ -481,6 +497,16 @@ class BackupPanelService extends TypertRemoteService {
|
|
|
481
497
|
setGithubToken(token) {
|
|
482
498
|
return this.ops.setGithubToken(token);
|
|
483
499
|
}
|
|
500
|
+
|
|
501
|
+
/** 检查 npm 是否有新版本(只读,不写任何文件)。 */
|
|
502
|
+
checkUpdate(signal) {
|
|
503
|
+
return this.ops.checkUpdate(signal);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** 一键更新:先留升级前快照,再走官方 dsh plugin update(完成后需重启生效)。 */
|
|
507
|
+
update(signal) {
|
|
508
|
+
return this.ops.update(signal);
|
|
509
|
+
}
|
|
484
510
|
}
|
|
485
511
|
|
|
486
512
|
export function apply(ctx, pluginConfig) {
|
|
@@ -633,7 +659,10 @@ export function apply(ctx, pluginConfig) {
|
|
|
633
659
|
const toFwd = (p) => (p.includes('\\') ? p.split('\\').join('/') : p);
|
|
634
660
|
const homeRaw = env?.get('HOME')?.value || env?.get('USERPROFILE')?.value;
|
|
635
661
|
const home = homeRaw ? toFwd(homeRaw.replace(/\/+$/, '')) : undefined;
|
|
636
|
-
|
|
662
|
+
// DSH_HOME 与 HOME 一样去尾斜杠(#88):'D:/dsh_data/' 会让 base 变空串、
|
|
663
|
+
// parent 退化为源目录本身,tar 位置参数成空串,产出 0 字节坏归档。
|
|
664
|
+
const dshHomeRaw = env?.get('DSH_HOME')?.value || (home ? `${home}/.dsh` : undefined);
|
|
665
|
+
const dshHome = dshHomeRaw ? toFwd(dshHomeRaw).replace(/\/+$/, '') || '/' : undefined;
|
|
637
666
|
if (!home || !dshHome) throw new Error('无法解析 HOME/USERPROFILE 或 DSH_HOME(launchEnvironment 缺失)');
|
|
638
667
|
const cfg = resolvedConfig();
|
|
639
668
|
const raw = typeof cfg.destination === 'string' && cfg.destination.trim()
|
|
@@ -643,6 +672,106 @@ export function apply(ctx, pluginConfig) {
|
|
|
643
672
|
return { home, dshHome: toFwd(dshHome), root };
|
|
644
673
|
}
|
|
645
674
|
|
|
675
|
+
// #88:parent 由字符串截取求得,DSH_HOME 直接挂在盘符根时退化为 'D:'——
|
|
676
|
+
// bsdtar/GNU tar 都把裸盘符解析为"该盘符当前目录",-C D: 必然失败并留下
|
|
677
|
+
// 空壳归档。tar 的 -C 一律传补过分隔符的值;fs 侧继续用原 parent
|
|
678
|
+
// ('D:' + '/' + name 仍是合法路径,renameBeside 等不受影响)。
|
|
679
|
+
const parentTar = (p) => (p.endsWith('/') ? p : `${p}/`);
|
|
680
|
+
|
|
681
|
+
/** semver 比较(常见形态,含 prerelease):a > b 才返回 true;解析失败保守降为 false。 */
|
|
682
|
+
function semverGt(a, b) {
|
|
683
|
+
const parse = (s) => {
|
|
684
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(s));
|
|
685
|
+
if (!m) return null;
|
|
686
|
+
return { n: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null };
|
|
687
|
+
};
|
|
688
|
+
const A = parse(a);
|
|
689
|
+
const B = parse(b);
|
|
690
|
+
if (!A || !B) return false;
|
|
691
|
+
for (let i = 0; i < 3; i += 1) {
|
|
692
|
+
if (A.n[i] !== B.n[i]) return A.n[i] > B.n[i];
|
|
693
|
+
}
|
|
694
|
+
if (A.pre === B.pre) return false;
|
|
695
|
+
return A.pre === null; // 无 prerelease 视为 release:同元组下 release > prerelease
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* 检查 npm 最新版(更新感知,A)。纯只读,失败静默——返回 error 文案交给
|
|
700
|
+
* 调用方,插件正常运行绝不因网络问题阻断。fetch 为 Node >=20 全局。
|
|
701
|
+
*/
|
|
702
|
+
async function checkUpdate(signal) {
|
|
703
|
+
const ctrl = new AbortController();
|
|
704
|
+
const timer = setTimeout(() => ctrl.abort(), 8000);
|
|
705
|
+
const onHostAbort = () => ctrl.abort();
|
|
706
|
+
signal?.addEventListener('abort', onHostAbort, { once: true });
|
|
707
|
+
try {
|
|
708
|
+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(SELF_META.name)}`, {
|
|
709
|
+
signal: ctrl.signal,
|
|
710
|
+
headers: { accept: 'application/vnd.npm.install-v1+json' },
|
|
711
|
+
});
|
|
712
|
+
if (!res.ok) return { error: `npm registry 返回 ${res.status}` };
|
|
713
|
+
const meta = await res.json();
|
|
714
|
+
const latest = meta?.['dist-tags']?.latest;
|
|
715
|
+
if (typeof latest !== 'string' || !latest) return { error: 'registry 响应缺少 dist-tags.latest' };
|
|
716
|
+
return { current: SELF_META.version, latest, update: semverGt(latest, SELF_META.version) };
|
|
717
|
+
} catch (err) {
|
|
718
|
+
if (err && err.name === 'AbortError') return { error: '检查超时(8 秒)' };
|
|
719
|
+
return { error: String(err && err.message ? err.message : err) };
|
|
720
|
+
} finally {
|
|
721
|
+
clearTimeout(timer);
|
|
722
|
+
signal?.removeEventListener('abort', onHostAbort);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* 一键更新(B):先拍升级前快照(未过期的退路),再走官方 `dsh plugin
|
|
728
|
+
* --profile <p> update <pkg>`(pnpm 语义——profile 是 pnpm 项目,插件绝不
|
|
729
|
+
* 自己覆写依赖文件,否则 lockfile 失配)。完成后提示重启生效:宿主在启动
|
|
730
|
+
* 时结算 Loader 树,Node 半不可在运行中热替换。
|
|
731
|
+
*/
|
|
732
|
+
async function runUpdate(signal, profileArg) {
|
|
733
|
+
const profile = profileArg || 'web';
|
|
734
|
+
const chk = await checkUpdate(signal);
|
|
735
|
+
if (chk.error) {
|
|
736
|
+
return { ok: false, updated: false, summary: `检查更新失败:${chk.error}\n可手动执行: dsh plugin --profile ${profile} update ${SELF_META.name}` };
|
|
737
|
+
}
|
|
738
|
+
if (!chk.update) {
|
|
739
|
+
return { ok: true, updated: false, current: chk.current, latest: chk.latest, summary: `已是最新版本 ${chk.current}` };
|
|
740
|
+
}
|
|
741
|
+
// 更新前快照:与启动钩子的升级前快照同一语义(不阻断更新,失败如实提示)
|
|
742
|
+
let snap = null;
|
|
743
|
+
try {
|
|
744
|
+
const s = await doBackup(undefined, signal, { namePrefix: 'dsh-pre-upgrade-' });
|
|
745
|
+
prunePrefixedSnapshots('dsh-pre-upgrade-', 2, s.name).catch(() => {});
|
|
746
|
+
snap = s.name;
|
|
747
|
+
} catch { /* 快照失败不阻断更新 */ }
|
|
748
|
+
// 解析 dsh CLI:优先 PATH(resolveExecutable),兜底用当前宿主入口(与
|
|
749
|
+
// detectHostTrain 同款定位——`node <入口> plugin ...` 与运行中的宿主同源)
|
|
750
|
+
let argv = null;
|
|
751
|
+
try {
|
|
752
|
+
argv = [await ctx.subprocess.resolveExecutable('dsh'), 'plugin', '--profile', profile, 'update', SELF_META.name];
|
|
753
|
+
} catch {
|
|
754
|
+
try {
|
|
755
|
+
argv = [process.execPath, realpathSync(process.argv[1]), 'plugin', '--profile', profile, 'update', SELF_META.name];
|
|
756
|
+
} catch {
|
|
757
|
+
return { ok: false, updated: false, summary: `找不到 dsh 命令,请手动执行: dsh plugin --profile ${profile} update ${SELF_META.name}${snap ? `\n更新前快照已留: ${snap}` : ''}` };
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
try {
|
|
761
|
+
const { root } = paths();
|
|
762
|
+
await spawnRun(argv, root, signal);
|
|
763
|
+
} catch (err) {
|
|
764
|
+
return { ok: false, updated: false, summary: `更新失败:${friendlyReason(err)}${snap ? `\n已有升级前快照 ${snap},可 /backup restore ${snap} 回滚。` : ''}` };
|
|
765
|
+
}
|
|
766
|
+
return {
|
|
767
|
+
ok: true,
|
|
768
|
+
updated: true,
|
|
769
|
+
current: chk.latest,
|
|
770
|
+
latest: chk.latest,
|
|
771
|
+
summary: `已更新到 ${chk.latest}。${snap ? `\n更新前已留升级前快照 ${snap}(想回退:/backup restore ${snap})。` : '\n更新前快照未生成(不影响本次更新)。'}\n请重启 dsh web 使新版本生效。`,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
646
775
|
function defaultKeep() {
|
|
647
776
|
const cfg = resolvedConfig();
|
|
648
777
|
const k = Number(cfg.keep);
|
|
@@ -1008,7 +1137,14 @@ export function apply(ctx, pluginConfig) {
|
|
|
1008
1137
|
const redacted = skipVault ? await existingSensitive(dshHome) : await refreshVault(dshHome, home, signal);
|
|
1009
1138
|
const tar = await ctx.subprocess.resolveExecutable('tar');
|
|
1010
1139
|
const redactFlags = isTyped ? [] : redactExcludeFlags(base);
|
|
1011
|
-
|
|
1140
|
+
try {
|
|
1141
|
+
await spawnRun([tar, '--exclude=*node_modules*', '--exclude=.system', ...quarantineFlags, ...redactFlags, ...extraExcludes(), '-czf', name, '-C', parentTar(parent), ...(isTyped ? typePaths : [base])], root, signal);
|
|
1142
|
+
} catch (err) {
|
|
1143
|
+
// tar 失败前已创建 -f 目标——空壳归档留在备份目录会被面板当正常备份
|
|
1144
|
+
// 列出来(#88:29 字节静默空包)。删掉空壳再抛,让失败可见。
|
|
1145
|
+
await rm(out, { force: true }).catch(() => {});
|
|
1146
|
+
throw err;
|
|
1147
|
+
}
|
|
1012
1148
|
|
|
1013
1149
|
const shaText = await sha256File(out, home, signal);
|
|
1014
1150
|
await writeOwned(`${out}.sha256`, `${shaText} ${out}\n`);
|
|
@@ -1683,7 +1819,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
1683
1819
|
}
|
|
1684
1820
|
try {
|
|
1685
1821
|
const tar = await ctx.subprocess.resolveExecutable('tar');
|
|
1686
|
-
await spawnRun([tar, '-xzf', picked.name, '-C', parent, ...fileTargets], root, signal);
|
|
1822
|
+
await spawnRun([tar, '-xzf', picked.name, '-C', parentTar(parent), ...fileTargets], root, signal);
|
|
1687
1823
|
} catch (err) {
|
|
1688
1824
|
// 对齐 #28 / doctorRepair 回滚:从留档还原每个被覆盖项;连还原都失败
|
|
1689
1825
|
// 时把留档路径交代清楚,绝不静默停在半截状态。
|
|
@@ -1751,7 +1887,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
1751
1887
|
}
|
|
1752
1888
|
try {
|
|
1753
1889
|
const tar = await ctx.subprocess.resolveExecutable('tar');
|
|
1754
|
-
await spawnRun([tar, '-xzf', picked.name, '-C', parent], root, signal);
|
|
1890
|
+
await spawnRun([tar, '-xzf', picked.name, '-C', parentTar(parent)], root, signal);
|
|
1755
1891
|
} catch (err) {
|
|
1756
1892
|
// 挪旁之后解压失败会让 .dsh 处于空位——这是恢复流程里用户最容易慌
|
|
1757
1893
|
// 的时刻。尽力自动回滚:清掉半截解压产物、把原数据改回原名;连回滚
|
|
@@ -2420,7 +2556,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
2420
2556
|
// 交代清楚,绝不静默停在最糟的中间态。
|
|
2421
2557
|
try {
|
|
2422
2558
|
const tar = await ctx.subprocess.resolveExecutable('tar');
|
|
2423
|
-
await spawnRun([tar, '-xzf', picked.name, '-C', parent, ...targets.map((t) => t.entry)], root, signal);
|
|
2559
|
+
await spawnRun([tar, '-xzf', picked.name, '-C', parentTar(parent), ...targets.map((t) => t.entry)], root, signal);
|
|
2424
2560
|
} catch (err) {
|
|
2425
2561
|
const restored = new Set();
|
|
2426
2562
|
for (let i = 0; i < targets.length; i++) {
|
|
@@ -2600,7 +2736,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
2600
2736
|
// ---------- /backup 命令 ----------
|
|
2601
2737
|
ctx.commands.register({
|
|
2602
2738
|
name: 'backup',
|
|
2603
|
-
description: '备份/恢复 DSH 数据;子命令: list | verify [前缀|all] | restore <前缀|latest> [--dry-run] [--sync-deps] | doctor [--repair <前缀|latest>] | migrate-check | auto [N小时|off] | github status|sync|pull|repo|token <token|off> | [--keep N]',
|
|
2739
|
+
description: '备份/恢复 DSH 数据;子命令: list | verify [前缀|all] | restore <前缀|latest> [--dry-run] [--sync-deps] | doctor [--repair <前缀|latest>] | migrate-check | auto [N小时|off] | github status|sync|pull|repo|token <token|off> | check-update | update [--profile <名>] | [--keep N]',
|
|
2604
2740
|
handler: async (invocation) => {
|
|
2605
2741
|
const input = invocation.rawInput.trim();
|
|
2606
2742
|
try {
|
|
@@ -2664,6 +2800,25 @@ export function apply(ctx, pluginConfig) {
|
|
|
2664
2800
|
return { kind: r.failCount || !r.env.hardlink || r.env.credentialsFlat ? 'error' : 'success', text: r.summary };
|
|
2665
2801
|
}
|
|
2666
2802
|
|
|
2803
|
+
if (head === 'check-update' || head === 'update') {
|
|
2804
|
+
const profileIdx = parts.indexOf('--profile');
|
|
2805
|
+
const profile = profileIdx >= 0 && parts[profileIdx + 1] && !parts[profileIdx + 1].startsWith('--')
|
|
2806
|
+
? parts[profileIdx + 1]
|
|
2807
|
+
: 'web';
|
|
2808
|
+
if (head === 'check-update') {
|
|
2809
|
+
const u = await checkUpdate(invocation.signal);
|
|
2810
|
+
if (u.error) return { kind: 'error', text: `检查更新失败:${u.error}` };
|
|
2811
|
+
return {
|
|
2812
|
+
kind: 'success',
|
|
2813
|
+
text: u.update
|
|
2814
|
+
? `发现新版本:${u.current} → ${u.latest}\n输入 /backup update 一键更新(更新前自动留升级前快照)`
|
|
2815
|
+
: `已是最新版本:${u.current}`,
|
|
2816
|
+
};
|
|
2817
|
+
}
|
|
2818
|
+
const r = await runUpdate(invocation.signal, profile);
|
|
2819
|
+
return { kind: r.ok ? 'success' : 'error', text: r.summary };
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2667
2822
|
if (head === 'github') {
|
|
2668
2823
|
const arg = parts[1] || 'status';
|
|
2669
2824
|
if (arg === 'repo') {
|
|
@@ -3093,6 +3248,22 @@ export function apply(ctx, pluginConfig) {
|
|
|
3093
3248
|
await writeStoredGithubToken(raw);
|
|
3094
3249
|
return { ok: true, tokenSet: true, summary: 'GitHub token 已保存:存在本机备份目录(0600),不进归档、不进 GitHub 同步。' };
|
|
3095
3250
|
},
|
|
3251
|
+
checkUpdate: async (signal) => {
|
|
3252
|
+
const u = await checkUpdate(signal);
|
|
3253
|
+
if (u.error) return { ok: false, current: SELF_META.version, latest: null, update: false, error: u.error, summary: `检查更新失败:${u.error}` };
|
|
3254
|
+
return {
|
|
3255
|
+
ok: true,
|
|
3256
|
+
current: u.current,
|
|
3257
|
+
latest: u.latest,
|
|
3258
|
+
update: u.update,
|
|
3259
|
+
error: null,
|
|
3260
|
+
summary: u.update ? `发现新版本:${u.current} → ${u.latest}` : `已是最新版本:${u.current}`,
|
|
3261
|
+
};
|
|
3262
|
+
},
|
|
3263
|
+
update: async (signal) => {
|
|
3264
|
+
const r = await runUpdate(signal, 'web');
|
|
3265
|
+
return { ok: r.ok, updated: r.updated, current: r.current ?? null, latest: r.latest ?? null, summary: r.summary };
|
|
3266
|
+
},
|
|
3096
3267
|
};
|
|
3097
3268
|
|
|
3098
3269
|
// 仅在装配了 Typert registry 的 profile(Web)里挂载面板服务;其余 profile 安静跳过。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiaoyuyu6420/dsh-backup",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "一键备份与恢复 DeepSeek Harness(dsh)的全部用户数据(~/.dsh):定时自动备份、升级快照、会话日志体检修复、迁移预检、宿主起不来也能用的救援通道、凭据脱敏、GitHub 同步跨机迁移,附可视化面板。 One command to back up & restore all of ~/.dsh: /backup, scheduled auto-backup, upgrade snapshots, session doctor & repair, migrate precheck, out-of-process rescue console, credential redaction, GitHub sync.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.24.0",
|
package/rescue/rescue.mjs
CHANGED
|
@@ -84,6 +84,10 @@ function run(argv, cwd) {
|
|
|
84
84
|
return r.stdout.toString('utf8');
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// #88:同 lib/index.js——parent 由字符串截取求得,DSH_HOME 挂盘符根时退化
|
|
88
|
+
// 为 'D:',裸盘符作 tar -C 会失败。-C 一律传补过分隔符的值。
|
|
89
|
+
const parentTar = (p) => (p.endsWith('/') ? p : `${p}/`);
|
|
90
|
+
|
|
87
91
|
async function sha256File(absPath) {
|
|
88
92
|
const info = await fs.stat(absPath);
|
|
89
93
|
if (info.size > HASH_MAX_BYTES) throw new Error(`文件 ${Math.floor(info.size / 1048576)}MB 超过哈希上限`);
|
|
@@ -420,7 +424,7 @@ async function doctorRepair(root, dshHome, selector) {
|
|
|
420
424
|
kept.push(keep);
|
|
421
425
|
}
|
|
422
426
|
try {
|
|
423
|
-
run(['tar', '-xzf', picked.name, '-C', parent, ...targets.map((t) => t.entry)], root);
|
|
427
|
+
run(['tar', '-xzf', picked.name, '-C', parentTar(parent), ...targets.map((t) => t.entry)], root);
|
|
424
428
|
} catch (err) {
|
|
425
429
|
const reason = err && err.message ? err.message : String(err);
|
|
426
430
|
const restored = new Set();
|
|
@@ -493,7 +497,13 @@ async function restoreArchive(root, selector, apply) {
|
|
|
493
497
|
const sensitiveNow = new Set(SENSITIVE_DEFAULTS);
|
|
494
498
|
if (Array.isArray(redactedFiles)) for (const rel of redactedFiles) sensitiveNow.add(rel);
|
|
495
499
|
const redactFlags = [...sensitiveNow].flatMap((rel) => [`--exclude=${base}/${rel}`, `--exclude=*/${rel}`]);
|
|
496
|
-
|
|
500
|
+
try {
|
|
501
|
+
run(['tar', '--exclude=*node_modules*', '--exclude=.system', ...redactFlags, '-czf', snapshotName, '-C', parentTar(parent), base], root);
|
|
502
|
+
} catch (err) {
|
|
503
|
+
// tar 失败留下的空壳快照不能留在恢复点列表里冒充可用备份(#88)
|
|
504
|
+
await fs.rm(`${root}/${snapshotName}`, { force: true }).catch(() => {});
|
|
505
|
+
throw err;
|
|
506
|
+
}
|
|
497
507
|
// 清掉上一次的 pre-restore 快照(只保留本次),防累积
|
|
498
508
|
for (const name of await fs.readdir(root)) {
|
|
499
509
|
if (name.startsWith('dsh-pre-restore-') && name !== snapshotName) {
|
|
@@ -510,7 +520,7 @@ async function restoreArchive(root, selector, apply) {
|
|
|
510
520
|
aside = `${parent}/${asideName}`;
|
|
511
521
|
}
|
|
512
522
|
try {
|
|
513
|
-
run(['tar', '-xzf', picked.name, '-C', parent], root);
|
|
523
|
+
run(['tar', '-xzf', picked.name, '-C', parentTar(parent)], root);
|
|
514
524
|
} catch (err) {
|
|
515
525
|
const reason = err && err.message ? err.message : String(err);
|
|
516
526
|
let rolledBack = false;
|