@xiaoyuyu6420/dsh-backup 0.6.1 → 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,12 +245,104 @@ 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');
230
318
  return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}${pad(now.getMilliseconds(), 3)}`;
231
319
  }
232
320
 
321
+ /**
322
+ * 从 http(s) URL 中剥离 userinfo(`user:pass@` 段,含 token-as-username
323
+ * 形态)。GitHub 同步的认证始终走环境变量 token → 同步工作树的
324
+ * .git-credentials,不依赖 URL 内嵌凭据;但用户可能把 PAT 写进 repo
325
+ * URL,此时 token 会明文落入 auto.json 并被 `/backup github status` 与
326
+ * 面板 repoRaw 回显。此处在存/显前剥离。
327
+ * 仅作用于 http(s)——ssh://git@、file://、owner/repo、本地路径等保持原样
328
+ * (不同认证模型,剥 username 会破坏如 ssh://git@host)。用 URL 解析处理
329
+ * port/IPv6/path 含已编码 @ 等;非 URL / 无 userinfo / 解析异常原样返回。
330
+ */
331
+ function stripUserinfo(raw) {
332
+ if (typeof raw !== 'string' || !/^https?:\/\//i.test(raw)) return raw;
333
+ try {
334
+ const u = new URL(raw);
335
+ if (u.username || u.password) {
336
+ u.username = '';
337
+ u.password = '';
338
+ return u.toString();
339
+ }
340
+ return raw;
341
+ } catch {
342
+ return raw;
343
+ }
344
+ }
345
+
233
346
  /**
234
347
  * 删除 dir 下的文件。纯 Node fs.unlink(零 shell):文件名来自备份目录
235
348
  * 内容,拼接进 cmd/rm 命令行会构成命令注入面(Windows 下 cmd 的
@@ -251,6 +364,31 @@ export function apply(ctx, pluginConfig) {
251
364
  await rename(`${dir}/${srcName}`, `${dir}/${dstName}`);
252
365
  }
253
366
 
367
+ /**
368
+ * 清理旧的恢复前快照(dsh-pre-restore-* 归档 + 边车),仅保留 keepName
369
+ * 这一份。恢复是低频操作,快照又被 listBackups 排除、用户不可见,故每
370
+ * 次恢复后清掉前一次的快照防隐藏累积(aside 目录仍由用户自行管理)。
371
+ */
372
+ async function prunePreRestoreSnapshots(keepName, signal) {
373
+ const { root } = paths();
374
+ let dirents;
375
+ try {
376
+ dirents = await readdir(root, { withFileTypes: true });
377
+ } catch {
378
+ return;
379
+ }
380
+ const keepSet = new Set(sidecarsFor(keepName));
381
+ const stale = dirents
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))
383
+ .map((d) => d.name);
384
+ if (stale.length) await removeFiles(stale, root, signal);
385
+ }
386
+
387
+ /** 归档伴生边车集:轮换、删除与 GitHub 同步对同一归档统一处理的文件列表。 */
388
+ function sidecarsFor(name) {
389
+ return [name, `${name}.sha256`, `${name}.meta.json`, `${name}.redacted.json`];
390
+ }
391
+
254
392
  async function listBackups() {
255
393
  const { root } = paths();
256
394
  let dirents;
@@ -262,7 +400,10 @@ export function apply(ctx, pluginConfig) {
262
400
  }
263
401
  const backups = [];
264
402
  for (const d of dirents) {
265
- if (!d.name.startsWith('dsh-') || !d.name.endsWith('.tar.gz')) continue;
403
+ // 恢复前自动快照(dsh-pre-restore-*)是内部安全网,不进用户列表、
404
+ // 不参与轮换、不被 pickArchive 的 latest/前缀误选——经 listBackups
405
+ // 过滤即可同时收口这三处入口。
406
+ if (!d.name.startsWith('dsh-') || d.name.startsWith('dsh-pre-restore-') || !d.name.endsWith('.tar.gz')) continue;
266
407
  let size;
267
408
  try {
268
409
  size = (await fsStat(`${root}/${d.name}`)).size;
@@ -302,24 +443,35 @@ export function apply(ctx, pluginConfig) {
302
443
  return createHash('sha256').update(bytes).digest('hex');
303
444
  }
304
445
 
305
- async function doBackup(keep, signal) {
446
+ async function doBackup(keep, signal, opts = {}) {
306
447
  const { home, dshHome, root } = paths();
307
448
  const keepN = keep && keep > 0 ? Math.floor(keep) : defaultKeep();
308
449
  // 状态文件先写:writeText 会自动创建备份目录,替代 mkdir -p(Windows 无 mkdir.exe)。
309
450
  await saveAutoState();
310
451
 
311
- const name = `dsh-${stampNow()}.tar.gz`;
452
+ const prefix = typeof opts?.namePrefix === 'string' && opts.namePrefix ? opts.namePrefix : 'dsh-';
453
+ const name = `${prefix}${stampNow()}.tar.gz`;
312
454
  const out = `${root}/${name}`;
313
455
  const base = dshHome.split('/').pop();
314
456
  const parent = dshHome.slice(0, -(base.length + 1)) || '/';
315
457
 
316
458
  // tar 以备份目录为 cwd、用纯文件名传 -f:Windows 上 GNU tar(msys)会把
317
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);
318
464
  const tar = await ctx.subprocess.resolveExecutable('tar');
319
- 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);
320
466
 
321
467
  const shaText = await sha256File(out, home, signal);
322
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`);
323
475
 
324
476
  // 安全:备份含明文凭据(.credentials.yaml / qq-bridge/config.json),收紧为仅本人可读写。
325
477
  // Windows 无 chmod,用户目录 ACL 默认私有。
@@ -332,7 +484,7 @@ export function apply(ctx, pluginConfig) {
332
484
  const all = await listBackups();
333
485
  const stale = all.slice(keepN).map((b) => b.name);
334
486
  if (stale.length) {
335
- await removeFiles(stale.flatMap((n) => [n, `${n}.sha256`]), root, signal);
487
+ await removeFiles(stale.flatMap((n) => sidecarsFor(n)), root, signal);
336
488
  }
337
489
 
338
490
  // GitHub 同步(失败不回滚备份;状态记入 auto.json)
@@ -353,7 +505,7 @@ export function apply(ctx, pluginConfig) {
353
505
  await saveAutoState();
354
506
  }
355
507
 
356
- return { path: out, sha: shaText, total: all.length, stale: stale.length, keep: keepN, sync };
508
+ return { path: out, name, sha: shaText, total: all.length, stale: stale.length, keep: keepN, sync };
357
509
  }
358
510
 
359
511
  // ---------- GitHub 同步 ----------
@@ -369,7 +521,7 @@ export function apply(ctx, pluginConfig) {
369
521
  const token = env?.get('DSH_BACKUP_GITHUB_TOKEN')?.value || env?.get('GITHUB_TOKEN')?.value;
370
522
  // 本地路径(测试/自托管)或 http(s) 全 URL 直接使用,否则视为 owner/repo。
371
523
  const repo = raw.includes('://') || /^[A-Za-z]:[\\/]|^\//.test(raw) ? raw : `https://github.com/${raw}.git`;
372
- return { repo: repo.split('\\').join('/'), token };
524
+ return { repo: stripUserinfo(repo.split('\\').join('/')), token };
373
525
  }
374
526
 
375
527
  /** 校验仓库地址格式(owner/repo、完整 URL 或本地路径),非法返回原因。 */
@@ -380,17 +532,15 @@ export function apply(ctx, pluginConfig) {
380
532
  }
381
533
 
382
534
  /**
383
- * 把当前备份集推送到 GitHub 仓库。工作树位于 `<备份目录>/.github-sync`:
384
- * 归档与边车复制进去,git add -A 同时记录轮换删除,commit 后
385
- * `push HEAD:main --force-with-lease`。https 远端的 token 只写入工作树内
386
- * 的 .git-credentials(credential helper),不进进程参数。
535
+ * 校验同步配置并准备好同步工作树(init + remote + token 凭据 + gitignore)。
536
+ * 推送(githubSync)与拉取(githubPull)共用;未配置/缺 token 返回 reason。
387
537
  */
388
- async function githubSync(signal) {
538
+ async function ensureSyncWorktree(signal) {
389
539
  const { root } = paths();
390
540
  const cfg = githubConfig();
391
- if (!cfg) return { skipped: '未配置 githubRepo(cordis.yml config.githubRepo)' };
541
+ if (!cfg) return { reason: '未配置 githubRepo(cordis.yml config.githubRepo)' };
392
542
  if (cfg.repo.startsWith('https://') && !cfg.token) {
393
- return { skipped: 'https 远端缺少 token(环境变量 DSH_BACKUP_GITHUB_TOKEN 或 GITHUB_TOKEN)' };
543
+ return { reason: 'https 远端缺少 token(环境变量 DSH_BACKUP_GITHUB_TOKEN 或 GITHUB_TOKEN)' };
394
544
  }
395
545
  const syncDir = `${root}/${SYNC_DIR}`;
396
546
  const git = await ctx.subprocess.resolveExecutable('git');
@@ -433,16 +583,37 @@ export function apply(ctx, pluginConfig) {
433
583
  }
434
584
  // token 文件绝不能进仓库:git add -A 会把它当普通文件提交
435
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
+ }
436
608
 
437
609
  // 镜像工作树:只保留 .gitignore、凭据文件(.git-credentials,token
438
610
  // 配置时在 keep 集内保留)与当前备份集(归档+边车),其余文件
439
611
  // (旧副本、误入杂物)清理——git add -A 因此只会收录归档;轮换
440
612
  // 删除与误入文件一并同步移除。
441
613
  const keep = new Set(['.gitignore']);
442
- if (cfg.token) keep.add('.git-credentials');
614
+ if (wt.cfg.token) keep.add('.git-credentials');
443
615
  for (const b of await listBackups()) {
444
- keep.add(b.name);
445
- keep.add(`${b.name}.sha256`);
616
+ for (const f of sidecarsFor(b.name)) keep.add(f);
446
617
  }
447
618
  let entries = [];
448
619
  try {
@@ -463,8 +634,14 @@ export function apply(ctx, pluginConfig) {
463
634
  tooBig.push(b.name);
464
635
  continue;
465
636
  }
466
- await copyFile(`${root}/${b.name}`, `${syncDir}/${b.name}`);
467
- 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
+ }
468
645
  }
469
646
  await spawnRun([git, 'add', '-A'], syncDir, signal);
470
647
  const status = await spawnRun([git, 'status', '--porcelain'], syncDir, signal);
@@ -482,6 +659,65 @@ export function apply(ctx, pluginConfig) {
482
659
  return `GitHub 同步: ${s.skipped || '无变更'}${s.tooBig?.length ? `\n跳过超大文件: ${s.tooBig.join(', ')}` : ''}`;
483
660
  }
484
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
+
485
721
  // ---------- Web 下载路由(仅 loopback,附件形式) ----------
486
722
  async function handleDownload(req, res) {
487
723
  try {
@@ -542,11 +778,11 @@ export function apply(ctx, pluginConfig) {
542
778
  throw new Error(`"${selector}" 匹配多份备份,请加长前缀:\n${hits.slice(0, 5).map((b) => ` ${b.name}`).join('\n')}`);
543
779
  }
544
780
 
545
- /** 删除指定备份(归档 + 校验边车);选择器经 pickArchive 精确匹配,杜绝路径穿越。 */
781
+ /** 删除指定备份(归档 + 全部边车);选择器经 pickArchive 精确匹配,杜绝路径穿越。 */
546
782
  async function removeBackup(selector, signal) {
547
783
  const { root } = paths();
548
784
  const picked = await pickArchive(selector);
549
- await removeFiles([picked.name, `${picked.name}.sha256`], root, signal);
785
+ await removeFiles(sidecarsFor(picked.name), root, signal);
550
786
  return { ok: true, name: picked.name, summary: `已删除备份: ${picked.name}` };
551
787
  }
552
788
 
@@ -593,7 +829,47 @@ export function apply(ctx, pluginConfig) {
593
829
  return { type: f[0][0], name: f.slice(nameIdx).join(' ').replace(/\/$/, '') };
594
830
  }
595
831
 
596
- 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 = {}) {
597
873
  const { home, dshHome, root } = paths();
598
874
  const picked = await pickArchive(selector);
599
875
  const archive = `${root}/${picked.name}`;
@@ -635,11 +911,31 @@ export function apply(ctx, pluginConfig) {
635
911
  throw new Error(`归档包含不安全条目(拒绝恢复):${bad.slice(0, 3).join(', ')},恢复已中止`);
636
912
  }
637
913
 
638
- 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
+ }
639
932
 
640
933
  // 恢复前自动快照当前数据,并把当前数据移到旁边(而非合并覆盖)。
641
- const snapshot = await doBackup(undefined, signal);
642
- let aside = null;
934
+ // 快照用 dsh-pre-restore- 前缀与用户常规备份区分,经 listBackups 过滤
935
+ // 后不进列表/轮换/latest 选择;每次恢复清掉上一次的快照防累积。
936
+ // 现有数据存在性先行:~/.dsh 已缺失(数据全失/新机首恢复)时没有可
937
+ // 快照的内容,跳过快照直接解压——先快照后判存在的旧序会让 tar 对
938
+ // 不存在的 .dsh 报错,恢复在最需要的场景反而失败。
643
939
  let current = false;
644
940
  try {
645
941
  await fsStat(dshHome);
@@ -650,13 +946,91 @@ export function apply(ctx, pluginConfig) {
650
946
  if (!err || err.code !== 'ENOENT') throw err;
651
947
  current = false;
652
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;
653
954
  if (current) {
654
955
  const asideName = `${base}.pre-restore-${stampNow()}`;
655
956
  await renameBeside(parent, base, asideName, signal);
656
957
  aside = `${parent}/${asideName}`;
657
958
  }
658
959
  await spawnRun([tar, '-xzf', picked.name, '-C', parent], root, signal);
659
- 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');
660
1034
  }
661
1035
 
662
1036
  // ---------- 自动备份与 GitHub 同步状态(落盘,重启续跑) ----------
@@ -689,9 +1063,11 @@ export function apply(ctx, pluginConfig) {
689
1063
  lastError: typeof parsed.github.lastError === 'string' ? parsed.github.lastError : null,
690
1064
  };
691
1065
  }
692
- lastAutoAt = typeof parsed?.lastAutoAt === 'string' && !Number.isNaN(Date.parse(parsed.lastAutoAt))
693
- ? parsed.lastAutoAt
694
- : null;
1066
+ // lastAutoAt 是「上次执行」时间戳,不可能在未来。未来日期(注入或时钟
1067
+ // 漂移)会使 nextAutoAt 算出超大延迟、auto 静默失效却仍显示已开启,
1068
+ // 故一律视为非法 → null,从当前时间起算 catch-up。
1069
+ const lastTs = typeof parsed?.lastAutoAt === 'string' ? Date.parse(parsed.lastAutoAt) : NaN;
1070
+ lastAutoAt = Number.isNaN(lastTs) || lastTs > Date.now() ? null : parsed.lastAutoAt;
695
1071
  return Number.isFinite(h) && h >= 1 && h <= 720 ? Math.floor(h) : 0;
696
1072
  } catch (err) {
697
1073
  console.warn(`[dsh-backup] auto.json 无法读取,自动备份计划已重置: ${String(err && err.message ? err.message : err)}`);
@@ -752,7 +1128,7 @@ export function apply(ctx, pluginConfig) {
752
1128
  // ---------- /backup 命令 ----------
753
1129
  ctx.commands.register({
754
1130
  name: 'backup',
755
- 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]',
756
1132
  handler: async (invocation) => {
757
1133
  const input = invocation.rawInput.trim();
758
1134
  try {
@@ -786,15 +1162,10 @@ export function apply(ctx, pluginConfig) {
786
1162
 
787
1163
  if (head === 'restore') {
788
1164
  const dryRun = parts.includes('--dry-run');
1165
+ const syncDeps = parts.includes('--sync-deps');
789
1166
  const sel = parts.slice(1).find((t) => !t.startsWith('--')) || 'latest';
790
- const r = await restoreArchive(sel, dryRun, invocation.signal);
791
- if (r.dryRun) {
792
- return { kind: 'success', text: `📦 恢复预览(未写入)\n 归档: ${r.archive}\n 条目: ${r.files} 项\n${r.sample.map((s) => ` ${s}`).join('\n')}` };
793
- }
794
- return {
795
- kind: 'success',
796
- text: `✅ 恢复完成\n 来源: ${r.archive}(${r.files} 项)\n 恢复前快照: ${r.snapshotPath}\n${r.aside ? ` 旧数据已移至: ${r.aside}\n` : ''} 请重启 dsh 使恢复的会话与配置生效。`,
797
- };
1167
+ const r = await restoreArchive(sel, dryRun, invocation.signal, { syncDeps });
1168
+ return { kind: 'success', text: summarizeRestore(r) };
798
1169
  }
799
1170
 
800
1171
  if (head === 'github') {
@@ -808,9 +1179,10 @@ export function apply(ctx, pluginConfig) {
808
1179
  }
809
1180
  const invalid = validateRepo(value);
810
1181
  if (invalid) return { kind: 'error', text: invalid };
811
- githubState = { ...githubState, repo: value, lastError: null };
1182
+ const clean = stripUserinfo(value);
1183
+ githubState = { ...githubState, repo: clean, lastError: null };
812
1184
  await saveAutoState();
813
- return { kind: 'success', text: `GitHub 同步仓库已设为: ${value}\n${autoSummary()}` };
1185
+ return { kind: 'success', text: `GitHub 同步仓库已设为: ${clean}\n${autoSummary()}` };
814
1186
  }
815
1187
  if (arg === 'sync') {
816
1188
  const s = await githubSync(invocation.signal);
@@ -823,6 +1195,27 @@ export function apply(ctx, pluginConfig) {
823
1195
  }
824
1196
  return { kind: 'success', text: summarizeSync(s) };
825
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
+ }
826
1219
  if (arg === 'status') {
827
1220
  const cfg = githubConfig();
828
1221
  const text = cfg
@@ -830,7 +1223,7 @@ export function apply(ctx, pluginConfig) {
830
1223
  : 'GitHub 同步未配置:/backup github repo <owner/repo> 设置,或在 cordis.yml 的 config.githubRepo 配置。';
831
1224
  return { kind: 'success', text };
832
1225
  }
833
- return { kind: 'error', text: '用法: /backup github status|sync|repo <地址|off>' };
1226
+ return { kind: 'error', text: '用法: /backup github status|sync|pull [--restore <前缀|latest>]|repo <地址|off>' };
834
1227
  }
835
1228
 
836
1229
  if (head === 'delete' || head === 'rm') {
@@ -872,13 +1265,14 @@ export function apply(ctx, pluginConfig) {
872
1265
  // ---------- backup_dsh 模型工具 ----------
873
1266
  ctx.tools.register(defineTool({
874
1267
  name: 'backup_dsh',
875
- 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=关闭,缺省查询)。',
876
1269
  parameters: {
877
1270
  mode: { type: 'string', required: true, enum: ['backup', 'list', 'verify', 'restore', 'auto'], description: 'backup=执行备份,list=列出备份,verify=校验完整性,restore=恢复,auto=定时备份' },
878
1271
  keep: { type: 'number', description: '保留的备份份数(mode=backup,默认 7)' },
879
1272
  hours: { type: 'number', description: '定时备份间隔小时数(mode=auto;0=关闭;缺省=查询状态)' },
880
1273
  selector: { type: 'string', description: '备份选择器(mode=verify/restore):归档名前缀、latest 或 all' },
881
1274
  dryRun: { type: 'boolean', description: 'mode=restore 时仅预览恢复内容,不写入' },
1275
+ syncDeps: { type: 'boolean', description: 'mode=restore 时恢复后对各 profile 执行 pnpm install 重装插件依赖' },
882
1276
  },
883
1277
  output: {
884
1278
  schema: { type: 'object', additionalProperties: true },
@@ -908,12 +1302,7 @@ export function apply(ctx, pluginConfig) {
908
1302
  }
909
1303
  if (mode === 'restore') {
910
1304
  const r = await restoreArchive(selector || 'latest', Boolean(args && args.dryRun), signal);
911
- if (r.dryRun) return { ok: true, summary: `恢复预览(未写入): ${r.archive}\n条目 ${r.files} 项,含:\n${r.sample.map((s) => ` ${s}`).join('\n')}` };
912
- return {
913
- ok: true,
914
- path: r.archive,
915
- summary: `恢复完成: ${r.archive}(${r.files} 项)\n恢复前快照: ${r.snapshotPath}\n${r.aside ? `旧数据已移至: ${r.aside}\n` : ''}请重启 dsh 生效。`,
916
- };
1305
+ return { ok: true, path: r.archive, summary: summarizeRestore(r) };
917
1306
  }
918
1307
  if (mode === 'auto') {
919
1308
  const h = args && args.hours !== undefined ? args.hours : null;
@@ -978,7 +1367,7 @@ export function apply(ctx, pluginConfig) {
978
1367
  try {
979
1368
  const r = await restoreArchive(selector || 'latest', Boolean(dryRun), signal);
980
1369
  if (r.dryRun) {
981
- 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) };
982
1371
  }
983
1372
  return {
984
1373
  ok: true,
@@ -987,7 +1376,10 @@ export function apply(ctx, pluginConfig) {
987
1376
  files: r.files,
988
1377
  aside: r.aside,
989
1378
  snapshotPath: r.snapshotPath,
990
- 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),
991
1383
  };
992
1384
  } catch (err) {
993
1385
  return { ok: false, dryRun: Boolean(dryRun), summary: String(err && err.message ? err.message : err) };
@@ -1009,7 +1401,8 @@ export function apply(ctx, pluginConfig) {
1009
1401
  const { root } = paths();
1010
1402
  return {
1011
1403
  // repoRaw 是用户原始输入(运行时值优先,否则 cordis.yml 默认),供面板编辑框回填
1012
- repoRaw: githubState.repo ?? (typeof pluginConfig?.githubRepo === 'string' ? pluginConfig.githubRepo : null),
1404
+ // 两路都经 stripUserinfo:避免 cordis.yml config.githubRepo 内嵌 token 时经面板泄露
1405
+ repoRaw: githubState.repo ?? (typeof pluginConfig?.githubRepo === 'string' ? stripUserinfo(pluginConfig.githubRepo) : null),
1013
1406
  repo: cfg ? cfg.repo : null,
1014
1407
  tokenSet: Boolean(cfg?.token),
1015
1408
  syncDir: `${root}/${SYNC_DIR}`,
@@ -1035,6 +1428,19 @@ export function apply(ctx, pluginConfig) {
1035
1428
  return { ok: false, summary: message, pushed: false, tooBig: [] };
1036
1429
  }
1037
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
+ },
1038
1444
  removeEntry: async (selector, signal) => {
1039
1445
  try {
1040
1446
  const r = await removeBackup(selector || 'latest', signal);
@@ -1052,9 +1458,10 @@ export function apply(ctx, pluginConfig) {
1052
1458
  }
1053
1459
  const invalid = validateRepo(raw);
1054
1460
  if (invalid) return { ok: false, summary: invalid };
1055
- githubState = { ...githubState, repo: raw, lastError: null };
1461
+ const clean = stripUserinfo(raw);
1462
+ githubState = { ...githubState, repo: clean, lastError: null };
1056
1463
  await saveAutoState();
1057
- return { ok: true, repo: raw, summary: `GitHub 同步仓库已设为: ${raw}` };
1464
+ return { ok: true, repo: clean, summary: `GitHub 同步仓库已设为: ${clean}` };
1058
1465
  },
1059
1466
  };
1060
1467