@xiaoyuyu6420/dsh-backup 0.9.0 → 0.10.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 +100 -19
- package/README.zh.md +98 -17
- package/lib/client.js +49 -17
- package/lib/index.js +441 -79
- package/package.json +7 -2
- package/rescue/rescue.mjs +29 -5
package/lib/index.js
CHANGED
|
@@ -78,6 +78,55 @@ const SENSITIVE_DEFAULTS = ['.credentials.yaml', '.env', 'qq-bridge/config.json'
|
|
|
78
78
|
/** 备份目录下保存明文敏感文件的子目录(随备份刷新为最新一份的副本)。 */
|
|
79
79
|
const VAULT_DIR = 'vault';
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* 分类型备份的内容类型表(相对 ~/.dsh 根的 POSIX 路径)。
|
|
83
|
+
* 每类映射一组路径:文件用具体名、目录用目录名;mcp 用 glob(承载组合
|
|
84
|
+
* 文件,连带其他 cordis 行——MCP 无独立目录,声明嵌在 cordis 组合层里)。
|
|
85
|
+
* credentials 类反转脱敏:明文进归档(跳过 redactExcludeFlags),但这类
|
|
86
|
+
* 归档在 githubSync 里被识别并跳过,永不上一线。旧归档(meta.types 缺省)
|
|
87
|
+
* 视为全量,与分类型归档(dsh-t- 前缀)独立轮换、不进全量列表。
|
|
88
|
+
*/
|
|
89
|
+
const BACKUP_TYPES = Object.freeze({
|
|
90
|
+
credentials: { label: '凭据/api key', paths: ['.credentials.yaml', '.env', 'qq-bridge/config.json'], credentials: true },
|
|
91
|
+
mcp: { label: 'MCP 配置', paths: ['cordis.patch.yml', 'profiles/*/cordis.patch.yml', 'profiles/*/cordis.yml'] },
|
|
92
|
+
skills: { label: '技能', paths: ['skills'] },
|
|
93
|
+
sessions: { label: '会话', paths: ['sessions'] },
|
|
94
|
+
settings: { label: '设置', paths: ['settings.yaml'] },
|
|
95
|
+
profiles: { label: '插件 profiles', paths: ['profiles'] },
|
|
96
|
+
});
|
|
97
|
+
const BACKUP_TYPE_KEYS = Object.freeze(Object.keys(BACKUP_TYPES));
|
|
98
|
+
/** 分类型归档前缀(与全量 dsh- 区分:独立轮换、不进全量列表、旧版插件不可见不误删)。 */
|
|
99
|
+
const TYPED_PREFIX = 'dsh-t-';
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 按类型 globs 匹配归档内条目(restore 侧):把 BACKUP_TYPES 的相对路径转成
|
|
103
|
+
* 对归档条目(${base}/ 前缀)的匹配——文件精确匹配、目录前缀匹配、含 * 的
|
|
104
|
+
* glob 转正则。返回命中的归档条目数组(去重)。与备份侧 typeTargets(文件
|
|
105
|
+
* 系统展开)对称:备份展开实际路径,恢复匹配归档条目。
|
|
106
|
+
*/
|
|
107
|
+
function typeEntries(types, entries, base) {
|
|
108
|
+
const out = [];
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
const esc = (s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
111
|
+
for (const key of types) {
|
|
112
|
+
const spec = BACKUP_TYPES[key];
|
|
113
|
+
if (!spec) continue;
|
|
114
|
+
for (const rel of spec.paths) {
|
|
115
|
+
if (rel.includes('*')) {
|
|
116
|
+
const re = new RegExp(`^${esc(base)}/${esc(rel).replace(/\*/g, '[^/]+')}$`);
|
|
117
|
+
for (const e of entries) if (re.test(e) && !seen.has(e)) { seen.add(e); out.push(e); }
|
|
118
|
+
} else {
|
|
119
|
+
const fileEntry = `${base}/${rel}`;
|
|
120
|
+
const dirPrefix = `${base}/${rel}/`;
|
|
121
|
+
for (const e of entries) {
|
|
122
|
+
if ((e === fileEntry || e.startsWith(dirPrefix)) && !seen.has(e)) { seen.add(e); out.push(e); }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
81
130
|
/** 随备份落盘的救援说明:DSH 起不来时,用户打开备份目录第一眼能看到的东西。 */
|
|
82
131
|
const RESCUE_TXT = `DSH 出问题时的自救通道(不依赖 DSH 能否启动)
|
|
83
132
|
====================================================
|
|
@@ -99,9 +148,12 @@ const RESCUE_TXT = `DSH 出问题时的自救通道(不依赖 DSH 能否启动
|
|
|
99
148
|
|
|
100
149
|
/** 双击启动器:起救援服务并自动开浏览器。按当前平台生成,避免目录里躺三个系统的文件。 */
|
|
101
150
|
const RESCUE_LAUNCHERS = Object.freeze({
|
|
102
|
-
|
|
151
|
+
// 双击 .command 时 GUI 的 PATH 只有 /usr/bin:/bin:...,nvm/Homebrew 装的
|
|
152
|
+
// node 找不到——直接 exec 会静默失败且窗口即关(UX 审查 P1-3)。先依次
|
|
153
|
+
// 探测常见安装位置;全落空时给人话指引并保持窗口不关。
|
|
154
|
+
darwin: ['点我恢复.command', '#!/bin/sh\ncd "$(dirname "$0")" || exit 1\nNODE_BIN="$(command -v node)"\nif [ -z "$NODE_BIN" ]; then\n for c in /opt/homebrew/bin/node /usr/local/bin/node "$HOME"/.nvm/versions/node/*/bin/node; do\n if [ -x "$c" ]; then NODE_BIN="$c"; break; fi\n done\nfi\nif [ -z "$NODE_BIN" ]; then\n echo "没有找到 node。请先安装 Node.js(https://nodejs.org),然后重新双击本文件。"\n echo "也可以在终端进入本目录手动执行: node rescue.mjs serve --open"\n printf "\\n按回车键关闭窗口…"\n read _\n exit 1\nfi\nexec "$NODE_BIN" rescue.mjs serve --open\n'],
|
|
103
155
|
win32: ['点我恢复.bat', '@echo off\r\nchcp 65001 >nul\r\ncd /d "%~dp0"\r\nnode rescue.mjs serve --open\r\npause\r\n'],
|
|
104
|
-
linux: ['点我恢复.sh', '#!/bin/sh\ncd "$(dirname "$0")" || exit 1\
|
|
156
|
+
linux: ['点我恢复.sh', '#!/bin/sh\ncd "$(dirname "$0")" || exit 1\nNODE_BIN="$(command -v node)"\nif [ -z "$NODE_BIN" ]; then\n for c in /usr/local/bin/node /opt/node/bin/node "$HOME"/.nvm/versions/node/*/bin/node "$HOME"/.local/bin/node; do\n if [ -x "$c" ]; then NODE_BIN="$c"; break; fi\n done\nfi\nif [ -z "$NODE_BIN" ]; then\n echo "没有找到 node。请先安装 Node.js,然后在终端进入本目录执行: node rescue.mjs serve --open"\n exit 1\nfi\nexec "$NODE_BIN" rescue.mjs serve --open\n'],
|
|
105
157
|
});
|
|
106
158
|
|
|
107
159
|
/**
|
|
@@ -109,7 +161,8 @@ const RESCUE_LAUNCHERS = Object.freeze({
|
|
|
109
161
|
* 纯函数、模块级:轮换分层与测试共用。
|
|
110
162
|
*/
|
|
111
163
|
function archiveDay(name) {
|
|
112
|
-
|
|
164
|
+
// dsh-t-(分类型归档)与 dsh-(全量)共用同一时间戳格式;前缀可选 t-。
|
|
165
|
+
const m = /^dsh-(?:t-)?(\d{4})(\d{2})(\d{2})-\d{6}\d{3}\.tar\.gz$/.exec(name);
|
|
113
166
|
return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
|
|
114
167
|
}
|
|
115
168
|
|
|
@@ -329,9 +382,9 @@ function panelDescriptor(method, parameters, cancellation) {
|
|
|
329
382
|
|
|
330
383
|
const PANEL_INVOCATIONS = Object.freeze([
|
|
331
384
|
panelDescriptor('status', []),
|
|
332
|
-
panelDescriptor('backup', ['keep'], true),
|
|
385
|
+
panelDescriptor('backup', ['keep', 'types'], true),
|
|
333
386
|
panelDescriptor('verify', ['selector'], true),
|
|
334
|
-
panelDescriptor('restore', ['selector', 'dryRun'], true),
|
|
387
|
+
panelDescriptor('restore', ['selector', 'dryRun', 'types', 'syncDeps'], true),
|
|
335
388
|
panelDescriptor('setAuto', ['hours']),
|
|
336
389
|
panelDescriptor('githubStatus', []),
|
|
337
390
|
panelDescriptor('githubSyncNow', [], true),
|
|
@@ -359,9 +412,9 @@ class BackupPanelService extends TypertRemoteService {
|
|
|
359
412
|
return this.ops.status();
|
|
360
413
|
}
|
|
361
414
|
|
|
362
|
-
/**
|
|
363
|
-
backup(keep, signal) {
|
|
364
|
-
return this.ops.backup(keep, signal);
|
|
415
|
+
/** 立即备份(types 非空时分类型子集备份)。 */
|
|
416
|
+
backup(keep, types, signal) {
|
|
417
|
+
return this.ops.backup(keep, types, signal);
|
|
365
418
|
}
|
|
366
419
|
|
|
367
420
|
/** 校验(selector=前缀|all|latest)。 */
|
|
@@ -369,9 +422,9 @@ class BackupPanelService extends TypertRemoteService {
|
|
|
369
422
|
return this.ops.verify(selector, signal);
|
|
370
423
|
}
|
|
371
424
|
|
|
372
|
-
/** 恢复(dryRun
|
|
373
|
-
restore(selector, dryRun, signal) {
|
|
374
|
-
return this.ops.restore(selector, dryRun, signal);
|
|
425
|
+
/** 恢复(dryRun 仅预览;types 非空时分类型 merge 恢复;syncDeps 恢复后重装 profile 依赖)。 */
|
|
426
|
+
restore(selector, dryRun, types, syncDeps, signal) {
|
|
427
|
+
return this.ops.restore(selector, dryRun, types, syncDeps, signal);
|
|
375
428
|
}
|
|
376
429
|
|
|
377
430
|
/** 设置自动备份(0=关闭)。 */
|
|
@@ -529,6 +582,34 @@ export function apply(ctx, pluginConfig) {
|
|
|
529
582
|
return { out, err };
|
|
530
583
|
}
|
|
531
584
|
|
|
585
|
+
/**
|
|
586
|
+
* 错误翻译层:把底层错误(fs 错误码、tar/git 的英文 stderr、JS 异常)映射
|
|
587
|
+
* 成「人话原因 + 折叠的技术细节」。所有面向用户的 catch 出口统一经此——
|
|
588
|
+
* 直接拼 err.message 会把 ENOENT/tar stderr 甩给最慌的用户(UX 审查
|
|
589
|
+
* P1-4:19 处直拼的模式级问题),在这里一处收口。
|
|
590
|
+
*/
|
|
591
|
+
function friendlyReason(err) {
|
|
592
|
+
const raw = String(err && err.message ? err.message : err);
|
|
593
|
+
// 内层(如 restoreArchive 的解压失败分支)已翻译过——不重复包一层
|
|
594
|
+
if (raw.includes('技术细节: ')) return raw;
|
|
595
|
+
if (/gzip decompression failed|not in gzip format|invalid distance|unexpected end of file|Canto open|damaged|failed to open|cannot open/i.test(raw)) {
|
|
596
|
+
return `归档内容损坏或不是有效的压缩包(技术细节: ${snippet(raw, 160)})`;
|
|
597
|
+
}
|
|
598
|
+
if (/\bENOENT\b/.test(raw)) {
|
|
599
|
+
return `需要的文件不存在——可能已被轮换清理,或路径变动(技术细节: ${snippet(raw, 160)})`;
|
|
600
|
+
}
|
|
601
|
+
if (/\b(ENOSPC|EDQUOT)\b/.test(raw)) {
|
|
602
|
+
return `磁盘空间不足,请释放空间后重试(技术细节: ${snippet(raw, 160)})`;
|
|
603
|
+
}
|
|
604
|
+
if (/\b(EACCES|EPERM|EROFS)\b/.test(raw)) {
|
|
605
|
+
return `权限不足,无法读写目标位置(技术细节: ${snippet(raw, 160)})`;
|
|
606
|
+
}
|
|
607
|
+
if (/Cannot read propert|is not a function|undefined is not/i.test(raw)) {
|
|
608
|
+
return `程序内部错误,请带着下面的细节反馈给作者(技术细节: ${snippet(raw, 160)})`;
|
|
609
|
+
}
|
|
610
|
+
return raw;
|
|
611
|
+
}
|
|
612
|
+
|
|
532
613
|
function paths() {
|
|
533
614
|
const env = ctx.get('launchEnvironment');
|
|
534
615
|
// Windows 上 launchEnvironment 无 HOME 时退回 USERPROFILE。
|
|
@@ -679,13 +760,9 @@ export function apply(ctx, pluginConfig) {
|
|
|
679
760
|
await rename(`${dir}/${srcName}`, `${dir}/${dstName}`);
|
|
680
761
|
}
|
|
681
762
|
|
|
682
|
-
/**
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
* 次恢复后清掉前一次的快照防隐藏累积(aside 目录仍由用户自行管理)。
|
|
686
|
-
*/
|
|
687
|
-
/** 清理内部快照(pre-restore / pre-upgrade 共用):只保留最近 keepN 份。 */
|
|
688
|
-
async function prunePrefixedSnapshots(prefix, keepN, keepName, signal) {
|
|
763
|
+
/** 清理内部快照(pre-restore / pre-upgrade 共用):只保留最近 keepN 份;protect 里的归档即使落在窗口外也强制保留。 */
|
|
764
|
+
async function prunePrefixedSnapshots(prefix, keepN, keepNames, signal) {
|
|
765
|
+
const protect = Array.isArray(keepNames) ? keepNames : [keepNames];
|
|
689
766
|
const { root } = paths();
|
|
690
767
|
let dirents;
|
|
691
768
|
try {
|
|
@@ -700,13 +777,18 @@ export function apply(ctx, pluginConfig) {
|
|
|
700
777
|
.reverse();
|
|
701
778
|
// 归档名排序即时间序;边车跟宿主同名排序紧邻其后,按"每份归档一组"数份数
|
|
702
779
|
const archiveCount = names.filter((n) => n.endsWith('.tar.gz'));
|
|
703
|
-
const staleArchives = archiveCount.slice(keepN).filter((n) => n
|
|
780
|
+
const staleArchives = archiveCount.slice(keepN).filter((n) => !protect.includes(n));
|
|
704
781
|
const staleSet = new Set(staleArchives.flatMap((n) => sidecarsFor(n)));
|
|
705
782
|
if (staleSet.size) await removeFiles([...staleSet], root, signal);
|
|
706
783
|
}
|
|
707
784
|
|
|
708
|
-
|
|
709
|
-
|
|
785
|
+
/**
|
|
786
|
+
* 恢复流程收尾的快照清理。keepNames 必须同时含新拍的快照与本次恢复的
|
|
787
|
+
* 目标归档——目标本身可能就是一份 dsh-pre-restore-* 快照,只保护新快照
|
|
788
|
+
* 会把正在恢复的归档清掉,解压必然 ENOENT(UX 审查实测的自毁死路)。
|
|
789
|
+
*/
|
|
790
|
+
async function prunePreRestoreSnapshots(keepNames, signal) {
|
|
791
|
+
await prunePrefixedSnapshots('dsh-pre-restore-', 1, keepNames, signal);
|
|
710
792
|
}
|
|
711
793
|
|
|
712
794
|
/** 归档伴生边车集:轮换、删除与 GitHub 同步对同一归档统一处理的文件列表。 */
|
|
@@ -731,10 +813,15 @@ export function apply(ctx, pluginConfig) {
|
|
|
731
813
|
// 内部快照(dsh-pre-restore-* / dsh-pre-upgrade-*)不进用户列表、
|
|
732
814
|
// 不参与轮换、不被 pickArchive 的 latest 误选;显式前缀仍可选中
|
|
733
815
|
// (见 pickArchive 的快照回退分支)。
|
|
734
|
-
if (!d.name.startsWith('dsh-') || SNAPSHOT_PREFIXES.some((p) => d.name.startsWith(p)) || !d.name.endsWith('.tar.gz')) continue;
|
|
816
|
+
if (!d.name.startsWith('dsh-') || SNAPSHOT_PREFIXES.some((p) => d.name.startsWith(p)) || d.name.startsWith(TYPED_PREFIX) || !d.name.endsWith('.tar.gz')) continue;
|
|
735
817
|
let size;
|
|
736
818
|
try {
|
|
737
|
-
|
|
819
|
+
const st = await fsStat(`${root}/${d.name}`);
|
|
820
|
+
// 正在写入的归档(tar 直写最终文件名,写完前 size=0)先不进列表——
|
|
821
|
+
// 用户刷新撞见 0 字节条目会以为备份坏了(UX 审查 P1-7);写入失败
|
|
822
|
+
// 残留的 0 字节文件 60 秒后仍会显示出来,不会被永久隐藏。
|
|
823
|
+
if (st.size === 0 && Date.now() - st.mtimeMs < 60_000) continue;
|
|
824
|
+
size = st.size;
|
|
738
825
|
} catch {
|
|
739
826
|
// 归档在列表与 stat 之间被删除(轮换/删除竞态)——大小显示为未知而非报错
|
|
740
827
|
size = undefined;
|
|
@@ -787,13 +874,90 @@ export function apply(ctx, pluginConfig) {
|
|
|
787
874
|
return createHash('sha256').update(bytes).digest('hex');
|
|
788
875
|
}
|
|
789
876
|
|
|
877
|
+
async function existsStat(p) {
|
|
878
|
+
try { await fsStat(p); return true; } catch { return false; }
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* 递归展开相对 ~/.dsh 的星号通配(spawnRun 不经 shell,tar 位置参数需字面
|
|
883
|
+
* 路径)。只返回实际存在的路径。mcp 类 profiles 下各 profile 的
|
|
884
|
+
* cordis.yml / cordis.patch.yml 靠它展开。
|
|
885
|
+
*/
|
|
886
|
+
async function expandGlob(rel, root) {
|
|
887
|
+
const parts = rel.split('/');
|
|
888
|
+
const out = [];
|
|
889
|
+
async function walk(i, dir, acc) {
|
|
890
|
+
if (i >= parts.length) { if (acc) out.push(acc); return; }
|
|
891
|
+
const seg = parts[i];
|
|
892
|
+
if (seg === '*') {
|
|
893
|
+
let dirents;
|
|
894
|
+
try { dirents = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
895
|
+
for (const d of dirents) {
|
|
896
|
+
if (!d.isDirectory()) continue;
|
|
897
|
+
await walk(i + 1, `${dir}/${d.name}`, acc ? `${acc}/${d.name}` : d.name);
|
|
898
|
+
}
|
|
899
|
+
} else {
|
|
900
|
+
const next = `${dir}/${seg}`;
|
|
901
|
+
if (!(await existsStat(next))) return;
|
|
902
|
+
await walk(i + 1, next, acc ? `${acc}/${seg}` : seg);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
await walk(0, root, '');
|
|
906
|
+
return out;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* 把分类型名展开成归档内相对路径(${base}/ 前缀),预检存在性、展开 glob。
|
|
911
|
+
* 返回 { targets, hasCredentials }:targets 供 tar 位置参数(-C parent ...targets);
|
|
912
|
+
* hasCredentials 标记是否含凭据类——凭据明文进归档,githubSync 据此跳过上云。
|
|
913
|
+
* 不存在的路径跳过(tar 对不存在路径会报错中止);glob 展开后同样预检。
|
|
914
|
+
*/
|
|
915
|
+
async function typeTargets(types, dshHome, base) {
|
|
916
|
+
const targets = [];
|
|
917
|
+
let hasCredentials = false;
|
|
918
|
+
const seen = new Set();
|
|
919
|
+
for (const key of types) {
|
|
920
|
+
const spec = BACKUP_TYPES[key];
|
|
921
|
+
if (!spec) continue;
|
|
922
|
+
if (spec.credentials) hasCredentials = true;
|
|
923
|
+
for (const rel of spec.paths) {
|
|
924
|
+
const matched = rel.includes('*') ? await expandGlob(rel, dshHome) : ((await existsStat(`${dshHome}/${rel}`)) ? [rel] : []);
|
|
925
|
+
for (const m of matched) {
|
|
926
|
+
const entry = `${base}/${m}`;
|
|
927
|
+
if (!seen.has(entry)) { seen.add(entry); targets.push(entry); }
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return { targets, hasCredentials };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/** 列分类型归档(dsh-t-*,新→旧),供独立轮换与列表展示。 */
|
|
935
|
+
async function listTypedBackups() {
|
|
936
|
+
const { root } = paths();
|
|
937
|
+
let dirents;
|
|
938
|
+
try { dirents = await readdir(root, { withFileTypes: true }); } catch { return []; }
|
|
939
|
+
const backups = [];
|
|
940
|
+
for (const d of dirents) {
|
|
941
|
+
if (!d.name.startsWith(TYPED_PREFIX) || !d.name.endsWith('.tar.gz')) continue;
|
|
942
|
+
let size;
|
|
943
|
+
try { size = (await fsStat(`${root}/${d.name}`)).size; } catch { size = undefined; }
|
|
944
|
+
backups.push({ name: d.name, size });
|
|
945
|
+
}
|
|
946
|
+
return backups.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
|
|
947
|
+
}
|
|
948
|
+
|
|
790
949
|
async function doBackup(keep, signal, opts = {}) {
|
|
791
950
|
const { home, dshHome, root } = paths();
|
|
792
951
|
const keepN = keep && keep > 0 ? Math.floor(keep) : defaultKeep();
|
|
793
952
|
// 状态文件先写:writeText 会自动创建备份目录,替代 mkdir -p(Windows 无 mkdir.exe)。
|
|
794
953
|
await saveAutoState();
|
|
795
954
|
|
|
796
|
-
|
|
955
|
+
// 分类型备份:opts.types 非空时用 dsh-t- 前缀(独立轮换、旧版不可见),
|
|
956
|
+
// tar 按 typeTargets 子集打包而非整个 .dsh。
|
|
957
|
+
const types = Array.isArray(opts?.types) && opts.types.length
|
|
958
|
+
? [...new Set(opts.types.filter((t) => BACKUP_TYPES[t]))]
|
|
959
|
+
: null;
|
|
960
|
+
const prefix = types ? TYPED_PREFIX : (typeof opts?.namePrefix === 'string' && opts.namePrefix ? opts.namePrefix : 'dsh-');
|
|
797
961
|
const name = `${prefix}${stampNow()}.tar.gz`;
|
|
798
962
|
const out = `${root}/${name}`;
|
|
799
963
|
const base = dshHome.split('/').pop();
|
|
@@ -815,19 +979,34 @@ export function apply(ctx, pluginConfig) {
|
|
|
815
979
|
// 敏感文件先从归档排除(redactExcludeFlags),其明文副本进本机 vault。
|
|
816
980
|
// skipVault(pre-restore 快照):数据可能正待恢复/已损,刷新 vault 会把
|
|
817
981
|
// 仅存的凭据副本清掉——只列清单不拷贝,vault 保持上一次常规备份的状态。
|
|
818
|
-
|
|
982
|
+
// 分类型备份:展开选定的类型为 tar 位置参数子集;凭据类要明文进归档,
|
|
983
|
+
// 跳过脱敏排除。types 备份不刷新 vault(归档已自含所选内容,凭据类含明文;
|
|
984
|
+
// 非凭据类子集本不含凭据文件),vault 保持上一次全量备份状态不被干扰。
|
|
985
|
+
let typePaths = null;
|
|
986
|
+
let hasCredentials = false;
|
|
987
|
+
if (types) {
|
|
988
|
+
({ targets: typePaths, hasCredentials } = await typeTargets(types, dshHome, base));
|
|
989
|
+
if (!typePaths.length) throw new Error('选定的类型在 ~/.dsh 里没有可备份的内容(路径均不存在)');
|
|
990
|
+
}
|
|
991
|
+
const isTyped = !!types;
|
|
992
|
+
const skipVault = opts?.skipVault || isTyped;
|
|
993
|
+
const redacted = skipVault ? await existingSensitive(dshHome) : await refreshVault(dshHome, home, signal);
|
|
819
994
|
const tar = await ctx.subprocess.resolveExecutable('tar');
|
|
820
|
-
|
|
995
|
+
const redactFlags = isTyped ? [] : redactExcludeFlags(base);
|
|
996
|
+
await spawnRun([tar, '--exclude=*node_modules*', '--exclude=.system', ...quarantineFlags, ...redactFlags, ...extraExcludes(), '-czf', name, '-C', parent, ...(isTyped ? typePaths : [base])], root, signal);
|
|
821
997
|
|
|
822
998
|
const shaText = await sha256File(out, home, signal);
|
|
823
999
|
await writeOwned(`${out}.sha256`, `${shaText} ${out}\n`);
|
|
824
|
-
//
|
|
825
|
-
|
|
1000
|
+
// types 归档不写 .redacted.json:凭据类明文已在归档内(不靠 vault 还原),
|
|
1001
|
+
// 非凭据类子集本不含凭据。全量归档照旧写脱敏清单供恢复侧从 vault 还原。
|
|
1002
|
+
if (!isTyped && sensitivePaths().length) {
|
|
826
1003
|
await writeOwned(`${out}.redacted.json`, `${JSON.stringify({ files: redacted })}\n`);
|
|
827
1004
|
}
|
|
828
1005
|
// 机器元数据边车:跨机恢复的预检依据(home 不一致 → 绝对路径提示);
|
|
829
1006
|
// quarantined 记录体检隔离、未入档的损坏会话文件。
|
|
830
|
-
|
|
1007
|
+
// meta 记 types(分类型归档标识;缺省=全量,旧版/旧归档照读不误);types
|
|
1008
|
+
// 归档 redacted 记 0(未脱敏),hasCredentials 供 githubSync 跳过上云判断。
|
|
1009
|
+
await writeOwned(`${out}.meta.json`, `${JSON.stringify({ host: hostname(), home, dshHome, createdAt: new Date().toISOString(), redacted: isTyped ? 0 : redacted.length, ...(isTyped ? { types, hasCredentials } : {}), ...(quarantined.length ? { quarantined } : {}) })}\n`);
|
|
831
1010
|
|
|
832
1011
|
// 救援通道:每次备份把进程外恢复工具落进备份目录——宿主起不来时
|
|
833
1012
|
// `node rescue.mjs`(或双击启动器)仍可恢复(DSH 依赖 Node,宿主死了
|
|
@@ -857,12 +1036,16 @@ export function apply(ctx, pluginConfig) {
|
|
|
857
1036
|
// 的每日首份、再往前 4 个不同 ISO 周的每周首份(今日的档内冗余仍按 keep
|
|
858
1037
|
// 裁剪)。严格只比旧的"全删"多保留,绝不多删;非标准命名(解析不出日期)
|
|
859
1038
|
// 保持旧行为不保护。
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
1039
|
+
// 轮换:types 归档与全量归档分桶独立计 keep 窗口(dsh-t- 前缀),互不挤占。
|
|
1040
|
+
// 统一用 rotateBase/rotateStale 变量,使下方 return 在两条路径下都可见。
|
|
1041
|
+
// skipRotate:恢复流程中途拍的快照绝不顺带轮换——目标归档可能正落在
|
|
1042
|
+
// keep 窗口外,轮换会删掉马上要解压的文件(review P1-3)。
|
|
1043
|
+
const rotateBase = isTyped ? await listTypedBackups() : await listBackups();
|
|
1044
|
+
const rotateTail = rotateBase.slice(keepN);
|
|
1045
|
+
const rotateStale = opts.skipRotate
|
|
1046
|
+
? []
|
|
1047
|
+
: rotateTail.filter((b) => !tierProtectedSet(rotateTail, rotateBase[0]?.name).has(b.name)).map((b) => b.name);
|
|
1048
|
+
if (rotateStale.length) await removeFiles(rotateStale.flatMap((n) => sidecarsFor(n)), root, signal);
|
|
866
1049
|
|
|
867
1050
|
// GitHub 同步(失败不回滚备份;状态记入 auto.json)
|
|
868
1051
|
let sync = null;
|
|
@@ -882,7 +1065,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
882
1065
|
await saveAutoState();
|
|
883
1066
|
}
|
|
884
1067
|
|
|
885
|
-
return { path: out, name, sha: shaText, total:
|
|
1068
|
+
return { path: out, name, sha: shaText, total: rotateBase.length, stale: rotateStale.length, keep: keepN, quarantined, sync, ...(isTyped ? { types, hasCredentials } : {}) };
|
|
886
1069
|
}
|
|
887
1070
|
|
|
888
1071
|
/** 备份回执的隔离提示行:体检发现坏会话文件未入档时如实告知 + 指路修复。 */
|
|
@@ -995,6 +1178,8 @@ export function apply(ctx, pluginConfig) {
|
|
|
995
1178
|
// 配置时在 keep 集内保留)与当前备份集(归档+边车),其余文件
|
|
996
1179
|
// (旧副本、误入杂物)清理——git add -A 因此只会收录归档;轮换
|
|
997
1180
|
// 删除与误入文件一并同步移除。
|
|
1181
|
+
// keep 集只含全量归档:listBackups 排除 dsh-t-(分类型归档),故类型归档
|
|
1182
|
+
// 不进 GitHub 同步——含 credentials 明文者尤甚,纵深满足"凭据不上云"。
|
|
998
1183
|
const keep = new Set(['.gitignore']);
|
|
999
1184
|
if (wt.cfg.token) keep.add('.git-credentials');
|
|
1000
1185
|
for (const b of await listBackups()) {
|
|
@@ -1146,10 +1331,10 @@ export function apply(ctx, pluginConfig) {
|
|
|
1146
1331
|
// 边车缺失
|
|
1147
1332
|
}
|
|
1148
1333
|
if (!/^[0-9a-f]{64}$/.test(expected)) {
|
|
1149
|
-
return { name, ok: false, note: '
|
|
1334
|
+
return { name, ok: false, note: '缺少或无效的配套校验文件(.sha256)——无法确认这份备份是否完好' };
|
|
1150
1335
|
}
|
|
1151
1336
|
const actual = await sha256File(archive, home, signal);
|
|
1152
|
-
return { name, ok: actual === expected, note: actual === expected ? '完整' : '
|
|
1337
|
+
return { name, ok: actual === expected, note: actual === expected ? '完整' : '校验和不匹配(归档或校验文件之一可能损坏)' };
|
|
1153
1338
|
}
|
|
1154
1339
|
|
|
1155
1340
|
async function pickArchive(selector) {
|
|
@@ -1168,6 +1353,14 @@ export function apply(ctx, pluginConfig) {
|
|
|
1168
1353
|
if (sHits.length > 1) {
|
|
1169
1354
|
throw new Error(`"${selector}" 匹配多份快照,请加长前缀:\n${sHits.slice(0, 5).map((n) => ` ${n}`).join('\n')}`);
|
|
1170
1355
|
}
|
|
1356
|
+
// 分类型归档不在全量列表(旧版视角不可见),显式名/前缀可在此命中
|
|
1357
|
+
const typed = await listTypedBackups();
|
|
1358
|
+
const tExact = typed.filter((b) => b.name === selector);
|
|
1359
|
+
const tHits = tExact.length ? tExact : typed.filter((b) => b.name.startsWith(selector));
|
|
1360
|
+
if (tHits.length === 1) return tHits[0];
|
|
1361
|
+
if (tHits.length > 1) {
|
|
1362
|
+
throw new Error(`"${selector}" 匹配多份分类型归档,请加长前缀:\n${tHits.slice(0, 5).map((n) => ` ${n.name}`).join('\n')}`);
|
|
1363
|
+
}
|
|
1171
1364
|
throw new Error(`没有匹配 "${selector}" 的备份,/backup list 查看`);
|
|
1172
1365
|
}
|
|
1173
1366
|
throw new Error(`"${selector}" 匹配多份备份,请加长前缀:\n${hits.slice(0, 5).map((b) => ` ${b.name}`).join('\n')}`);
|
|
@@ -1176,7 +1369,17 @@ export function apply(ctx, pluginConfig) {
|
|
|
1176
1369
|
/** 删除指定备份(归档 + 全部边车);选择器经 pickArchive 精确匹配,杜绝路径穿越。 */
|
|
1177
1370
|
async function removeBackup(selector, signal) {
|
|
1178
1371
|
const { root } = paths();
|
|
1179
|
-
|
|
1372
|
+
let picked;
|
|
1373
|
+
try {
|
|
1374
|
+
picked = await pickArchive(selector);
|
|
1375
|
+
} catch (err) {
|
|
1376
|
+
// 分类型归档不在 listBackups(全量视角不可见),删除时按 dsh-t- 名单单独匹配
|
|
1377
|
+
const typed = await listTypedBackups();
|
|
1378
|
+
const exact = typed.filter((b) => b.name === selector);
|
|
1379
|
+
const hits = exact.length ? exact : typed.filter((b) => b.name.startsWith(selector));
|
|
1380
|
+
if (hits.length !== 1) throw err;
|
|
1381
|
+
picked = hits[0];
|
|
1382
|
+
}
|
|
1180
1383
|
await removeFiles(sidecarsFor(picked.name), root, signal);
|
|
1181
1384
|
return { ok: true, name: picked.name, summary: `已删除备份: ${picked.name}` };
|
|
1182
1385
|
}
|
|
@@ -1256,7 +1459,10 @@ export function apply(ctx, pluginConfig) {
|
|
|
1256
1459
|
lines.push(`⚠️ 备份来自另一台机器/用户目录(${preflight.sourceHost ?? '未知主机'},${preflight.sourceHome}),settings 内的绝对路径可能需要调整`);
|
|
1257
1460
|
}
|
|
1258
1461
|
if (Array.isArray(preflight.redactedFiles)) {
|
|
1259
|
-
|
|
1462
|
+
// 0 个凭据文件时"已脱敏"是噪音且误导(让人以为有东西被隐去)——如实说明即可
|
|
1463
|
+
lines.push(preflight.redactedFiles.length
|
|
1464
|
+
? `🔐 该归档已脱敏:${preflight.redactedFiles.length} 个凭据文件不随归档走,恢复时从本机 vault 还原(跨机恢复需重填)`
|
|
1465
|
+
: '🔐 归档不含凭据文件;本机凭据不受恢复影响');
|
|
1260
1466
|
}
|
|
1261
1467
|
if (preflight.legacyUnaccountedCredentials) {
|
|
1262
1468
|
lines.push('⚠️ 归档未携带脱敏清单(v0.6.x 时代格式)且不含凭据文件——恢复后凭据需从本机 vault 或原机器补齐');
|
|
@@ -1354,6 +1560,96 @@ export function apply(ctx, pluginConfig) {
|
|
|
1354
1560
|
},
|
|
1355
1561
|
);
|
|
1356
1562
|
|
|
1563
|
+
// 安全拦截:分类型归档必须带 --types 走 merge。整包路径(挪旁+解压)会把
|
|
1564
|
+
// ~/.dsh 替换成子集内容,丢失未包含的类型数据——rescue 同语义拦截。
|
|
1565
|
+
if (!opts?.types && meta && Array.isArray(meta.types) && meta.types.length) {
|
|
1566
|
+
throw new Error(`${picked.name} 是分类型归档(${meta.types.join(', ')})。整包恢复会丢失其他类型数据——请带 --types ${meta.types.join(',')} 分类型恢复,或选一份全量归档(dsh- 开头)整包恢复。`);
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// 分类型恢复(merge):只把选中类型的条目解压进现有 ~/.dsh,不挪旁整包。
|
|
1570
|
+
// 复用 doctorRepair 的"留档+子集解压+回滚"哲学——打破现有"绝不合并、整包
|
|
1571
|
+
// 覆盖"不变量,用留档兜底。凭据类归档已含明文,直接覆盖;非凭据类
|
|
1572
|
+
// mergeTargets 不含凭据文件,不碰 vault(不走下方的 vault 还原)。
|
|
1573
|
+
if (Array.isArray(opts?.types) && opts.types.length) {
|
|
1574
|
+
const mergeTargets = typeEntries(opts.types, entries, base);
|
|
1575
|
+
if (!mergeTargets.length) {
|
|
1576
|
+
throw new Error(`归档 ${picked.name} 里没有选中类型(${opts.types.join(', ')})的条目`);
|
|
1577
|
+
}
|
|
1578
|
+
// 预检将覆盖的现有文件(留档依据);目录条目 tar 解压时 mkdir 无覆盖
|
|
1579
|
+
// 风险,新增项也不算覆盖——只留档现有文件。
|
|
1580
|
+
const willOverwrite = [];
|
|
1581
|
+
for (const entry of mergeTargets) {
|
|
1582
|
+
const rel = entry.slice(`${base}/`.length);
|
|
1583
|
+
const st = await fsStat(`${dshHome}/${rel}`).then((s) => s, () => null);
|
|
1584
|
+
if (st && st.isFile()) willOverwrite.push({ entry, rel });
|
|
1585
|
+
}
|
|
1586
|
+
// 只解压文件条目:目录条目在 GNU tar 下递归消费内容会让显式成员报
|
|
1587
|
+
// "Not found in archive"(exit 2),在 bsdtar 下又不递归——传文件列表在
|
|
1588
|
+
// 两端行为一致(tar -x 文件成员自动补父目录;doctor 定点修复同模式)。
|
|
1589
|
+
const fileTargets = mergeTargets.filter((e) => !mergeTargets.some((o) => o.startsWith(`${e}/`)));
|
|
1590
|
+
if (dryRun) {
|
|
1591
|
+
return {
|
|
1592
|
+
archive, files: fileTargets.length, sample: fileTargets.slice(0, 24), aside: null, snapshotPath: null, dryRun: true,
|
|
1593
|
+
merge: true, types: opts.types, willOverwrite: willOverwrite.map((w) => w.rel),
|
|
1594
|
+
targetExists, preflight: preflightLines(preflight, profileDirs),
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
// 留档将覆盖的现有文件(.pre-merge-<时间戳>),失败可逐个还原。
|
|
1598
|
+
const stamp = stampNow();
|
|
1599
|
+
const kept = [];
|
|
1600
|
+
for (const w of willOverwrite) {
|
|
1601
|
+
const keep = `${dshHome}/${w.rel}.pre-merge-${stamp}`;
|
|
1602
|
+
await mkdir(dirname(keep), { recursive: true });
|
|
1603
|
+
await copyFile(`${dshHome}/${w.rel}`, keep);
|
|
1604
|
+
kept.push({ ...w, keep });
|
|
1605
|
+
}
|
|
1606
|
+
try {
|
|
1607
|
+
const tar = await ctx.subprocess.resolveExecutable('tar');
|
|
1608
|
+
await spawnRun([tar, '-xzf', picked.name, '-C', parent, ...fileTargets], root, signal);
|
|
1609
|
+
} catch (err) {
|
|
1610
|
+
// 对齐 #28 / doctorRepair 回滚:从留档还原每个被覆盖项;连还原都失败
|
|
1611
|
+
// 时把留档路径交代清楚,绝不静默停在半截状态。
|
|
1612
|
+
const reason = err && err.message ? err.message : String(err);
|
|
1613
|
+
const restored = new Set();
|
|
1614
|
+
for (const w of willOverwrite) {
|
|
1615
|
+
try { await copyFile(w.keep, `${dshHome}/${w.rel}`); restored.add(w.rel); } catch { /* 逐个失败落入下方报告 */ }
|
|
1616
|
+
}
|
|
1617
|
+
if (restored.size === willOverwrite.length) {
|
|
1618
|
+
throw new Error(`分类型恢复失败(${reason})。已从留档还原全部覆盖现场,数据保持恢复前状态;排查后可重试`);
|
|
1619
|
+
}
|
|
1620
|
+
const missing = willOverwrite.filter((w) => !restored.has(w.rel)).map((w) => w.rel);
|
|
1621
|
+
throw new Error(`分类型恢复失败(${reason}),且部分现场未自动还原: ${missing.join(', ')}。对应的 *.pre-merge-${stamp} 留档仍在原目录旁,手工拷回即可`);
|
|
1622
|
+
}
|
|
1623
|
+
// 可选依赖重装:merge 含 profiles 时对新解出的 profile 跑 pnpm install。
|
|
1624
|
+
const deps = { installed: [], failed: [], note: null };
|
|
1625
|
+
if (opts?.syncDeps) {
|
|
1626
|
+
const mergedProfiles = [...new Set(mergeTargets
|
|
1627
|
+
.filter((n) => n.startsWith(`${base}/profiles/`) && n.endsWith('/package.json'))
|
|
1628
|
+
.map((n) => n.slice(`${base}/profiles/`.length).split('/')[0]))];
|
|
1629
|
+
if (mergedProfiles.length) {
|
|
1630
|
+
try {
|
|
1631
|
+
const pnpm = await ctx.subprocess.resolveExecutable('pnpm');
|
|
1632
|
+
for (const p of mergedProfiles) {
|
|
1633
|
+
try {
|
|
1634
|
+
await spawnRun([pnpm, 'install', '--frozen-lockfile'], `${dshHome}/profiles/${p}`, signal);
|
|
1635
|
+
deps.installed.push(p);
|
|
1636
|
+
} catch {
|
|
1637
|
+
await spawnRun([pnpm, 'install'], `${dshHome}/profiles/${p}`, signal);
|
|
1638
|
+
deps.installed.push(p);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
} catch {
|
|
1642
|
+
deps.note = 'pnpm 不可用,跳过依赖重装(配置已恢复)';
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
return {
|
|
1647
|
+
archive, files: fileTargets.length, aside: null, snapshotPath: null, dryRun: false,
|
|
1648
|
+
merge: true, types: opts.types, restored: fileTargets.length,
|
|
1649
|
+
kept: kept.map((k) => k.keep), deps, preflight: preflightLines(preflight, profileDirs),
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1357
1653
|
if (dryRun) {
|
|
1358
1654
|
return {
|
|
1359
1655
|
archive, files: entries.length, sample: entries.slice(0, 12), aside: null, snapshotPath: null, dryRun: true,
|
|
@@ -1366,9 +1662,9 @@ export function apply(ctx, pluginConfig) {
|
|
|
1366
1662
|
// 快照用 dsh-pre-restore- 前缀与用户常规备份区分,经 listBackups 过滤
|
|
1367
1663
|
// 后不进列表/轮换/latest 选择;每次恢复清掉上一次的快照防累积。
|
|
1368
1664
|
const snapshot = targetExists
|
|
1369
|
-
? await doBackup(undefined, signal, { namePrefix: 'dsh-pre-restore-', skipVault: true })
|
|
1665
|
+
? await doBackup(undefined, signal, { namePrefix: 'dsh-pre-restore-', skipVault: true, skipRotate: true })
|
|
1370
1666
|
: null;
|
|
1371
|
-
if (snapshot) await prunePreRestoreSnapshots(snapshot.name, signal);
|
|
1667
|
+
if (snapshot) await prunePreRestoreSnapshots([snapshot.name, picked.name], signal);
|
|
1372
1668
|
const asideName = `${base}.pre-restore-${stampNow()}`;
|
|
1373
1669
|
let aside = null;
|
|
1374
1670
|
if (targetExists) {
|
|
@@ -1392,11 +1688,11 @@ export function apply(ctx, pluginConfig) {
|
|
|
1392
1688
|
// 回滚失败:落入下方的人工指引分支,把位置交代清楚
|
|
1393
1689
|
}
|
|
1394
1690
|
if (rolledBack) {
|
|
1395
|
-
throw new Error(
|
|
1691
|
+
throw new Error(`解压归档失败:${friendlyReason(err)}。已自动还原到恢复前状态,数据未丢失,可处理后重试`);
|
|
1396
1692
|
}
|
|
1397
1693
|
throw new Error(aside
|
|
1398
|
-
?
|
|
1399
|
-
:
|
|
1694
|
+
? `解压归档失败:${friendlyReason(err)},且自动还原未成功。原数据完好保存在 ${aside},把它改名回 "${dshHome}" 即可回到恢复前状态`
|
|
1695
|
+
: `解压归档失败:${friendlyReason(err)},且清理未成功。半截解压产物在 ${dshHome},删除后即可重试`);
|
|
1400
1696
|
}
|
|
1401
1697
|
|
|
1402
1698
|
// vault 还原:脱敏归档恢复出的 ~/.dsh 不含凭据文件,从本机 vault 拷回。
|
|
@@ -1457,6 +1753,28 @@ export function apply(ctx, pluginConfig) {
|
|
|
1457
1753
|
|
|
1458
1754
|
/** 恢复结果的人类可读汇总(命令、模型工具与面板共用)。 */
|
|
1459
1755
|
function summarizeRestore(r) {
|
|
1756
|
+
if (r.merge) {
|
|
1757
|
+
const types = (r.types ?? []).join(', ');
|
|
1758
|
+
if (r.dryRun) {
|
|
1759
|
+
const over = r.willOverwrite ?? [];
|
|
1760
|
+
const shown = r.sample.length;
|
|
1761
|
+
const tail = r.files > shown ? `\n … 共 ${r.files} 项` : '';
|
|
1762
|
+
const overLine = over.length
|
|
1763
|
+
? `\n 其中覆盖现有 ${over.length} 个(先留档 .pre-merge-*): ${over.slice(0, 8).join(', ')}${over.length > 8 ? ' …' : ''}`
|
|
1764
|
+
: '\n 均为新增,不覆盖现有文件';
|
|
1765
|
+
return `📦 分类型恢复预览 —— 只预览,还没动磁盘\n 归档: ${r.archive}\n 类型: ${types}\n 将写入: ${r.files} 项${overLine}\n${(r.preflight ?? []).map((l) => ` ${l}`).join('\n')}\n\n 将写入的条目:\n${r.sample.map((s) => ` ${s}`).join('\n')}${tail}`;
|
|
1766
|
+
}
|
|
1767
|
+
const lines = [
|
|
1768
|
+
'✅ 分类型恢复完成',
|
|
1769
|
+
` 已合并写入: ${r.restored} 项 ← 来自 ${r.archive.split('/').pop()}(类型: ${types})`,
|
|
1770
|
+
];
|
|
1771
|
+
if (r.kept?.length) lines.push(` 覆盖的旧文件已留档: ${r.kept.length} 个(.pre-merge-*,确认后可自行删除)`);
|
|
1772
|
+
for (const p of r.preflight ?? []) if (p.startsWith('⚠️')) lines.push(` ${p}`);
|
|
1773
|
+
if (r.deps?.note) lines.push(` 插件依赖: ${r.deps.note}`);
|
|
1774
|
+
else if (r.deps?.installed?.length) lines.push(` 插件依赖: 已装好 ${r.deps.installed.join(', ')}`);
|
|
1775
|
+
lines.push(' ⏸️ 待办: 重启 dsh 让恢复的类型内容生效');
|
|
1776
|
+
return lines.join('\n');
|
|
1777
|
+
}
|
|
1460
1778
|
if (r.dryRun) {
|
|
1461
1779
|
// 预览要回答两件事:确认后会发生什么(按目标是否已有数据切换口径)、
|
|
1462
1780
|
// 凭据/依赖的 preflight 提醒。此时还没动任何文件,标题必须说清。
|
|
@@ -1464,7 +1782,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
1464
1782
|
? [' · 目标位置还没有 .dsh(首次恢复):直接写入,不做挪动和快照']
|
|
1465
1783
|
: [
|
|
1466
1784
|
' · 当前的 .dsh 整个挪到一旁,改名为 .dsh.pre-restore-<时间>,不会和新数据混在一起',
|
|
1467
|
-
' ·
|
|
1785
|
+
' · 挪之前自动做一份快照备份(恢复前快照只保留最近一次)',
|
|
1468
1786
|
' · 然后把这份归档的内容原样写回 .dsh',
|
|
1469
1787
|
];
|
|
1470
1788
|
const pre = ['', ...moves, ...(r.preflight ?? []).map((l) => ` ${l}`)].join('\n');
|
|
@@ -1487,6 +1805,9 @@ export function apply(ctx, pluginConfig) {
|
|
|
1487
1805
|
if (r.vaultMissing?.length) lines.push(` ⚠️ 凭据缺失需手动重填: ${r.vaultMissing.join(', ')}`);
|
|
1488
1806
|
const installed = r.deps?.installed ?? [];
|
|
1489
1807
|
const failed = r.deps?.failed ?? [];
|
|
1808
|
+
// 待办必须只有一条且顺序正确:node_modules 不随归档,先重装依赖再重启,
|
|
1809
|
+
// 照"直接重启"执行会得到 cannot resolve profile bundle(UX 审查 P1-2)。
|
|
1810
|
+
const depsPending = Boolean(r.deps?.note) || failed.length > 0 || (!installed.length && r.profiles?.length);
|
|
1490
1811
|
if (r.deps?.note) {
|
|
1491
1812
|
lines.push(` 插件依赖: 还没装 —— ${r.deps.note};之后也可用 --sync-deps 补装`);
|
|
1492
1813
|
} else if (failed.length) {
|
|
@@ -1494,9 +1815,11 @@ export function apply(ctx, pluginConfig) {
|
|
|
1494
1815
|
} else if (installed.length) {
|
|
1495
1816
|
lines.push(` 插件依赖: 已装好 ${installed.join(', ')}`);
|
|
1496
1817
|
} else if (r.profiles?.length) {
|
|
1497
|
-
lines.push(' 插件依赖: 还没装
|
|
1818
|
+
lines.push(' 插件依赖: 还没装');
|
|
1498
1819
|
}
|
|
1499
|
-
lines.push(
|
|
1820
|
+
lines.push(depsPending
|
|
1821
|
+
? ' ⏸️ 待办: 先重装插件依赖(重开 dsh 前进各 profile 目录跑 pnpm install),再重启 dsh——顺序不能反,否则起不来'
|
|
1822
|
+
: ' ⏸️ 待办: 重启 dsh,会话和配置才会切换到恢复的内容');
|
|
1500
1823
|
return lines.join('\n');
|
|
1501
1824
|
}
|
|
1502
1825
|
|
|
@@ -1683,7 +2006,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
1683
2006
|
}
|
|
1684
2007
|
}
|
|
1685
2008
|
|
|
1686
|
-
function summarizeDoctorScan(r) {
|
|
2009
|
+
function summarizeDoctorScan(r, opts = {}) {
|
|
1687
2010
|
if (!r.files.length) return '未找到任何会话日志——可能还没有产生过会话,或布局与预期不同。';
|
|
1688
2011
|
const healthy = r.scanned - r.corruptCount - (r.skippedCount ?? 0);
|
|
1689
2012
|
let head = `扫描 ${r.scanned} 个会话日志:${healthy} 健康、${r.corruptCount} 损坏`;
|
|
@@ -1692,7 +2015,9 @@ export function apply(ctx, pluginConfig) {
|
|
|
1692
2015
|
const shown = r.corrupt.slice(0, 15);
|
|
1693
2016
|
const more = r.corrupt.length > shown.length ? `\n …另有 ${r.corrupt.length - shown.length} 个损坏文件` : '';
|
|
1694
2017
|
const detail = shown.map((c) => ` ❌ ${c.rel}\n ${c.reason}`).join('\n');
|
|
1695
|
-
|
|
2018
|
+
// 修复入口按调用面分流:聊天面指聊天命令;面板没有 doctor UI,指到真实可用路径
|
|
2019
|
+
const hint = opts.repairHint ?? '可从备份定点修复:/backup doctor --repair [前缀|latest](修复前会把损坏文件留档为 *.corrupt-*)';
|
|
2020
|
+
return `${head}\n${detail}${more}\n\n${hint}`;
|
|
1696
2021
|
}
|
|
1697
2022
|
|
|
1698
2023
|
/** 只读扫描 ~/.dsh 下全部会话日志;不写任何文件。 */
|
|
@@ -1756,7 +2081,6 @@ export function apply(ctx, pluginConfig) {
|
|
|
1756
2081
|
const tar = await ctx.subprocess.resolveExecutable('tar');
|
|
1757
2082
|
await spawnRun([tar, '-xzf', picked.name, '-C', parent, ...targets.map((t) => t.entry)], root, signal);
|
|
1758
2083
|
} catch (err) {
|
|
1759
|
-
const reason = err && err.message ? err.message : String(err);
|
|
1760
2084
|
const restored = new Set();
|
|
1761
2085
|
for (let i = 0; i < targets.length; i++) {
|
|
1762
2086
|
try {
|
|
@@ -1765,10 +2089,10 @@ export function apply(ctx, pluginConfig) {
|
|
|
1765
2089
|
} catch { /* 单个还原失败:落入下方如实报告 */ }
|
|
1766
2090
|
}
|
|
1767
2091
|
if (restored.size === targets.length) {
|
|
1768
|
-
throw new Error(
|
|
2092
|
+
throw new Error(`定点修复失败:${friendlyReason(err)}。已从留档还原全部损坏现场,数据保持修复前状态,可处理后重试`);
|
|
1769
2093
|
}
|
|
1770
2094
|
const missing = targets.filter((t) => !restored.has(t.rel)).map((t) => t.rel);
|
|
1771
|
-
throw new Error(
|
|
2095
|
+
throw new Error(`定点修复失败:${friendlyReason(err)},且部分现场未自动还原: ${missing.join(', ')}。对应的 *.corrupt-${stamp} 留档仍在原目录旁,手工拷回原位即可`);
|
|
1772
2096
|
}
|
|
1773
2097
|
|
|
1774
2098
|
// 修完复检:从归档解出的副本必须健康,否则如实报告(skipped 不算失败——
|
|
@@ -1867,7 +2191,11 @@ export function apply(ctx, pluginConfig) {
|
|
|
1867
2191
|
lastTrain = typeof parsed?.lastTrain === 'string' && parsed.lastTrain ? parsed.lastTrain : null;
|
|
1868
2192
|
return Number.isFinite(h) && h >= 1 && h <= 720 ? Math.floor(h) : 0;
|
|
1869
2193
|
} catch (err) {
|
|
1870
|
-
|
|
2194
|
+
// ENOENT = 首次使用还没有 auto.json,是正常初始状态,静默按未配置处理;
|
|
2195
|
+
// 其他读取失败(解析错误/权限)才值得告警,且文案要给"影响 + 出路"。
|
|
2196
|
+
if (err && err.code !== 'ENOENT') {
|
|
2197
|
+
console.warn(`[dsh-backup] 自动备份计划文件读取失败,定时备份已暂停(不影响已有备份):执行 /backup auto <小时数> 可重新开启(${String(err && err.message ? err.message : err)})`);
|
|
2198
|
+
}
|
|
1871
2199
|
return 0;
|
|
1872
2200
|
}
|
|
1873
2201
|
}
|
|
@@ -1880,11 +2208,17 @@ export function apply(ctx, pluginConfig) {
|
|
|
1880
2208
|
return Math.max(anchor, Date.now());
|
|
1881
2209
|
}
|
|
1882
2210
|
|
|
2211
|
+
/** 本地时间显示:YYYY-MM-DD HH:mm——不依赖运行时 locale(裸跑环境会落 en-US)。 */
|
|
2212
|
+
function formatWhen(ts) {
|
|
2213
|
+
const d = new Date(ts);
|
|
2214
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
2215
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
1883
2218
|
function autoSummary() {
|
|
1884
2219
|
if (!autoDispose) return '自动备份未开启(/backup auto <N小时> 开启)';
|
|
1885
2220
|
// 绝对节奏:下次 = 上次执行 + 周期;错过(重启间隔超周期)则按现在显示,调度会立即补跑。
|
|
1886
|
-
|
|
1887
|
-
return `自动备份已开启:每 ${autoHours} 小时一次(已持久化,重启续跑),下次约 ${next}${lastAuto ? `;上次自动备份: ${lastAuto}` : ''}`;
|
|
2221
|
+
return `自动备份已开启:每 ${autoHours} 小时一次(已持久化,重启续跑),下次 ${formatWhen(nextAutoAt())}${lastAuto ? `;上次自动备份: ${lastAuto}` : ''}`;
|
|
1888
2222
|
}
|
|
1889
2223
|
|
|
1890
2224
|
/** 链式 timeout 调度:每次触发后按上次执行时间推算下一次,重启后节奏不重置。 */
|
|
@@ -1965,7 +2299,10 @@ export function apply(ctx, pluginConfig) {
|
|
|
1965
2299
|
const dryRun = parts.includes('--dry-run');
|
|
1966
2300
|
const syncDeps = parts.includes('--sync-deps');
|
|
1967
2301
|
const sel = parts.slice(1).find((t) => !t.startsWith('--')) || 'latest';
|
|
1968
|
-
|
|
2302
|
+
// 分类型恢复:--types skills(只把选中类型 merge 进现有 ~/.dsh,不挪旁整包)
|
|
2303
|
+
const typesMatch = input.match(/--(?:types|only)\s+([\w,]+)/);
|
|
2304
|
+
const types = typesMatch ? [...new Set(typesMatch[1].split(',').map((s) => s.trim()).filter(Boolean))] : undefined;
|
|
2305
|
+
const r = await restoreArchive(sel, dryRun, invocation.signal, { syncDeps, ...(types ? { types } : {}) });
|
|
1969
2306
|
return { kind: 'success', text: summarizeRestore(r) };
|
|
1970
2307
|
}
|
|
1971
2308
|
|
|
@@ -2041,7 +2378,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
2041
2378
|
|
|
2042
2379
|
if (head === 'delete' || head === 'rm') {
|
|
2043
2380
|
const sel = parts[1];
|
|
2044
|
-
if (!sel) return { kind: 'error', text: '用法: /backup delete <归档名前缀|latest
|
|
2381
|
+
if (!sel) return { kind: 'error', text: '用法: /backup delete <归档名前缀|latest>(删除不可恢复,需要时可用 /backup 重新备份)' };
|
|
2045
2382
|
const r = await removeBackup(sel, invocation.signal);
|
|
2046
2383
|
return { kind: 'success', text: `🗑️ ${r.summary}` };
|
|
2047
2384
|
}
|
|
@@ -2058,19 +2395,22 @@ export function apply(ctx, pluginConfig) {
|
|
|
2058
2395
|
return { kind: 'error', text: '小时数需为 1~720 之间的数字(如 /backup auto 12)' };
|
|
2059
2396
|
}
|
|
2060
2397
|
await setAuto(h);
|
|
2061
|
-
return { kind: 'success', text: `✅ 自动备份已开启:每 ${h} 小时执行一次(保留 ${autoKeep()} 份,已持久化)。\n${autoSummary()}` };
|
|
2398
|
+
return { kind: 'success', text: `✅ 自动备份已开启:每 ${h} 小时执行一次(保留 ${autoKeep()} 份,已持久化)。\n首次备份正在执行,之后每 ${h} 小时一次。\n${autoSummary()}` };
|
|
2062
2399
|
}
|
|
2063
2400
|
|
|
2064
2401
|
let keep;
|
|
2065
2402
|
const m = input.match(/--keep\s+(\d+)/);
|
|
2066
2403
|
if (m) keep = Number(m[1]);
|
|
2067
|
-
|
|
2404
|
+
// 分类型备份:--types api,mcp,skills(逗号分隔,可多类;--only 同义)。缺省=全量。
|
|
2405
|
+
const typesMatch = input.match(/--(?:types|only)\s+([\w,]+)/);
|
|
2406
|
+
const types = typesMatch ? [...new Set(typesMatch[1].split(',').map((s) => s.trim()).filter(Boolean))] : undefined;
|
|
2407
|
+
const r = await doBackup(keep, invocation.signal, types ? { types } : {});
|
|
2068
2408
|
return {
|
|
2069
2409
|
kind: 'success',
|
|
2070
|
-
text: `✅
|
|
2410
|
+
text: `✅ 备份完成${types ? `(类型: ${types.join(', ')}${r.hasCredentials ? ',含明文凭据·不进 GitHub 同步' : ''})` : ''}\n 文件: ${r.path}\n 校验和: ${r.sha.slice(0, 16)}…\n 轮换: 删除 ${r.stale} 份旧备份(保留 ${r.keep} 份)${quarantineNote(r)}\n ${autoSummary()}`,
|
|
2071
2411
|
};
|
|
2072
2412
|
} catch (err) {
|
|
2073
|
-
return { kind: 'error', text: `备份失败: ${
|
|
2413
|
+
return { kind: 'error', text: `备份失败: ${friendlyReason(err)}` };
|
|
2074
2414
|
}
|
|
2075
2415
|
},
|
|
2076
2416
|
});
|
|
@@ -2087,6 +2427,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
2087
2427
|
dryRun: { type: 'boolean', description: 'mode=restore 时仅预览(不写入);真实恢复前必须先预览并向用户展示确认' },
|
|
2088
2428
|
syncDeps: { type: 'boolean', description: 'mode=restore 时恢复后对各 profile 执行 pnpm install 重装插件依赖' },
|
|
2089
2429
|
repair: { type: 'boolean', description: 'mode=doctor 时从 selector 归档定点修复损坏的会话日志(缺省仅扫描)' },
|
|
2430
|
+
types: { type: 'string', description: 'mode=backup/restore 时按内容类型子集操作(逗号分隔,如 credentials,mcp,skills)。backup=只打选中类型子集归档;restore=只把选中类型 merge 进现有 ~/.dsh(不挪旁整包,覆盖前留档)' },
|
|
2090
2431
|
},
|
|
2091
2432
|
output: {
|
|
2092
2433
|
schema: { type: 'object', additionalProperties: true },
|
|
@@ -2115,7 +2456,8 @@ export function apply(ctx, pluginConfig) {
|
|
|
2115
2456
|
return { ok: bad === 0, summary: lines.join('\n') || '暂无备份可校验。' };
|
|
2116
2457
|
}
|
|
2117
2458
|
if (mode === 'restore') {
|
|
2118
|
-
const
|
|
2459
|
+
const types = typeof args?.types === 'string' && args.types.trim() ? [...new Set(args.types.split(',').map((s) => s.trim()).filter(Boolean))] : undefined;
|
|
2460
|
+
const r = await restoreArchive(selector || 'latest', Boolean(args && args.dryRun), signal, { syncDeps: Boolean(args && args.syncDeps), ...(types ? { types } : {}) });
|
|
2119
2461
|
return { ok: true, path: r.archive, summary: summarizeRestore(r) };
|
|
2120
2462
|
}
|
|
2121
2463
|
if (mode === 'doctor') {
|
|
@@ -2140,14 +2482,17 @@ export function apply(ctx, pluginConfig) {
|
|
|
2140
2482
|
await setAuto(0);
|
|
2141
2483
|
return { ok: true, summary: '自动备份已关闭。' };
|
|
2142
2484
|
}
|
|
2143
|
-
if (!Number.isFinite(h) || h < 1 || h > 720) return { ok: false, summary: 'hours 需为 1~720' };
|
|
2485
|
+
if (!Number.isFinite(h) || h < 1 || h > 720) return { ok: false, summary: 'hours 需为 1~720 的整数(0=关闭)' };
|
|
2144
2486
|
await setAuto(h);
|
|
2145
|
-
return { ok: true, summary: `自动备份已开启:每 ${h} 小时一次(已持久化)。\n${autoSummary()}` };
|
|
2487
|
+
return { ok: true, summary: `自动备份已开启:每 ${h} 小时一次(已持久化)。\n首次备份正在执行,之后每 ${h} 小时一次。\n${autoSummary()}` };
|
|
2146
2488
|
}
|
|
2147
|
-
const
|
|
2148
|
-
|
|
2489
|
+
const types = typeof args?.types === 'string' && args.types.trim()
|
|
2490
|
+
? [...new Set(args.types.split(',').map((s) => s.trim()).filter(Boolean))]
|
|
2491
|
+
: undefined;
|
|
2492
|
+
const r = await doBackup(args && args.keep ? args.keep : undefined, signal, types ? { types } : {});
|
|
2493
|
+
return { ok: true, path: r.path, sha: r.sha, summary: `备份完成: ${r.path}${types ? `(类型: ${types.join(', ')}${r.hasCredentials ? ',含明文凭据·不进 GitHub 同步' : ''})` : ''}\nsha256: ${r.sha}\n轮换删除 ${r.stale} 份(保留 ${r.keep} 份)${quarantineNote(r)}` };
|
|
2149
2494
|
} catch (err) {
|
|
2150
|
-
return { ok: false, summary: `操作失败: ${
|
|
2495
|
+
return { ok: false, summary: `操作失败: ${friendlyReason(err)}` };
|
|
2151
2496
|
}
|
|
2152
2497
|
},
|
|
2153
2498
|
}));
|
|
@@ -2156,7 +2501,15 @@ export function apply(ctx, pluginConfig) {
|
|
|
2156
2501
|
const panelOps = {
|
|
2157
2502
|
status: async () => {
|
|
2158
2503
|
const all = await listBackups();
|
|
2504
|
+
const typed = await listTypedBackups();
|
|
2159
2505
|
const { root, dshHome } = paths();
|
|
2506
|
+
// 分类型归档单独列出并带类型清单(读各自 meta.types),供面板分区展示
|
|
2507
|
+
const typedBackups = [];
|
|
2508
|
+
for (const b of typed) {
|
|
2509
|
+
let types = [];
|
|
2510
|
+
try { types = JSON.parse(await readFile(`${root}/${b.name}.meta.json`, 'utf8')).types ?? []; } catch { /* 边车缺失 */ }
|
|
2511
|
+
typedBackups.push({ name: b.name, size: typeof b.size === 'number' ? b.size : null, types });
|
|
2512
|
+
}
|
|
2160
2513
|
return {
|
|
2161
2514
|
destination: root,
|
|
2162
2515
|
dshHome,
|
|
@@ -2164,17 +2517,22 @@ export function apply(ctx, pluginConfig) {
|
|
|
2164
2517
|
autoHours,
|
|
2165
2518
|
lastAuto,
|
|
2166
2519
|
backups: all.map((b) => ({ name: b.name, size: typeof b.size === 'number' ? b.size : null })),
|
|
2520
|
+
typedBackups,
|
|
2167
2521
|
};
|
|
2168
2522
|
},
|
|
2169
|
-
backup: async (keep, signal) => {
|
|
2170
|
-
const
|
|
2523
|
+
backup: async (keep, types, signal) => {
|
|
2524
|
+
const validTypes = Array.isArray(types) && types.length ? [...new Set(types.filter((t) => BACKUP_TYPES[t]))] : undefined;
|
|
2525
|
+
const r = await doBackup(keep, signal, validTypes ? { types: validTypes } : {});
|
|
2526
|
+
// 面板回执给"成了、叫什么、存哪了";64 位 sha 全文是噪音(完整值就在
|
|
2527
|
+
// 归档旁的 .sha256),路径用文件名——目录已显示在上方总览卡
|
|
2171
2528
|
return {
|
|
2172
2529
|
ok: true,
|
|
2173
|
-
summary: `备份完成: ${r.path}\nsha256: ${r.sha}
|
|
2530
|
+
summary: `备份完成: ${r.path.split('/').pop()}${validTypes ? `(类型: ${validTypes.join(', ')}${r.hasCredentials ? ',含明文凭据·不进 GitHub 同步' : ''})` : ''}\nsha256: ${r.sha.slice(0, 16)}…(完整校验值在归档旁的 .sha256 文件)${r.stale ? `\n轮换删除 ${r.stale} 份旧备份(保留 ${r.keep} 份)` : ''}${quarantineNote(r)}`,
|
|
2174
2531
|
path: r.path,
|
|
2175
2532
|
sha: r.sha,
|
|
2176
2533
|
stale: r.stale,
|
|
2177
2534
|
keep: r.keep,
|
|
2535
|
+
...(validTypes ? { types: validTypes, hasCredentials: Boolean(r.hasCredentials) } : {}),
|
|
2178
2536
|
};
|
|
2179
2537
|
},
|
|
2180
2538
|
verify: async (selector, signal) => {
|
|
@@ -2192,11 +2550,12 @@ export function apply(ctx, pluginConfig) {
|
|
|
2192
2550
|
}
|
|
2193
2551
|
return { ok: bad === 0, summary: results.map((r) => `${r.ok ? '✅' : '❌'} ${r.name} — ${r.note}`).join('\n') || '暂无备份可校验。', results };
|
|
2194
2552
|
},
|
|
2195
|
-
restore: async (selector, dryRun, signal) => {
|
|
2553
|
+
restore: async (selector, dryRun, types, syncDeps, signal) => {
|
|
2196
2554
|
try {
|
|
2197
|
-
const
|
|
2555
|
+
const validTypes = Array.isArray(types) && types.length ? [...new Set(types.filter((t) => BACKUP_TYPES[t]))] : undefined;
|
|
2556
|
+
const r = await restoreArchive(selector || 'latest', Boolean(dryRun), signal, { syncDeps: Boolean(syncDeps), ...(validTypes ? { types: validTypes } : {}) });
|
|
2198
2557
|
if (r.dryRun) {
|
|
2199
|
-
return { ok: true, dryRun: true, archive: r.archive, files: r.files, sample: r.sample, preflight: r.preflight ?? [], targetExists: r.targetExists, summary: summarizeRestore(r) };
|
|
2558
|
+
return { ok: true, dryRun: true, archive: r.archive, files: r.files, sample: r.sample, preflight: r.preflight ?? [], targetExists: r.targetExists, ...(r.merge ? { merge: true, types: r.types, willOverwrite: r.willOverwrite ?? [] } : {}), summary: summarizeRestore(r) };
|
|
2200
2559
|
}
|
|
2201
2560
|
return {
|
|
2202
2561
|
ok: true,
|
|
@@ -2208,10 +2567,12 @@ export function apply(ctx, pluginConfig) {
|
|
|
2208
2567
|
preflight: r.preflight ?? [],
|
|
2209
2568
|
vaultRestored: r.vaultRestored ?? [],
|
|
2210
2569
|
vaultMissing: r.vaultMissing ?? [],
|
|
2570
|
+
...(r.merge ? { merge: true, types: r.types, restored: r.restored, kept: r.kept ?? [] } : {}),
|
|
2571
|
+
deps: r.deps,
|
|
2211
2572
|
summary: summarizeRestore(r),
|
|
2212
2573
|
};
|
|
2213
2574
|
} catch (err) {
|
|
2214
|
-
return { ok: false, dryRun: Boolean(dryRun), summary:
|
|
2575
|
+
return { ok: false, dryRun: Boolean(dryRun), summary: friendlyReason(err) };
|
|
2215
2576
|
}
|
|
2216
2577
|
},
|
|
2217
2578
|
setAuto: async (hours) => {
|
|
@@ -2220,10 +2581,10 @@ export function apply(ctx, pluginConfig) {
|
|
|
2220
2581
|
return { ok: true, hours: 0, summary: '自动备份已关闭。' };
|
|
2221
2582
|
}
|
|
2222
2583
|
if (!Number.isFinite(hours) || hours < 1 || hours > 720) {
|
|
2223
|
-
return { ok: false, summary: '
|
|
2584
|
+
return { ok: false, summary: '间隔需为 1~720 的整数小时(填 12 表示每 12 小时一次)' };
|
|
2224
2585
|
}
|
|
2225
2586
|
await setAuto(Math.floor(hours));
|
|
2226
|
-
return { ok: true, hours: Math.floor(hours), summary: autoSummary() };
|
|
2587
|
+
return { ok: true, hours: Math.floor(hours), summary: `自动备份已开启:每 ${Math.floor(hours)} 小时一次。\n首次备份正在执行,之后每 ${Math.floor(hours)} 小时一次。\n${autoSummary()}` };
|
|
2227
2588
|
},
|
|
2228
2589
|
githubStatus: async () => {
|
|
2229
2590
|
const cfg = githubConfig();
|
|
@@ -2276,7 +2637,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
2276
2637
|
const r = await removeBackup(selector || 'latest', signal);
|
|
2277
2638
|
return { ok: true, summary: r.summary };
|
|
2278
2639
|
} catch (err) {
|
|
2279
|
-
return { ok: false, summary:
|
|
2640
|
+
return { ok: false, summary: friendlyReason(err) };
|
|
2280
2641
|
}
|
|
2281
2642
|
},
|
|
2282
2643
|
setGithubRepo: async (repo) => {
|
|
@@ -2320,7 +2681,8 @@ export function apply(ctx, pluginConfig) {
|
|
|
2320
2681
|
ok: r.corruptCount === 0,
|
|
2321
2682
|
scanned: r.scanned,
|
|
2322
2683
|
corruptCount: r.corruptCount,
|
|
2323
|
-
|
|
2684
|
+
// 面板没有 doctor UI,指路必须指向真实可用的入口(聊天命令或救援页)
|
|
2685
|
+
summary: summarizeDoctorScan(r, { repairHint: '定点修复目前要在聊天窗执行:/backup doctor --repair [前缀|latest](修复前会把损坏文件留档为 *.corrupt-*);DSH 起不来时可用备份目录里的救援页「体检并修复」' }),
|
|
2324
2686
|
corrupt: r.corrupt.map((c) => ({ path: c.rel, reason: c.reason })),
|
|
2325
2687
|
};
|
|
2326
2688
|
} catch (err) {
|