@xiaoyuyu6420/dsh-backup 0.6.2 → 0.7.1
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 +131 -93
- package/README.zh.md +131 -81
- package/lib/client.js +7 -7
- package/lib/index.js +402 -51
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -12,19 +12,23 @@
|
|
|
12
12
|
* - `/backup auto <N小时>|off|status` 定时自动备份(1~720;保留份数由 config.keep
|
|
13
13
|
* 支配,未配置时 <24h 3 份、否则 7 份;重启按
|
|
14
14
|
* 上次执行时间推算下次,不重置节奏)
|
|
15
|
-
* - `/backup github status|sync|repo <地址|off>`
|
|
15
|
+
* - `/backup github status|sync|pull [--restore <前缀|latest>]|repo <地址|off>`
|
|
16
|
+
* GitHub 同步状态 / 立即推送 / 拉取到本地 / 设置仓库
|
|
16
17
|
* - `/backup delete|rm <前缀|latest>` 删除备份(归档 + 校验边车)
|
|
17
18
|
* - `/backup --keep N` 覆盖本次保留份数
|
|
18
|
-
* - `backup_dsh` 模型工具:mode=backup|list|verify|restore|auto
|
|
19
|
-
* - cordis.yml `config`:destination / keep / exclude / githubRepo(见 README)
|
|
19
|
+
* - `backup_dsh` 模型工具:mode=backup|list|verify|restore|auto(restore 支持 syncDeps)
|
|
20
|
+
* - cordis.yml `config`:destination / keep / exclude / redact / githubRepo(见 README)
|
|
20
21
|
* - Settings「备份」标签页(Web):状态、立即备份、校验、恢复、下载、
|
|
21
|
-
* 自动备份开关与 GitHub
|
|
22
|
+
* 自动备份开关与 GitHub 同步(推送 + 拉取),经 `backupPanel` Typert Remote 命名空间访问
|
|
22
23
|
* - Web 下载路由:GET /backup-download/<归档名>(仅 loopback,附件形式)
|
|
23
24
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* 安全说明(v0.7.0 起):凭据文件(.credentials.yaml / .env / qq-bridge/config.json,
|
|
26
|
+
* config.redact 可增删)默认脱敏——不进归档、不进 GitHub 同步,明文只存备份目录下
|
|
27
|
+
* 的本机 vault(vault/,POSIX 700/600),恢复时自动拷回 ~/.dsh;跨机恢复(vault 为空)
|
|
28
|
+
* 按 .redacted.json 清单提示重填。归档另携 .meta.json(主机/家目录/时间)供恢复预检:
|
|
29
|
+
* 家目录不一致时提示绝对路径风险。config.redact=false/'off' 可回到 v0.6.x 明文行为
|
|
30
|
+
* (不推荐)。归档与校验边车在 POSIX 上 chmod 600 仅本人可读写。
|
|
31
|
+
* restore 会拒绝恢复含归档根目录之外条目的文件(tar 路径穿越防护)。
|
|
28
32
|
*
|
|
29
33
|
* 存储说明:插件自有数据(归档、校验和、auto.json)直接经 node:fs 写入
|
|
30
34
|
* (与 dsh-session 持久化、skill-filesystem 同一模式)——ctx.fs 能力是
|
|
@@ -33,7 +37,8 @@
|
|
|
33
37
|
*/
|
|
34
38
|
import { createHash } from 'node:crypto';
|
|
35
39
|
import { createReadStream } from 'node:fs';
|
|
36
|
-
import { mkdir, open, copyFile, readdir, readFile, rename, stat as fsStat, unlink, writeFile } from 'node:fs/promises';
|
|
40
|
+
import { mkdir, open, copyFile, readdir, readFile, rename, rm, stat as fsStat, unlink, writeFile } from 'node:fs/promises';
|
|
41
|
+
import { hostname } from 'node:os';
|
|
37
42
|
import { dirname, join } from 'node:path';
|
|
38
43
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
39
44
|
import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
@@ -51,6 +56,16 @@ const MAX_GITHUB_BYTES = 90 * 1024 * 1024;
|
|
|
51
56
|
const SYNC_DIR = '.github-sync';
|
|
52
57
|
/** Web 下载路由前缀(无尾斜杠:prefix 匹配语义为 pathname.startsWith(prefix + '/'))。 */
|
|
53
58
|
const DOWNLOAD_PREFIX = '/backup-download';
|
|
59
|
+
/**
|
|
60
|
+
* 默认脱敏的敏感文件(相对 ~/.dsh 根的 POSIX 路径):凭据与环境变量文件
|
|
61
|
+
* 明文绝不进归档、绝不进 GitHub 同步——真实值只落本机 vault(备份目录下
|
|
62
|
+
* `vault/`,结构镜像 ~/.dsh)。恢复时从 vault 自动还原;跨机恢复(vault 为
|
|
63
|
+
* 空)则提示重填。config.redact 数组可追加;false/'off' 关闭整套(回到
|
|
64
|
+
* v0.6.x 明文行为,不推荐)。
|
|
65
|
+
*/
|
|
66
|
+
const SENSITIVE_DEFAULTS = ['.credentials.yaml', '.env', 'qq-bridge/config.json'];
|
|
67
|
+
/** 备份目录下保存明文敏感文件的子目录(随备份刷新为最新一份的副本)。 */
|
|
68
|
+
const VAULT_DIR = 'vault';
|
|
54
69
|
|
|
55
70
|
/**
|
|
56
71
|
* `backupPanel` Remote 命名空间的调用描述符(src-json codec)。手工经
|
|
@@ -83,6 +98,7 @@ const PANEL_INVOCATIONS = Object.freeze([
|
|
|
83
98
|
panelDescriptor('setAuto', ['hours']),
|
|
84
99
|
panelDescriptor('githubStatus', []),
|
|
85
100
|
panelDescriptor('githubSyncNow', [], true),
|
|
101
|
+
panelDescriptor('githubPull', [], true),
|
|
86
102
|
panelDescriptor('removeEntry', ['selector'], true),
|
|
87
103
|
panelDescriptor('setGithubRepo', ['repo']),
|
|
88
104
|
]);
|
|
@@ -134,6 +150,11 @@ class BackupPanelService extends TypertRemoteService {
|
|
|
134
150
|
return this.ops.githubSyncNow(signal);
|
|
135
151
|
}
|
|
136
152
|
|
|
153
|
+
/** 从 GitHub 拉取备份集到本地(新机恢复第一步,不自动恢复)。 */
|
|
154
|
+
githubPull(signal) {
|
|
155
|
+
return this.ops.githubPull(signal);
|
|
156
|
+
}
|
|
157
|
+
|
|
137
158
|
/** 删除指定备份(归档 + 校验边车)。 */
|
|
138
159
|
removeEntry(selector, signal) {
|
|
139
160
|
return this.ops.removeEntry(selector, signal);
|
|
@@ -224,6 +245,73 @@ export function apply(ctx, pluginConfig) {
|
|
|
224
245
|
return list.filter((p) => typeof p === 'string' && p.length > 0).map((p) => `--exclude=${p}`);
|
|
225
246
|
}
|
|
226
247
|
|
|
248
|
+
/** 生效的脱敏清单:默认集 + config.redact 追加(false/'off' 整体关闭)。 */
|
|
249
|
+
function sensitivePaths() {
|
|
250
|
+
const cfg = pluginConfig?.redact;
|
|
251
|
+
if (cfg === false || cfg === 'off' || cfg === 'none') return [];
|
|
252
|
+
const extra = Array.isArray(cfg) ? cfg.filter((p) => typeof p === 'string' && p.trim().length) : [];
|
|
253
|
+
return [...new Set([...SENSITIVE_DEFAULTS, ...extra.map((p) => p.trim())])];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 归档排除模式(tar 成员路径以 `<base>/` 开头,如 `.dsh/.credentials.yaml`)。
|
|
258
|
+
* GNU/bsdtar 的 --exclude 对成员路径做 glob 匹配:`<base>/<rel>` 精确
|
|
259
|
+
* 命中根级文件,再加一条任意目录前缀的同名模式覆盖深层出现
|
|
260
|
+
* (rel 本身可含 glob)。
|
|
261
|
+
*/
|
|
262
|
+
function redactExcludeFlags(base) {
|
|
263
|
+
return sensitivePaths().flatMap((rel) => [`--exclude=${base}/${rel}`, `--exclude=*/${rel}`]);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 列出 ~/.dsh 中现存的敏感文件(相对路径),不拷贝。供跳过 vault 刷新
|
|
268
|
+
* 的内部快照(pre-restore)仍能记录正确的脱敏清单边车。
|
|
269
|
+
*/
|
|
270
|
+
async function existingSensitive(dshHome) {
|
|
271
|
+
const out = [];
|
|
272
|
+
for (const rel of sensitivePaths()) {
|
|
273
|
+
const ok = await fsStat(`${dshHome}/${rel}`).then(() => true, () => false);
|
|
274
|
+
if (ok) out.push(rel);
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 刷新本机 vault:把 ~/.dsh 中现存的敏感文件按相对路径镜像到
|
|
281
|
+
* `<备份目录>/vault/`(先清空旧镜像,vault 永远对应最新一次备份)。
|
|
282
|
+
* 返回实际入库的相对路径列表。目录 700 / 文件 600(POSIX)。
|
|
283
|
+
*/
|
|
284
|
+
async function refreshVault(dshHome, home, signal) {
|
|
285
|
+
const { root } = paths();
|
|
286
|
+
const rels = sensitivePaths();
|
|
287
|
+
const vaultDir = `${root}/${VAULT_DIR}`;
|
|
288
|
+
if (!rels.length) return [];
|
|
289
|
+
if (signal?.aborted) throw new Error('操作已取消');
|
|
290
|
+
await rm(vaultDir, { recursive: true, force: true });
|
|
291
|
+
const stored = [];
|
|
292
|
+
for (const rel of rels) {
|
|
293
|
+
const src = `${dshHome}/${rel}`;
|
|
294
|
+
const ok = await fsStat(src).then(() => true, (err) => {
|
|
295
|
+
if (err && err.code === 'ENOENT') return false;
|
|
296
|
+
throw err;
|
|
297
|
+
});
|
|
298
|
+
if (!ok) continue;
|
|
299
|
+
const dst = `${vaultDir}/${rel}`;
|
|
300
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
301
|
+
await copyFile(src, dst);
|
|
302
|
+
stored.push(rel);
|
|
303
|
+
}
|
|
304
|
+
if (stored.length && !IS_WIN) {
|
|
305
|
+
const chmod = await ctx.subprocess.resolveExecutable('chmod');
|
|
306
|
+
try {
|
|
307
|
+
await spawnRun([chmod, '-R', 'go-rwx', vaultDir], home, signal);
|
|
308
|
+
} catch {
|
|
309
|
+
// chmod 失败不阻断备份:vault 已在用户私有目录下,权限收紧是纵深防御
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return stored;
|
|
313
|
+
}
|
|
314
|
+
|
|
227
315
|
function stampNow() {
|
|
228
316
|
const now = new Date();
|
|
229
317
|
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
@@ -289,12 +377,18 @@ export function apply(ctx, pluginConfig) {
|
|
|
289
377
|
} catch {
|
|
290
378
|
return;
|
|
291
379
|
}
|
|
380
|
+
const keepSet = new Set(sidecarsFor(keepName));
|
|
292
381
|
const stale = dirents
|
|
293
|
-
.filter((d) => d.name.startsWith('dsh-pre-restore-') && (d.name.endsWith('.tar.gz') || d.name.endsWith('.sha256')
|
|
382
|
+
.filter((d) => d.name.startsWith('dsh-pre-restore-') && (d.name.endsWith('.tar.gz') || d.name.endsWith('.sha256') || d.name.endsWith('.meta.json') || d.name.endsWith('.redacted.json')) && !keepSet.has(d.name))
|
|
294
383
|
.map((d) => d.name);
|
|
295
384
|
if (stale.length) await removeFiles(stale, root, signal);
|
|
296
385
|
}
|
|
297
386
|
|
|
387
|
+
/** 归档伴生边车集:轮换、删除与 GitHub 同步对同一归档统一处理的文件列表。 */
|
|
388
|
+
function sidecarsFor(name) {
|
|
389
|
+
return [name, `${name}.sha256`, `${name}.meta.json`, `${name}.redacted.json`];
|
|
390
|
+
}
|
|
391
|
+
|
|
298
392
|
async function listBackups() {
|
|
299
393
|
const { root } = paths();
|
|
300
394
|
let dirents;
|
|
@@ -363,11 +457,21 @@ export function apply(ctx, pluginConfig) {
|
|
|
363
457
|
|
|
364
458
|
// tar 以备份目录为 cwd、用纯文件名传 -f:Windows 上 GNU tar(msys)会把
|
|
365
459
|
// 含盘符冒号的绝对路径当远程归档("Cannot connect to C"),bsdtar 则两者皆可。
|
|
460
|
+
// 敏感文件先从归档排除(redactExcludeFlags),其明文副本进本机 vault。
|
|
461
|
+
// skipVault(pre-restore 快照):数据可能正待恢复/已损,刷新 vault 会把
|
|
462
|
+
// 仅存的凭据副本清掉——只列清单不拷贝,vault 保持上一次常规备份的状态。
|
|
463
|
+
const redacted = opts?.skipVault ? await existingSensitive(dshHome) : await refreshVault(dshHome, home, signal);
|
|
366
464
|
const tar = await ctx.subprocess.resolveExecutable('tar');
|
|
367
|
-
await spawnRun([tar, '--exclude=*node_modules*', '--exclude=.system', ...extraExcludes(), '-czf', name, '-C', parent, base], root, signal);
|
|
465
|
+
await spawnRun([tar, '--exclude=*node_modules*', '--exclude=.system', ...redactExcludeFlags(base), ...extraExcludes(), '-czf', name, '-C', parent, base], root, signal);
|
|
368
466
|
|
|
369
467
|
const shaText = await sha256File(out, home, signal);
|
|
370
468
|
await writeOwned(`${out}.sha256`, `${shaText} ${out}\n`);
|
|
469
|
+
// 脱敏清单边车:恢复侧据其判断归档是否脱敏、需要从 vault 还原哪些文件。
|
|
470
|
+
if (sensitivePaths().length) {
|
|
471
|
+
await writeOwned(`${out}.redacted.json`, `${JSON.stringify({ files: redacted })}\n`);
|
|
472
|
+
}
|
|
473
|
+
// 机器元数据边车:跨机恢复的预检依据(home 不一致 → 绝对路径提示)。
|
|
474
|
+
await writeOwned(`${out}.meta.json`, `${JSON.stringify({ host: hostname(), home, dshHome, createdAt: new Date().toISOString(), redacted: redacted.length })}\n`);
|
|
371
475
|
|
|
372
476
|
// 安全:备份含明文凭据(.credentials.yaml / qq-bridge/config.json),收紧为仅本人可读写。
|
|
373
477
|
// Windows 无 chmod,用户目录 ACL 默认私有。
|
|
@@ -380,7 +484,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
380
484
|
const all = await listBackups();
|
|
381
485
|
const stale = all.slice(keepN).map((b) => b.name);
|
|
382
486
|
if (stale.length) {
|
|
383
|
-
await removeFiles(stale.flatMap((n) =>
|
|
487
|
+
await removeFiles(stale.flatMap((n) => sidecarsFor(n)), root, signal);
|
|
384
488
|
}
|
|
385
489
|
|
|
386
490
|
// GitHub 同步(失败不回滚备份;状态记入 auto.json)
|
|
@@ -428,17 +532,15 @@ export function apply(ctx, pluginConfig) {
|
|
|
428
532
|
}
|
|
429
533
|
|
|
430
534
|
/**
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
* `push HEAD:main --force-with-lease`。https 远端的 token 只写入工作树内
|
|
434
|
-
* 的 .git-credentials(credential helper),不进进程参数。
|
|
535
|
+
* 校验同步配置并准备好同步工作树(init + remote + token 凭据 + gitignore)。
|
|
536
|
+
* 推送(githubSync)与拉取(githubPull)共用;未配置/缺 token 返回 reason。
|
|
435
537
|
*/
|
|
436
|
-
async function
|
|
538
|
+
async function ensureSyncWorktree(signal) {
|
|
437
539
|
const { root } = paths();
|
|
438
540
|
const cfg = githubConfig();
|
|
439
|
-
if (!cfg) return {
|
|
541
|
+
if (!cfg) return { reason: '未配置 githubRepo(cordis.yml config.githubRepo)' };
|
|
440
542
|
if (cfg.repo.startsWith('https://') && !cfg.token) {
|
|
441
|
-
return {
|
|
543
|
+
return { reason: 'https 远端缺少 token(环境变量 DSH_BACKUP_GITHUB_TOKEN 或 GITHUB_TOKEN)' };
|
|
442
544
|
}
|
|
443
545
|
const syncDir = `${root}/${SYNC_DIR}`;
|
|
444
546
|
const git = await ctx.subprocess.resolveExecutable('git');
|
|
@@ -481,16 +583,37 @@ export function apply(ctx, pluginConfig) {
|
|
|
481
583
|
}
|
|
482
584
|
// token 文件绝不能进仓库:git add -A 会把它当普通文件提交
|
|
483
585
|
await writeOwned(`${syncDir}/.gitignore`, '.git-credentials\n');
|
|
586
|
+
return { cfg, syncDir, git };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* 把当前备份集推送到 GitHub 仓库。工作树位于 `<备份目录>/.github-sync`:
|
|
591
|
+
* 归档与边车复制进去,git add -A 同时记录轮换删除,commit 后
|
|
592
|
+
* `push HEAD:main --force-with-lease`。https 远端的 token 只写入工作树内
|
|
593
|
+
* 的 .git-credentials(credential helper),不进进程参数。
|
|
594
|
+
*/
|
|
595
|
+
async function githubSync(signal) {
|
|
596
|
+
const { root } = paths();
|
|
597
|
+
const wt = await ensureSyncWorktree(signal);
|
|
598
|
+
if (wt.reason) return { skipped: wt.reason };
|
|
599
|
+
const { syncDir, git } = wt;
|
|
600
|
+
// pull 之后本地工作树可能领先/分叉:push 前先对齐远端,保证 force-with-lease
|
|
601
|
+
// 有基准(远端被其他机器推送过时直接 reset 到远端再叠加本地备份集)。
|
|
602
|
+
try {
|
|
603
|
+
await spawnRun([git, 'fetch', 'origin', 'main'], syncDir, signal);
|
|
604
|
+
await spawnRun([git, 'reset', '--hard', 'origin/main'], syncDir, signal);
|
|
605
|
+
} catch {
|
|
606
|
+
// 远端尚无 main(首次推送)或不可达:继续走本地提交路径,push 自会报错
|
|
607
|
+
}
|
|
484
608
|
|
|
485
609
|
// 镜像工作树:只保留 .gitignore、凭据文件(.git-credentials,token
|
|
486
610
|
// 配置时在 keep 集内保留)与当前备份集(归档+边车),其余文件
|
|
487
611
|
// (旧副本、误入杂物)清理——git add -A 因此只会收录归档;轮换
|
|
488
612
|
// 删除与误入文件一并同步移除。
|
|
489
613
|
const keep = new Set(['.gitignore']);
|
|
490
|
-
if (cfg.token) keep.add('.git-credentials');
|
|
614
|
+
if (wt.cfg.token) keep.add('.git-credentials');
|
|
491
615
|
for (const b of await listBackups()) {
|
|
492
|
-
|
|
493
|
-
keep.add(`${b.name}.sha256`);
|
|
616
|
+
for (const f of sidecarsFor(b.name)) keep.add(f);
|
|
494
617
|
}
|
|
495
618
|
let entries = [];
|
|
496
619
|
try {
|
|
@@ -511,8 +634,14 @@ export function apply(ctx, pluginConfig) {
|
|
|
511
634
|
tooBig.push(b.name);
|
|
512
635
|
continue;
|
|
513
636
|
}
|
|
514
|
-
|
|
515
|
-
|
|
637
|
+
for (const f of sidecarsFor(b.name)) {
|
|
638
|
+
await copyFile(`${root}/${f}`, `${syncDir}/${f}`).catch((err) => {
|
|
639
|
+
// .redacted.json 等可选边车随归档存在性浮动:缺失即跳过(ENOENT),
|
|
640
|
+
// 其余复制错误(EACCES 等)照常抛出——同步镜像必须与源一致。
|
|
641
|
+
if (err && err.code === 'ENOENT') return;
|
|
642
|
+
throw err;
|
|
643
|
+
});
|
|
644
|
+
}
|
|
516
645
|
}
|
|
517
646
|
await spawnRun([git, 'add', '-A'], syncDir, signal);
|
|
518
647
|
const status = await spawnRun([git, 'status', '--porcelain'], syncDir, signal);
|
|
@@ -530,6 +659,65 @@ export function apply(ctx, pluginConfig) {
|
|
|
530
659
|
return `GitHub 同步: ${s.skipped || '无变更'}${s.tooBig?.length ? `\n跳过超大文件: ${s.tooBig.join(', ')}` : ''}`;
|
|
531
660
|
}
|
|
532
661
|
|
|
662
|
+
/**
|
|
663
|
+
* 从 GitHub 同步仓库拉取备份集到本地备份目录(新机恢复的第一步):
|
|
664
|
+
* fetch + reset --hard origin/main 对齐远端,然后把本地缺失的归档与
|
|
665
|
+
* 边车从工作树拷回备份目录。仅拉取,不自动恢复——覆盖式恢复必须经
|
|
666
|
+
* restore 的校验/预览/快照链,由用户显式发起。
|
|
667
|
+
* 拉回的归档先做 sha256 校验(边车随仓库走),损坏即报且不落列表。
|
|
668
|
+
*/
|
|
669
|
+
async function githubPull(signal) {
|
|
670
|
+
const { root, home } = paths();
|
|
671
|
+
const wt = await ensureSyncWorktree(signal);
|
|
672
|
+
if (wt.reason) throw new Error(wt.reason);
|
|
673
|
+
const { syncDir, git } = wt;
|
|
674
|
+
await spawnRun([git, 'fetch', 'origin', 'main'], syncDir, signal);
|
|
675
|
+
await spawnRun([git, 'reset', '--hard', 'origin/main'], syncDir, signal);
|
|
676
|
+
|
|
677
|
+
let entries = [];
|
|
678
|
+
try {
|
|
679
|
+
entries = await readdir(syncDir, { withFileTypes: true });
|
|
680
|
+
} catch {
|
|
681
|
+
entries = [];
|
|
682
|
+
}
|
|
683
|
+
const wanted = entries
|
|
684
|
+
.filter((e) => e.isFile() && /^dsh-[A-Za-z0-9._-]+\.tar\.gz$/.test(e.name))
|
|
685
|
+
.map((e) => e.name);
|
|
686
|
+
const pulled = [];
|
|
687
|
+
const corrupt = [];
|
|
688
|
+
for (const name of wanted) {
|
|
689
|
+
const exists = await fsStat(`${root}/${name}`).then(() => true, () => false);
|
|
690
|
+
if (exists) continue;
|
|
691
|
+
// 先拷到临时名,sha256 校验通过再转正:损坏归档绝不进备份列表
|
|
692
|
+
const staging = `${root}/.pull-${name}`;
|
|
693
|
+
await copyFile(`${syncDir}/${name}`, staging);
|
|
694
|
+
let shaOk = false;
|
|
695
|
+
try {
|
|
696
|
+
const text = await readFile(`${syncDir}/${name}.sha256`, 'utf8');
|
|
697
|
+
const expected = text.trim().split(/\s+/)[0];
|
|
698
|
+
const actual = await sha256File(staging, home, signal);
|
|
699
|
+
shaOk = /^[0-9a-f]{64}$/.test(expected) && actual === expected;
|
|
700
|
+
} catch {
|
|
701
|
+
shaOk = false;
|
|
702
|
+
}
|
|
703
|
+
if (!shaOk) {
|
|
704
|
+
await unlink(staging).catch(() => {});
|
|
705
|
+
corrupt.push(name);
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
await rename(staging, `${root}/${name}`);
|
|
709
|
+
// 边车随归档转正(缺失的可选边车跳过)
|
|
710
|
+
for (const side of [`${name}.sha256`, `${name}.meta.json`, `${name}.redacted.json`]) {
|
|
711
|
+
await copyFile(`${syncDir}/${side}`, `${root}/${side}`).catch((err) => {
|
|
712
|
+
if (err && err.code === 'ENOENT') return;
|
|
713
|
+
throw err;
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
pulled.push(name);
|
|
717
|
+
}
|
|
718
|
+
return { pulled, corrupt, total: wanted.length };
|
|
719
|
+
}
|
|
720
|
+
|
|
533
721
|
// ---------- Web 下载路由(仅 loopback,附件形式) ----------
|
|
534
722
|
async function handleDownload(req, res) {
|
|
535
723
|
try {
|
|
@@ -590,11 +778,11 @@ export function apply(ctx, pluginConfig) {
|
|
|
590
778
|
throw new Error(`"${selector}" 匹配多份备份,请加长前缀:\n${hits.slice(0, 5).map((b) => ` ${b.name}`).join('\n')}`);
|
|
591
779
|
}
|
|
592
780
|
|
|
593
|
-
/** 删除指定备份(归档 +
|
|
781
|
+
/** 删除指定备份(归档 + 全部边车);选择器经 pickArchive 精确匹配,杜绝路径穿越。 */
|
|
594
782
|
async function removeBackup(selector, signal) {
|
|
595
783
|
const { root } = paths();
|
|
596
784
|
const picked = await pickArchive(selector);
|
|
597
|
-
await removeFiles(
|
|
785
|
+
await removeFiles(sidecarsFor(picked.name), root, signal);
|
|
598
786
|
return { ok: true, name: picked.name, summary: `已删除备份: ${picked.name}` };
|
|
599
787
|
}
|
|
600
788
|
|
|
@@ -641,7 +829,47 @@ export function apply(ctx, pluginConfig) {
|
|
|
641
829
|
return { type: f[0][0], name: f.slice(nameIdx).join(' ').replace(/\/$/, '') };
|
|
642
830
|
}
|
|
643
831
|
|
|
644
|
-
|
|
832
|
+
/**
|
|
833
|
+
* 读取归档的机器元数据 / 脱敏清单边车,构造恢复预检信息(边车缺失的
|
|
834
|
+
* v0.6.x 老归档返回 redacted=null、host=null,预检静默降级)。
|
|
835
|
+
*/
|
|
836
|
+
async function readArchiveMeta(archiveName) {
|
|
837
|
+
const { root } = paths();
|
|
838
|
+
let meta = null;
|
|
839
|
+
let redactedFiles = null;
|
|
840
|
+
try {
|
|
841
|
+
meta = JSON.parse(await readFile(`${root}/${archiveName}.meta.json`, 'utf8'));
|
|
842
|
+
} catch {
|
|
843
|
+
// 老归档无 meta 边车
|
|
844
|
+
}
|
|
845
|
+
try {
|
|
846
|
+
redactedFiles = JSON.parse(await readFile(`${root}/${archiveName}.redacted.json`, 'utf8'))?.files ?? [];
|
|
847
|
+
} catch {
|
|
848
|
+
// 未脱敏(或老归档)无清单
|
|
849
|
+
}
|
|
850
|
+
return { meta, redactedFiles };
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* 恢复预检:跨机(home 与备份机器不一致)绝对路径提示 + 脱敏归档的
|
|
855
|
+
* vault 可还原性 + 随归档恢复的 profile 依赖(node_modules 被排除,
|
|
856
|
+
* 恢复后需重装)。返回人类可读的提示行数组(无提示返回空)。
|
|
857
|
+
*/
|
|
858
|
+
function preflightLines(preflight, profileDirs) {
|
|
859
|
+
const lines = [];
|
|
860
|
+
if (preflight.homeChanged) {
|
|
861
|
+
lines.push(`⚠️ 备份来自另一台机器/用户目录(${preflight.sourceHost ?? '未知主机'},${preflight.sourceHome}),settings 内的绝对路径可能需要调整`);
|
|
862
|
+
}
|
|
863
|
+
if (Array.isArray(preflight.redactedFiles)) {
|
|
864
|
+
lines.push(`🔐 该归档已脱敏:${preflight.redactedFiles.length} 个凭据文件不随归档走,恢复时从本机 vault 还原(跨机恢复需重填)`);
|
|
865
|
+
}
|
|
866
|
+
if (profileDirs.length) {
|
|
867
|
+
lines.push(`📦 归档含 ${profileDirs.length} 个 profile 的依赖声明(node_modules 不随归档),恢复后用 --sync-deps 或手动 pnpm install 重装插件`);
|
|
868
|
+
}
|
|
869
|
+
return lines;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function restoreArchive(selector, dryRun, signal, opts = {}) {
|
|
645
873
|
const { home, dshHome, root } = paths();
|
|
646
874
|
const picked = await pickArchive(selector);
|
|
647
875
|
const archive = `${root}/${picked.name}`;
|
|
@@ -683,14 +911,31 @@ export function apply(ctx, pluginConfig) {
|
|
|
683
911
|
throw new Error(`归档包含不安全条目(拒绝恢复):${bad.slice(0, 3).join(', ')},恢复已中止`);
|
|
684
912
|
}
|
|
685
913
|
|
|
686
|
-
|
|
914
|
+
// 预检(dry-run 与真实恢复共用):跨机 home 对比 + 脱敏清单 + profile 依赖。
|
|
915
|
+
const { meta, redactedFiles } = await readArchiveMeta(picked.name);
|
|
916
|
+
const preflight = {
|
|
917
|
+
homeChanged: Boolean(meta && typeof meta.home === 'string' && meta.home !== home),
|
|
918
|
+
sourceHost: meta?.host ?? null,
|
|
919
|
+
sourceHome: meta?.home ?? null,
|
|
920
|
+
redactedFiles,
|
|
921
|
+
};
|
|
922
|
+
const profileDirs = [...new Set(entries
|
|
923
|
+
.filter((n) => n.startsWith(`${base}/profiles/`) && n.endsWith('/package.json'))
|
|
924
|
+
.map((n) => n.slice(`${base}/profiles/`.length).split('/')[0]))];
|
|
925
|
+
|
|
926
|
+
if (dryRun) {
|
|
927
|
+
return {
|
|
928
|
+
archive, files: entries.length, sample: entries.slice(0, 12), aside: null, snapshotPath: null, dryRun: true,
|
|
929
|
+
preflight: preflightLines(preflight, profileDirs),
|
|
930
|
+
};
|
|
931
|
+
}
|
|
687
932
|
|
|
688
933
|
// 恢复前自动快照当前数据,并把当前数据移到旁边(而非合并覆盖)。
|
|
689
934
|
// 快照用 dsh-pre-restore- 前缀与用户常规备份区分,经 listBackups 过滤
|
|
690
935
|
// 后不进列表/轮换/latest 选择;每次恢复清掉上一次的快照防累积。
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
936
|
+
// 现有数据存在性先行:~/.dsh 已缺失(数据全失/新机首恢复)时没有可
|
|
937
|
+
// 快照的内容,跳过快照直接解压——先快照后判存在的旧序会让 tar 对
|
|
938
|
+
// 不存在的 .dsh 报错,恢复在最需要的场景反而失败。
|
|
694
939
|
let current = false;
|
|
695
940
|
try {
|
|
696
941
|
await fsStat(dshHome);
|
|
@@ -701,13 +946,91 @@ export function apply(ctx, pluginConfig) {
|
|
|
701
946
|
if (!err || err.code !== 'ENOENT') throw err;
|
|
702
947
|
current = false;
|
|
703
948
|
}
|
|
949
|
+
const snapshot = current
|
|
950
|
+
? await doBackup(undefined, signal, { namePrefix: 'dsh-pre-restore-', skipVault: true })
|
|
951
|
+
: null;
|
|
952
|
+
if (snapshot) await prunePreRestoreSnapshots(snapshot.name, signal);
|
|
953
|
+
let aside = null;
|
|
704
954
|
if (current) {
|
|
705
955
|
const asideName = `${base}.pre-restore-${stampNow()}`;
|
|
706
956
|
await renameBeside(parent, base, asideName, signal);
|
|
707
957
|
aside = `${parent}/${asideName}`;
|
|
708
958
|
}
|
|
709
959
|
await spawnRun([tar, '-xzf', picked.name, '-C', parent], root, signal);
|
|
710
|
-
|
|
960
|
+
|
|
961
|
+
// vault 还原:脱敏归档恢复出的 ~/.dsh 不含凭据文件,从本机 vault 拷回。
|
|
962
|
+
// vault 为空(跨机首恢复)时逐项记入 missing,由调用方提示重填——凭据
|
|
963
|
+
// 无法从归档恢复是脱敏设计的直接代价,明确告知优于静默缺文件。
|
|
964
|
+
const vaultRestored = [];
|
|
965
|
+
const vaultMissing = [];
|
|
966
|
+
if (Array.isArray(redactedFiles) && redactedFiles.length) {
|
|
967
|
+
const vaultDir = `${root}/${VAULT_DIR}`;
|
|
968
|
+
for (const rel of redactedFiles) {
|
|
969
|
+
const src = `${vaultDir}/${rel}`;
|
|
970
|
+
const have = await fsStat(src).then(() => true, (err) => {
|
|
971
|
+
if (err && err.code === 'ENOENT') return false;
|
|
972
|
+
throw err;
|
|
973
|
+
});
|
|
974
|
+
if (!have) {
|
|
975
|
+
vaultMissing.push(rel);
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
const dst = `${dshHome}/${rel}`;
|
|
979
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
980
|
+
await copyFile(src, dst);
|
|
981
|
+
vaultRestored.push(rel);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// 可选依赖重装:node_modules 不随归档,--sync-deps 对每个恢复出的 profile
|
|
986
|
+
// 跑 pnpm install --frozen-lockfile(与 DSH 自身的装配套路一致)。pnpm
|
|
987
|
+
// 不可用时降级为提示,不判恢复失败——配置与会话已经恢复到位。
|
|
988
|
+
const deps = { installed: [], failed: [], note: null };
|
|
989
|
+
if (opts?.syncDeps && profileDirs.length) {
|
|
990
|
+
try {
|
|
991
|
+
const pnpm = await ctx.subprocess.resolveExecutable('pnpm');
|
|
992
|
+
for (const p of profileDirs) {
|
|
993
|
+
try {
|
|
994
|
+
await spawnRun([pnpm, 'install', '--frozen-lockfile'], `${dshHome}/profiles/${p}`, signal);
|
|
995
|
+
deps.installed.push(p);
|
|
996
|
+
} catch (err) {
|
|
997
|
+
// lockfile 与声明漂移时 frozen 会失败:退回普通 install 再试一次
|
|
998
|
+
try {
|
|
999
|
+
await spawnRun([pnpm, 'install'], `${dshHome}/profiles/${p}`, signal);
|
|
1000
|
+
deps.installed.push(p);
|
|
1001
|
+
} catch (err2) {
|
|
1002
|
+
deps.failed.push(p);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
} catch {
|
|
1007
|
+
deps.note = '未找到 pnpm,请在各 profile 目录手动执行 pnpm install';
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
return {
|
|
1012
|
+
archive, files: entries.length, sample: [], aside, snapshotPath: snapshot ? snapshot.path : null, dryRun: false,
|
|
1013
|
+
preflight: preflightLines(preflight, profileDirs), vaultRestored, vaultMissing, deps, profiles: profileDirs,
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** 恢复结果的人类可读汇总(命令、模型工具与面板共用)。 */
|
|
1018
|
+
function summarizeRestore(r) {
|
|
1019
|
+
if (r.dryRun) {
|
|
1020
|
+
const pre = r.preflight?.length ? `\n${r.preflight.map((l) => ` ${l}`).join('\n')}` : '';
|
|
1021
|
+
return `📦 恢复预览(未写入)\n 归档: ${r.archive}\n 条目: ${r.files} 项${pre}\n${r.sample.map((s) => ` ${s}`).join('\n')}`;
|
|
1022
|
+
}
|
|
1023
|
+
const lines = ['✅ 恢复完成', ` 来源: ${r.archive}(${r.files} 项)`];
|
|
1024
|
+
if (r.snapshotPath) lines.push(` 恢复前快照: ${r.snapshotPath}`);
|
|
1025
|
+
if (r.aside) lines.push(` 旧数据已移至: ${r.aside}`);
|
|
1026
|
+
for (const p of r.preflight ?? []) lines.push(` ${p}`);
|
|
1027
|
+
if (r.vaultRestored?.length) lines.push(`🔐 本机 vault 已还原 ${r.vaultRestored.length} 个凭据文件`);
|
|
1028
|
+
if (r.vaultMissing?.length) lines.push(`⚠️ 以下凭据文件不在本机 vault,需重填: ${r.vaultMissing.join(', ')}`);
|
|
1029
|
+
if (r.deps?.installed?.length) lines.push(`📦 已重装 profile 依赖: ${r.deps.installed.join(', ')}`);
|
|
1030
|
+
if (r.deps?.failed?.length) lines.push(`⚠️ 依赖重装失败: ${r.deps.failed.join(', ')}`);
|
|
1031
|
+
if (r.deps?.note) lines.push(`⚠️ ${r.deps.note}`);
|
|
1032
|
+
lines.push(' 请重启 dsh 使恢复的会话与配置生效。');
|
|
1033
|
+
return lines.join('\n');
|
|
711
1034
|
}
|
|
712
1035
|
|
|
713
1036
|
// ---------- 自动备份与 GitHub 同步状态(落盘,重启续跑) ----------
|
|
@@ -805,7 +1128,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
805
1128
|
// ---------- /backup 命令 ----------
|
|
806
1129
|
ctx.commands.register({
|
|
807
1130
|
name: 'backup',
|
|
808
|
-
description: '备份/恢复 DSH 数据;子命令: list | verify [前缀|all] | restore <前缀|latest> [--dry-run] | auto [N小时|off] | [--keep N]',
|
|
1131
|
+
description: '备份/恢复 DSH 数据;子命令: list | verify [前缀|all] | restore <前缀|latest> [--dry-run] [--sync-deps] | auto [N小时|off] | github status|sync|pull|repo | [--keep N]',
|
|
809
1132
|
handler: async (invocation) => {
|
|
810
1133
|
const input = invocation.rawInput.trim();
|
|
811
1134
|
try {
|
|
@@ -839,15 +1162,10 @@ export function apply(ctx, pluginConfig) {
|
|
|
839
1162
|
|
|
840
1163
|
if (head === 'restore') {
|
|
841
1164
|
const dryRun = parts.includes('--dry-run');
|
|
1165
|
+
const syncDeps = parts.includes('--sync-deps');
|
|
842
1166
|
const sel = parts.slice(1).find((t) => !t.startsWith('--')) || 'latest';
|
|
843
|
-
const r = await restoreArchive(sel, dryRun, invocation.signal);
|
|
844
|
-
|
|
845
|
-
return { kind: 'success', text: `📦 恢复预览(未写入)\n 归档: ${r.archive}\n 条目: ${r.files} 项\n${r.sample.map((s) => ` ${s}`).join('\n')}` };
|
|
846
|
-
}
|
|
847
|
-
return {
|
|
848
|
-
kind: 'success',
|
|
849
|
-
text: `✅ 恢复完成\n 来源: ${r.archive}(${r.files} 项)\n 恢复前快照: ${r.snapshotPath}\n${r.aside ? ` 旧数据已移至: ${r.aside}\n` : ''} 请重启 dsh 使恢复的会话与配置生效。`,
|
|
850
|
-
};
|
|
1167
|
+
const r = await restoreArchive(sel, dryRun, invocation.signal, { syncDeps });
|
|
1168
|
+
return { kind: 'success', text: summarizeRestore(r) };
|
|
851
1169
|
}
|
|
852
1170
|
|
|
853
1171
|
if (head === 'github') {
|
|
@@ -877,6 +1195,27 @@ export function apply(ctx, pluginConfig) {
|
|
|
877
1195
|
}
|
|
878
1196
|
return { kind: 'success', text: summarizeSync(s) };
|
|
879
1197
|
}
|
|
1198
|
+
if (arg === 'pull') {
|
|
1199
|
+
// /backup github pull [--restore <前缀|latest>]:拉取不自动恢复(覆盖
|
|
1200
|
+
// 式恢复必须走显式 restore),--restore 由用户点名后才执行。
|
|
1201
|
+
const restoreIdx = parts.indexOf('--restore');
|
|
1202
|
+
const restoreSel = restoreIdx >= 0 ? parts[restoreIdx + 1] : null;
|
|
1203
|
+
const p = await githubPull(invocation.signal);
|
|
1204
|
+
const lines = [];
|
|
1205
|
+
if (p.pulled.length) {
|
|
1206
|
+
lines.push(`✅ 已拉取 ${p.pulled.length} 份备份:\n${p.pulled.map((n) => ` ${n}`).join('\n')}`);
|
|
1207
|
+
} else {
|
|
1208
|
+
lines.push(`远端共 ${p.total} 份备份,本地均已存在。`);
|
|
1209
|
+
}
|
|
1210
|
+
if (p.corrupt.length) lines.push(`⚠️ sha256 校验失败、已跳过: ${p.corrupt.join(', ')}`);
|
|
1211
|
+
if (restoreSel) {
|
|
1212
|
+
const r = await restoreArchive(restoreSel, false, invocation.signal);
|
|
1213
|
+
lines.push(summarizeRestore(r));
|
|
1214
|
+
} else {
|
|
1215
|
+
lines.push('用 /backup restore <前缀|latest> [--dry-run] 恢复;跨机恢复后凭据需按提示重填。');
|
|
1216
|
+
}
|
|
1217
|
+
return { kind: 'success', text: lines.join('\n') };
|
|
1218
|
+
}
|
|
880
1219
|
if (arg === 'status') {
|
|
881
1220
|
const cfg = githubConfig();
|
|
882
1221
|
const text = cfg
|
|
@@ -884,7 +1223,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
884
1223
|
: 'GitHub 同步未配置:/backup github repo <owner/repo> 设置,或在 cordis.yml 的 config.githubRepo 配置。';
|
|
885
1224
|
return { kind: 'success', text };
|
|
886
1225
|
}
|
|
887
|
-
return { kind: 'error', text: '用法: /backup github status|sync|repo <地址|off>' };
|
|
1226
|
+
return { kind: 'error', text: '用法: /backup github status|sync|pull [--restore <前缀|latest>]|repo <地址|off>' };
|
|
888
1227
|
}
|
|
889
1228
|
|
|
890
1229
|
if (head === 'delete' || head === 'rm') {
|
|
@@ -926,13 +1265,14 @@ export function apply(ctx, pluginConfig) {
|
|
|
926
1265
|
// ---------- backup_dsh 模型工具 ----------
|
|
927
1266
|
ctx.tools.register(defineTool({
|
|
928
1267
|
name: 'backup_dsh',
|
|
929
|
-
description: '备份、校验或恢复 DSH 用户数据(~/.dsh
|
|
1268
|
+
description: '备份、校验或恢复 DSH 用户数据(~/.dsh 的会话、配置、技能)。凭据文件默认脱敏:不进归档、不进 GitHub 同步,明文只存本机 vault,恢复时自动还原。mode=backup 立即备份(keep 指定保留份数);mode=list 列出备份;mode=verify 校验完整性(selector=前缀或 all,缺省最新一份);mode=restore 恢复(selector=前缀或 latest,dryRun 仅预览,syncDeps 恢复后重装 profile 依赖;恢复前自动校验并快照当前数据);mode=auto 设置定时备份(hours 间隔小时数,0=关闭,缺省查询)。',
|
|
930
1269
|
parameters: {
|
|
931
1270
|
mode: { type: 'string', required: true, enum: ['backup', 'list', 'verify', 'restore', 'auto'], description: 'backup=执行备份,list=列出备份,verify=校验完整性,restore=恢复,auto=定时备份' },
|
|
932
1271
|
keep: { type: 'number', description: '保留的备份份数(mode=backup,默认 7)' },
|
|
933
1272
|
hours: { type: 'number', description: '定时备份间隔小时数(mode=auto;0=关闭;缺省=查询状态)' },
|
|
934
1273
|
selector: { type: 'string', description: '备份选择器(mode=verify/restore):归档名前缀、latest 或 all' },
|
|
935
1274
|
dryRun: { type: 'boolean', description: 'mode=restore 时仅预览恢复内容,不写入' },
|
|
1275
|
+
syncDeps: { type: 'boolean', description: 'mode=restore 时恢复后对各 profile 执行 pnpm install 重装插件依赖' },
|
|
936
1276
|
},
|
|
937
1277
|
output: {
|
|
938
1278
|
schema: { type: 'object', additionalProperties: true },
|
|
@@ -962,12 +1302,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
962
1302
|
}
|
|
963
1303
|
if (mode === 'restore') {
|
|
964
1304
|
const r = await restoreArchive(selector || 'latest', Boolean(args && args.dryRun), signal);
|
|
965
|
-
|
|
966
|
-
return {
|
|
967
|
-
ok: true,
|
|
968
|
-
path: r.archive,
|
|
969
|
-
summary: `恢复完成: ${r.archive}(${r.files} 项)\n恢复前快照: ${r.snapshotPath}\n${r.aside ? `旧数据已移至: ${r.aside}\n` : ''}请重启 dsh 生效。`,
|
|
970
|
-
};
|
|
1305
|
+
return { ok: true, path: r.archive, summary: summarizeRestore(r) };
|
|
971
1306
|
}
|
|
972
1307
|
if (mode === 'auto') {
|
|
973
1308
|
const h = args && args.hours !== undefined ? args.hours : null;
|
|
@@ -1032,7 +1367,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
1032
1367
|
try {
|
|
1033
1368
|
const r = await restoreArchive(selector || 'latest', Boolean(dryRun), signal);
|
|
1034
1369
|
if (r.dryRun) {
|
|
1035
|
-
return { ok: true, dryRun: true, archive: r.archive, files: r.files, sample: r.sample,
|
|
1370
|
+
return { ok: true, dryRun: true, archive: r.archive, files: r.files, sample: r.sample, preflight: r.preflight ?? [], summary: summarizeRestore(r) };
|
|
1036
1371
|
}
|
|
1037
1372
|
return {
|
|
1038
1373
|
ok: true,
|
|
@@ -1041,7 +1376,10 @@ export function apply(ctx, pluginConfig) {
|
|
|
1041
1376
|
files: r.files,
|
|
1042
1377
|
aside: r.aside,
|
|
1043
1378
|
snapshotPath: r.snapshotPath,
|
|
1044
|
-
|
|
1379
|
+
preflight: r.preflight ?? [],
|
|
1380
|
+
vaultRestored: r.vaultRestored ?? [],
|
|
1381
|
+
vaultMissing: r.vaultMissing ?? [],
|
|
1382
|
+
summary: summarizeRestore(r),
|
|
1045
1383
|
};
|
|
1046
1384
|
} catch (err) {
|
|
1047
1385
|
return { ok: false, dryRun: Boolean(dryRun), summary: String(err && err.message ? err.message : err) };
|
|
@@ -1090,6 +1428,19 @@ export function apply(ctx, pluginConfig) {
|
|
|
1090
1428
|
return { ok: false, summary: message, pushed: false, tooBig: [] };
|
|
1091
1429
|
}
|
|
1092
1430
|
},
|
|
1431
|
+
githubPull: async (signal) => {
|
|
1432
|
+
try {
|
|
1433
|
+
const p = await githubPull(signal);
|
|
1434
|
+
const lines = [];
|
|
1435
|
+
if (p.pulled.length) lines.push(`已拉取 ${p.pulled.length} 份备份`);
|
|
1436
|
+
else lines.push(`远端共 ${p.total} 份备份,本地均已存在。`);
|
|
1437
|
+
if (p.corrupt.length) lines.push(`sha256 校验失败、已跳过: ${p.corrupt.join(', ')}`);
|
|
1438
|
+
lines.push('在上方备份列表中选择归档恢复;跨机恢复后凭据需按提示重填。');
|
|
1439
|
+
return { ok: true, summary: lines.join('\n'), pulled: p.pulled, corrupt: p.corrupt, total: p.total };
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
return { ok: false, summary: String(err && err.message ? err.message : err), pulled: [], corrupt: [], total: 0 };
|
|
1442
|
+
}
|
|
1443
|
+
},
|
|
1093
1444
|
removeEntry: async (selector, signal) => {
|
|
1094
1445
|
try {
|
|
1095
1446
|
const r = await removeBackup(selector || 'latest', signal);
|