@xiaoyuyu6420/dsh-backup 0.6.2 → 0.7.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/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>` GitHub 同步状态 / 立即同步 / 设置仓库
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 同步,经 `backupPanel` Typert Remote 命名空间访问
22
+ * 自动备份开关与 GitHub 同步(推送 + 拉取),经 `backupPanel` Typert Remote 命名空间访问
22
23
  * - Web 下载路由:GET /backup-download/<归档名>(仅 loopback,附件形式)
23
24
  *
24
- * 安全说明:备份包含明文凭据(.credentials.yamlqq-bridge/config.json),
25
- * 归档与边车在 POSIX chmod 600 仅本人可读写;请勿将备份目录同步到不受信位置。
26
- * GitHub 同步前请确认目标仓库是私有仓库。restore 会拒绝恢复含归档根目录之外
27
- * 条目的文件(tar 路径穿越防护)。
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')) && d.name !== keepName && d.name !== `${keepName}.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) => [n, `${n}.sha256`]), root, signal);
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
- * 把当前备份集推送到 GitHub 仓库。工作树位于 `<备份目录>/.github-sync`:
432
- * 归档与边车复制进去,git add -A 同时记录轮换删除,commit 后
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 githubSync(signal) {
538
+ async function ensureSyncWorktree(signal) {
437
539
  const { root } = paths();
438
540
  const cfg = githubConfig();
439
- if (!cfg) return { skipped: '未配置 githubRepo(cordis.yml config.githubRepo)' };
541
+ if (!cfg) return { reason: '未配置 githubRepo(cordis.yml config.githubRepo)' };
440
542
  if (cfg.repo.startsWith('https://') && !cfg.token) {
441
- return { skipped: 'https 远端缺少 token(环境变量 DSH_BACKUP_GITHUB_TOKEN 或 GITHUB_TOKEN)' };
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
- keep.add(b.name);
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
- await copyFile(`${root}/${b.name}`, `${syncDir}/${b.name}`);
515
- await copyFile(`${root}/${b.name}.sha256`, `${syncDir}/${b.name}.sha256`);
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
- /** 删除指定备份(归档 + 校验边车);选择器经 pickArchive 精确匹配,杜绝路径穿越。 */
781
+ /** 删除指定备份(归档 + 全部边车);选择器经 pickArchive 精确匹配,杜绝路径穿越。 */
594
782
  async function removeBackup(selector, signal) {
595
783
  const { root } = paths();
596
784
  const picked = await pickArchive(selector);
597
- await removeFiles([picked.name, `${picked.name}.sha256`], root, signal);
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
- async function restoreArchive(selector, dryRun, signal) {
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
- if (dryRun) return { archive, files: entries.length, sample: entries.slice(0, 12), aside: null, snapshotPath: null, dryRun: true };
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
- const snapshot = await doBackup(undefined, signal, { namePrefix: 'dsh-pre-restore-' });
692
- await prunePreRestoreSnapshots(snapshot.name, signal);
693
- let aside = null;
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
- return { archive, files: entries.length, sample: [], aside, snapshotPath: snapshot.path, dryRun: false };
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
- if (r.dryRun) {
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 的会话、配置、技能、凭据)。mode=backup 立即备份(keep 指定保留份数);mode=list 列出备份;mode=verify 校验完整性(selector=前缀或 all,缺省最新一份);mode=restore 恢复(selector=前缀或 latest,dryRun 仅预览;恢复前自动校验并快照当前数据);mode=auto 设置定时备份(hours 间隔小时数,0=关闭,缺省查询)。注意:备份包含明文凭据,请勿将备份目录同步到不受信位置。',
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
- if (r.dryRun) return { ok: true, summary: `恢复预览(未写入): ${r.archive}\n条目 ${r.files} 项,含:\n${r.sample.map((s) => ` ${s}`).join('\n')}` };
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, summary: `归档 ${r.files} 项` };
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
- summary: `恢复完成(${r.files} 项)${r.aside ? `\n旧数据已移至 ${r.aside}` : ''}\n请重启 dsh 生效。`,
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);