@xiaoyuyu6420/dsh-backup 0.13.0 → 0.13.2

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
@@ -59,8 +59,18 @@ export const inject = ['subprocess', 'commands', 'timer', 'tools'];
59
59
 
60
60
  /** Windows 无 POSIX chmod 语义(用户目录 ACL 默认私有)。 */
61
61
  const IS_WIN = process.platform === 'win32';
62
- /** sha256 回退路径(node:fs 读取 + node:crypto)的内存上限。 */
62
+ /** 深度校验(会话日志整读入内存)的体积上限;sha256 流式回退不适用此限。 */
63
63
  const HASH_MAX_BYTES = 256 * 1024 * 1024;
64
+ /** 流式 sha256 的分块大小(4MB ≈ 每块几毫秒 CPU,块间让出事件循环)。 */
65
+ const HASH_CHUNK_BYTES = 4 * 1024 * 1024;
66
+ /**
67
+ * 启动宽限期:插件装配后头 45 秒不启动任何重活(全量 tar/gzip + 哈希要几分钟)。
68
+ * 宿主自己的 boot 预算按 30s 计(#103:DSH Desktop 的 HostRpc 30s 超时),
69
+ * 启动期补跑全量备份会抢 CPU/IO 把宿主 boot 拖超时,进而被判失败强杀。
70
+ */
71
+ const AUTO_BOOT_GRACE_MS = 45_000;
72
+ /** 自动备份失败后同会话内的重试间隔(只重试一次,避免失败热循环烧 CPU)。 */
73
+ const AUTO_RETRY_MS = 30 * 60_000;
64
74
  /** GitHub 单个文件 100MB 上限,留 10MB 余量。 */
65
75
  const MAX_GITHUB_BYTES = 90 * 1024 * 1024;
66
76
  /** 同步工作树目录名(位于备份目录下)。 */
@@ -77,6 +87,8 @@ const DOWNLOAD_PREFIX = '/backup-download';
77
87
  const SENSITIVE_DEFAULTS = ['.credentials.yaml', '.env', 'qq-bridge/config.json'];
78
88
  /** 备份目录下保存明文敏感文件的子目录(随备份刷新为最新一份的副本)。 */
79
89
  const VAULT_DIR = 'vault';
90
+ /** vault 下存放「凭据旧布局存底」的子目录:跨升级的长效安全网,备份刷新时不得删除。 */
91
+ const PRESERVED_DIR = 'preserved';
80
92
 
81
93
  /**
82
94
  * 分类型备份的内容类型表(相对 ~/.dsh 根的 POSIX 路径)。
@@ -94,7 +106,6 @@ const BACKUP_TYPES = Object.freeze({
94
106
  settings: { label: '设置', paths: ['settings.yaml'] },
95
107
  profiles: { label: '插件 profiles', paths: ['profiles'] },
96
108
  });
97
- const BACKUP_TYPE_KEYS = Object.freeze(Object.keys(BACKUP_TYPES));
98
109
  /** 分类型归档前缀(与全量 dsh- 区分:独立轮换、不进全量列表、旧版插件不可见不误删)。 */
99
110
  const TYPED_PREFIX = 'dsh-t-';
100
111
 
@@ -297,17 +308,36 @@ const DEFAULTS = Object.freeze({
297
308
  /**
298
309
  * 解析 settings 命名空间的 schema,同时定义默认值。
299
310
  * Base 层:pluginConfig(cordis.patch.yml,插件级或 profile 级)。
300
- * User 层:Settings 面板运行时编辑,持久化到 settings.yaml。
311
+ * User 层:Settings 面板运行时编辑(0.1.6 及更早持久化到 settings.yaml;
312
+ * 0.1.7 起由宿主写入 profile 覆盖层)。
313
+ *
314
+ * **两代模型共用这一份**:
315
+ * - ≤0.1.6-alpha.1:`ctx.settings.register(NS, SCHEMA)` 自注册命名空间;
316
+ * - ≥0.1.7(含 0.2.0):`register` 被移除,宿主改从「条目的 Config schema」
317
+ * 派生表单,且只认**标了 volatile 的字段**(宿主 dsh-settings 的
318
+ * `volatileForm` 仅当 `meta.volatile` 存在才纳入表单)。故此处导出 Config
319
+ * 并逐字段标注 volatile。
320
+ *
321
+ * `volatileField` 做运行时探测:老载体(@deepseek-ai/schemastery < 3.18.4)
322
+ * 没有 `.volatile()`,探测失败就原样返回——老宿主走 register 路径,不需要它。
323
+ * 这样同一份代码在 0.1.1 → 0.2.0 全列车都能装配,不会因新 API 缺失而崩在装载期。
301
324
  */
325
+ const volatileField = (schema) => (typeof schema.volatile === 'function' ? schema.volatile() : schema);
326
+
302
327
  const SCHEMA = z.object({
303
- destination: z.string().default(DEFAULTS.destination),
304
- keep: z.number().min(0).default(0),
305
- exclude: z.array(z.string()).default(DEFAULTS.exclude),
306
- redact: z.union([z.array(z.string()), 'off']).default(DEFAULTS.redact),
307
- githubRepo: z.string().default(DEFAULTS.githubRepo),
308
- updateCheck: z.boolean().default(DEFAULTS.updateCheck),
328
+ destination: volatileField(z.string().default(DEFAULTS.destination)),
329
+ keep: volatileField(z.number().min(0).default(0)),
330
+ exclude: volatileField(z.array(z.string()).default(DEFAULTS.exclude)),
331
+ // 'none' 是 v0.6 时代写法(代码里映射为 'off'):schema 收下它,别让老配置
332
+ // 在新宿主的条目校验里被拒。
333
+ redact: volatileField(z.union([z.array(z.string()), 'off', 'none']).default(DEFAULTS.redact)),
334
+ githubRepo: volatileField(z.string().default(DEFAULTS.githubRepo)),
335
+ updateCheck: volatileField(z.boolean().default(DEFAULTS.updateCheck)),
309
336
  });
310
337
 
338
+ /** 宿主装载条目时读取的 Config schema(0.1.7+ 的设置表单来源)。 */
339
+ export const Config = SCHEMA;
340
+
311
341
  const SETTINGS_FIELDS = ['destination', 'keep', 'exclude', 'redact', 'githubRepo', 'updateCheck'];
312
342
 
313
343
  /** 校验并归一化单个字段到 `out`;非法值忽略。 */
@@ -515,7 +545,13 @@ export function apply(ctx, pluginConfig) {
515
545
  let settingsService;
516
546
  ctx.inject(['settings'], (sctx) => {
517
547
  settingsService = sctx.settings;
518
- settingsService.register(NS, SCHEMA, { base: resolveBase(pluginConfig) });
548
+ // 0.1.7 起宿主移除了 settings.register(新模型改由「profile 条目自带的
549
+ // Config schema」派生表单);旧宿主(≤0.1.6-alpha.1)仍走自注册命名空间。
550
+ // 两级都留:有 register 就注册,没有就跳过——请求侧按「命名空间缺失」显式
551
+ // 降级,而不是在装配期抛错(旧实现在新宿主上是静默 TypeError)。
552
+ if (typeof settingsService.register === 'function') {
553
+ settingsService.register(NS, SCHEMA, { base: resolveBase(pluginConfig) });
554
+ }
519
555
  // detach 清理:服务中途 dispose 时置空,resolvedConfig() 自动降级到 pluginConfig
520
556
  return () => { settingsService = undefined; };
521
557
  });
@@ -678,6 +714,9 @@ export function apply(ctx, pluginConfig) {
678
714
  // ('D:' + '/' + name 仍是合法路径,renameBeside 等不受影响)。
679
715
  const parentTar = (p) => (p.endsWith('/') ? p : `${p}/`);
680
716
 
717
+ /** 仓库地址:CLI 首次备份的 star 提示用(面板侧另有常驻入口,见 src/client.js)。 */
718
+ const STAR_URL = 'https://github.com/xiaoyuyu6420/dsh-backup';
719
+
681
720
  /** semver 比较(常见形态,含 prerelease):a > b 才返回 true;解析失败保守降为 false。 */
682
721
  function semverGt(a, b) {
683
722
  const parse = (s) => {
@@ -816,6 +855,18 @@ export function apply(ctx, pluginConfig) {
816
855
  return out;
817
856
  }
818
857
 
858
+ /**
859
+ * vault 串行锁:同一进程内「备份刷新镜像」与「凭据存底」(启动钩子 / migrate
860
+ * 预检)都写同一个 vault 目录。旧实现是"整目录 rm -rf 再重写",两路并发时
861
+ * 互相踩——Linux 上实测 ENOTEMPTY 直接让整个备份失败(CI 稳定复现)。
862
+ */
863
+ let vaultChain = Promise.resolve();
864
+ function withVaultLock(task) {
865
+ const next = vaultChain.then(task, task);
866
+ vaultChain = next.then(() => {}, () => {});
867
+ return next;
868
+ }
869
+
819
870
  /**
820
871
  * 刷新本机 vault:把 ~/.dsh 中现存的敏感文件按相对路径镜像到
821
872
  * `<备份目录>/vault/`(先清空旧镜像,vault 永远对应最新一次备份)。
@@ -827,29 +878,41 @@ export function apply(ctx, pluginConfig) {
827
878
  const vaultDir = `${root}/${VAULT_DIR}`;
828
879
  if (!rels.length) return [];
829
880
  if (signal?.aborted) throw new Error('操作已取消');
830
- await rm(vaultDir, { recursive: true, force: true });
831
- const stored = [];
832
- for (const rel of rels) {
833
- const src = `${dshHome}/${rel}`;
834
- const ok = await fsStat(src).then(() => true, (err) => {
835
- if (err && err.code === 'ENOENT') return false;
836
- throw err;
837
- });
838
- if (!ok) continue;
839
- const dst = `${vaultDir}/${rel}`;
840
- await mkdir(dirname(dst), { recursive: true });
841
- await copyFile(src, dst);
842
- stored.push(rel);
843
- }
844
- if (stored.length && !IS_WIN) {
845
- const chmod = await ctx.subprocess.resolveExecutable('chmod');
881
+ return withVaultLock(async () => {
882
+ // 只清 vault 里的**镜像条目**:`preserved/`(凭据存底)绝不能被下一次备份
883
+ // 抹掉——它正是为"升级后想回退"留的后路。镜像文件随后会被 copyFile 覆盖成
884
+ // 最新内容,所以清理失败(另一实例正在写)一律容忍,不值得让备份失败。
885
+ let names = [];
846
886
  try {
847
- await spawnRun([chmod, '-R', 'go-rwx', vaultDir], home, signal);
848
- } catch {
849
- // chmod 失败不阻断备份:vault 已在用户私有目录下,权限收紧是纵深防御
887
+ names = await readdir(vaultDir);
888
+ } catch { /* vault 尚不存在:下面逐文件 mkdir 会建 */ }
889
+ for (const n of names) {
890
+ if (n === PRESERVED_DIR) continue;
891
+ await rm(`${vaultDir}/${n}`, { recursive: true, force: true }).catch(() => {});
850
892
  }
851
- }
852
- return stored;
893
+ const stored = [];
894
+ for (const rel of rels) {
895
+ const src = `${dshHome}/${rel}`;
896
+ const ok = await fsStat(src).then(() => true, (err) => {
897
+ if (err && err.code === 'ENOENT') return false;
898
+ throw err;
899
+ });
900
+ if (!ok) continue;
901
+ const dst = `${vaultDir}/${rel}`;
902
+ await mkdir(dirname(dst), { recursive: true });
903
+ await copyFile(src, dst);
904
+ stored.push(rel);
905
+ }
906
+ if (stored.length && !IS_WIN) {
907
+ const chmod = await ctx.subprocess.resolveExecutable('chmod');
908
+ try {
909
+ await spawnRun([chmod, '-R', 'go-rwx', vaultDir], home, signal);
910
+ } catch {
911
+ // chmod 失败不阻断备份:vault 已在用户私有目录下,权限收紧是纵深防御
912
+ }
913
+ }
914
+ return stored;
915
+ });
853
916
  }
854
917
 
855
918
  function stampNow() {
@@ -997,7 +1060,11 @@ export function apply(ctx, pluginConfig) {
997
1060
  }
998
1061
 
999
1062
  // 校验和:POSIX 优先 sha256sum(macOS 回退 shasum)流式计算;
1000
- // 都不可用时(Windows)回退 node:fs 读取 + node:crypto 内存哈希。
1063
+ // 都不可用时(Windows 典型)回退 node:crypto 流式哈希。
1064
+ // #103:旧实现是 readFile 整包进内存 + 同步 update,且 >256MB 直接抛错——
1065
+ // Windows 上 3GB 级归档因此**必然失败**,备份写完 tar 却永远收不了尾,
1066
+ // 锚点不前进 → 每次启动重跑一次全量 tar → 宿主 boot 窗口被拖死。
1067
+ // 改为 createReadStream 分块喂哈希:不整包入内存、每块之间让出事件循环、无体积上限。
1001
1068
  async function sha256File(absPath, home, signal) {
1002
1069
  for (const candidate of [['sha256sum', []], ['shasum', ['-a', '256']]]) {
1003
1070
  try {
@@ -1010,12 +1077,18 @@ export function apply(ctx, pluginConfig) {
1010
1077
  // 尝试下一个
1011
1078
  }
1012
1079
  }
1013
- const info = await fsStat(absPath);
1014
- if (info.size > HASH_MAX_BYTES) {
1015
- throw new Error(`计算 sha256 失败:文件 ${Math.floor(info.size / 1048576)}MB 超过 ${Math.floor(HASH_MAX_BYTES / 1048576)}MB 回退上限`);
1080
+ const hash = createHash('sha256');
1081
+ const stream = createReadStream(absPath, { highWaterMark: HASH_CHUNK_BYTES });
1082
+ try {
1083
+ for await (const chunk of stream) {
1084
+ if (signal?.aborted) throw new Error('操作已取消');
1085
+ hash.update(chunk);
1086
+ }
1087
+ } catch (err) {
1088
+ stream.destroy();
1089
+ throw err;
1016
1090
  }
1017
- const bytes = await readFile(absPath);
1018
- return createHash('sha256').update(bytes).digest('hex');
1091
+ return hash.digest('hex');
1019
1092
  }
1020
1093
 
1021
1094
  async function existsStat(p) {
@@ -1843,17 +1916,23 @@ export function apply(ctx, pluginConfig) {
1843
1916
  if (mergedProfiles.length) {
1844
1917
  try {
1845
1918
  const pnpm = await ctx.subprocess.resolveExecutable('pnpm');
1919
+ // 单个 profile 装失败记入 failed 并继续(与全量恢复路径同语义);
1920
+ // pnpm 本身缺失才落到外层 note。
1846
1921
  for (const p of mergedProfiles) {
1847
1922
  try {
1848
1923
  await spawnRun([pnpm, 'install', '--frozen-lockfile'], `${dshHome}/profiles/${p}`, signal);
1849
1924
  deps.installed.push(p);
1850
1925
  } catch {
1851
- await spawnRun([pnpm, 'install'], `${dshHome}/profiles/${p}`, signal);
1852
- deps.installed.push(p);
1926
+ try {
1927
+ await spawnRun([pnpm, 'install'], `${dshHome}/profiles/${p}`, signal);
1928
+ deps.installed.push(p);
1929
+ } catch {
1930
+ deps.failed.push(p);
1931
+ }
1853
1932
  }
1854
1933
  }
1855
1934
  } catch {
1856
- deps.note = 'pnpm 不可用,跳过依赖重装(配置已恢复)';
1935
+ deps.note = '未找到 pnpm,请在各 profile 目录手动执行 pnpm install';
1857
1936
  }
1858
1937
  }
1859
1938
  }
@@ -2428,14 +2507,17 @@ export function apply(ctx, pluginConfig) {
2428
2507
  async function preserveFlatCredentials() {
2429
2508
  const { dshHome, root } = paths();
2430
2509
  if (!(await credentialsIsFlatLayout(dshHome))) return null;
2431
- const dir = `${root}/${VAULT_DIR}/preserved`;
2432
- await mkdir(dir, { recursive: true });
2433
- const dest = `${dir}/credentials-flat-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.yaml`;
2434
- await copyFile(`${dshHome}/.credentials.yaml`, dest);
2435
- // 轮换:只留最近 3 份(按文件名时间戳排序即时间序)
2436
- const old = (await readdir(dir)).filter((n) => n.startsWith('credentials-flat-')).sort();
2437
- for (const n of old.slice(0, Math.max(old.length - 3, 0))) await rm(`${dir}/${n}`, { force: true });
2438
- return dest;
2510
+ // 与备份侧的 vault 刷新共用串行锁:并发写入同一 vault 目录互相踩(ENOTEMPTY)
2511
+ return withVaultLock(async () => {
2512
+ const dir = `${root}/${VAULT_DIR}/${PRESERVED_DIR}`;
2513
+ await mkdir(dir, { recursive: true });
2514
+ const dest = `${dir}/credentials-flat-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.yaml`;
2515
+ await copyFile(`${dshHome}/.credentials.yaml`, dest);
2516
+ // 轮换:只留最近 3 份(按文件名时间戳排序即时间序)
2517
+ const old = (await readdir(dir)).filter((n) => n.startsWith('credentials-flat-')).sort();
2518
+ for (const n of old.slice(0, Math.max(old.length - 3, 0))) await rm(`${dir}/${n}`, { force: true });
2519
+ return dest;
2520
+ });
2439
2521
  }
2440
2522
 
2441
2523
  /** 环境级探测:硬链接支持(v3 发布用 fs.link,exFAT 等会 ENOTSUP)+ 凭据布局。 */
@@ -2598,6 +2680,30 @@ export function apply(ctx, pluginConfig) {
2598
2680
  // 上次见到的宿主列车(detectHostTrain 代理值):null=尚未记录(首启动只记
2599
2681
  // 录不拍快照),非 null 且变化 → 升/降级 → 自动拍 dsh-pre-upgrade- 快照。
2600
2682
  let lastTrain = null;
2683
+ // 增长:CLI 回执的 star 提示只出现一次(持久化在 auto.json;旧版读未知字段安全忽略)
2684
+ let starHinted = false;
2685
+ // #103 循环防护三件套:
2686
+ // - lastAutoAttemptAt:每次自动备份**起跑即记**(不等到成功)。写盘后即使进程
2687
+ // 被强杀(重活干到一半被杀正是本 issue 的形态),下次启动也知道"刚试过",
2688
+ // 不会以 delay≤0 立刻重跑;旧版读未知字段安全忽略。
2689
+ // - newestArchiveAt:备份目录里最新归档的 mtime,调度锚点的最终兜底——磁盘上
2690
+ // 躺着新归档就说明不必马上再打一份,避免锚点丢失导致的重跑。
2691
+ // - lastAutoError:上次失败原因(成功即清),让"备份一直在失败"这件事在
2692
+ // /backup 与面板上可见,而不是只躺在没人看的宿主日志里。
2693
+ let lastAutoAttemptAt = null;
2694
+ let newestArchiveAt = 0;
2695
+ let lastAutoError = null;
2696
+ // 连续失败次数(内存态):失败重试按 30min × 2^(n-1) 退避、封顶一个周期,
2697
+ // 避免确定性失败(磁盘满/权限)变成每 30 分钟烧一次 CPU 的热循环。
2698
+ let autoFailStreak = 0;
2699
+ // 无人值守的重活串行队列:开机补跑与列车快照可能同时到点,两份 GB 级 tar
2700
+ // 并行会把机器彻底压死。手动备份/恢复快照是用户在等结果的显式操作,不入队。
2701
+ let heavyChain = Promise.resolve();
2702
+ function enqueueHeavy(task) {
2703
+ const next = heavyChain.then(task, task);
2704
+ heavyChain = next.then(() => {}, () => {});
2705
+ return next;
2706
+ }
2601
2707
 
2602
2708
  /**
2603
2709
  * 等待 settings 用户层加载完成(describe 出现 revision)。启动钩子若在
@@ -2640,7 +2746,7 @@ export function apply(ctx, pluginConfig) {
2640
2746
  // (先到者 rename 走,后到者 ENOENT),UUID 跨实例唯一
2641
2747
  const tmp = `${target}.${randomUUID()}.tmp`;
2642
2748
  try {
2643
- await writeOwned(tmp, `${JSON.stringify({ hours: autoHours, lastAutoAt, github: githubState, lastTrain })}\n`);
2749
+ await writeOwned(tmp, `${JSON.stringify({ hours: autoHours, lastAutoAt, github: githubState, lastTrain, starHinted, lastAutoAttemptAt, lastAutoError })}\n`);
2644
2750
  await rename(tmp, target);
2645
2751
  } catch (err) {
2646
2752
  await rm(tmp, { force: true }).catch(() => {});
@@ -2648,6 +2754,32 @@ export function apply(ctx, pluginConfig) {
2648
2754
  }
2649
2755
  }
2650
2756
 
2757
+ /**
2758
+ * 磁盘上最新全量归档的 mtime(#103 兜底锚点):锚点丢失/不前进时,只要备份
2759
+ * 目录里躺着新归档就不该马上再打一份——这是"失败→重跑→再失败"死循环的
2760
+ * 第二道断路(第一道是 lastAutoAttemptAt)。口径与 listBackups 一致:
2761
+ * 只看用户可见的全量归档,不含内部快照与分类型归档。
2762
+ */
2763
+ async function refreshNewestArchiveAt() {
2764
+ const { root } = paths();
2765
+ let dirents;
2766
+ try {
2767
+ dirents = await readdir(root, { withFileTypes: true });
2768
+ } catch {
2769
+ return; // 备份目录还没有/不可读:锚点保持 0
2770
+ }
2771
+ let newest = 0;
2772
+ for (const d of dirents) {
2773
+ if (!d.name.startsWith('dsh-') || !d.name.endsWith('.tar.gz')) continue;
2774
+ if (SNAPSHOT_PREFIXES.some((p) => d.name.startsWith(p)) || d.name.startsWith(TYPED_PREFIX)) continue;
2775
+ try {
2776
+ const st = await fsStat(`${root}/${d.name}`);
2777
+ if (st.mtimeMs > newest) newest = st.mtimeMs;
2778
+ } catch { /* 与轮换/删除竞态:忽略 */ }
2779
+ }
2780
+ newestArchiveAt = newest;
2781
+ }
2782
+
2651
2783
  async function loadAutoState() {
2652
2784
  try {
2653
2785
  const { root } = paths();
@@ -2665,7 +2797,16 @@ export function apply(ctx, pluginConfig) {
2665
2797
  // 故一律视为非法 → null,从当前时间起算 catch-up。
2666
2798
  const lastTs = typeof parsed?.lastAutoAt === 'string' ? Date.parse(parsed.lastAutoAt) : NaN;
2667
2799
  lastAutoAt = Number.isNaN(lastTs) || lastTs > Date.now() ? null : parsed.lastAutoAt;
2800
+ const attTs = typeof parsed?.lastAutoAttemptAt === 'string' ? Date.parse(parsed.lastAutoAttemptAt) : NaN;
2801
+ lastAutoAttemptAt = Number.isNaN(attTs) || attTs > Date.now() ? null : parsed.lastAutoAttemptAt;
2668
2802
  lastTrain = typeof parsed?.lastTrain === 'string' && parsed.lastTrain ? parsed.lastTrain : null;
2803
+ starHinted = parsed?.starHinted === true;
2804
+ const e = parsed?.lastAutoError;
2805
+ lastAutoError = e && typeof e === 'object' && typeof e.message === 'string'
2806
+ ? { at: typeof e.at === 'string' ? e.at : '', message: e.message }
2807
+ : null;
2808
+ // 旧版写的 auto.json 没有这些字段 → 全部按缺失处理,行为与旧版一致
2809
+ await refreshNewestArchiveAt();
2669
2810
  return Number.isFinite(h) && h >= 1 && h <= 720 ? Math.floor(h) : 0;
2670
2811
  } catch (err) {
2671
2812
  // ENOENT = 首次使用还没有 auto.json,是正常初始状态,静默按未配置处理;
@@ -2673,16 +2814,20 @@ export function apply(ctx, pluginConfig) {
2673
2814
  if (err && err.code !== 'ENOENT') {
2674
2815
  console.warn(`[dsh-backup] 自动备份计划文件读取失败,定时备份已暂停(不影响已有备份):执行 /backup auto <小时数> 可重新开启(${String(err && err.message ? err.message : err)})`);
2675
2816
  }
2817
+ await refreshNewestArchiveAt();
2676
2818
  return 0;
2677
2819
  }
2678
2820
  }
2679
2821
 
2680
- /** 下次自动备份触发时间(毫秒):上次执行 + 周期;无锚点从当前时间起算。 */
2822
+ /** 调度锚点(毫秒):上次成功 / 磁盘最新归档,取最晚者;皆无则 0。 */
2823
+ function autoAnchorAt() {
2824
+ return Math.max(Number(new Date(lastAutoAt)) || 0, newestArchiveAt || 0);
2825
+ }
2826
+
2827
+ /** 下次自动备份触发时间(毫秒):锚点 + 周期;无锚点从当前时间起算。 */
2681
2828
  function nextAutoAt() {
2682
2829
  const cycle = autoHours * 3600 * 1000;
2683
- // 非法/缺失 lastAutoAt 按 0 处理:从当前时间起算,避免 NaN 传染延迟计算
2684
- const anchor = (Number(new Date(lastAutoAt)) || 0) + cycle;
2685
- return Math.max(anchor, Date.now());
2830
+ return Math.max(autoAnchorAt() + cycle, Date.now());
2686
2831
  }
2687
2832
 
2688
2833
  /** 本地时间显示:YYYY-MM-DD HH:mm——不依赖运行时 locale(裸跑环境会落 en-US)。 */
@@ -2692,43 +2837,82 @@ export function apply(ctx, pluginConfig) {
2692
2837
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
2693
2838
  }
2694
2839
 
2840
+ /** 失败提示(若有):让"备份一直在失败"在 /backup 与面板上可见,而不是只躺在宿主日志里。 */
2841
+ function autoErrorNote() {
2842
+ if (!lastAutoError) return '';
2843
+ const when = lastAutoError.at ? formatWhen(Date.parse(lastAutoError.at)) : '';
2844
+ const msg = lastAutoError.message.length > 200 ? `${lastAutoError.message.slice(0, 200)}…` : lastAutoError.message;
2845
+ return `\n⚠️ 上次自动备份未完成${when ? `(${when})` : ''}:${msg}`;
2846
+ }
2847
+
2695
2848
  function autoSummary() {
2696
- if (!autoDispose) return '自动备份未开启(/backup auto <N小时> 开启)';
2697
- // 绝对节奏:下次 = 上次执行 + 周期;错过(重启间隔超周期)则按现在显示,调度会立即补跑。
2698
- return `自动备份已开启:每 ${autoHours} 小时一次(已持久化,重启续跑),下次 ${formatWhen(nextAutoAt())}${lastAuto ? `;上次自动备份: ${lastAuto}` : ''}`;
2849
+ if (!autoDispose) return `自动备份未开启(/backup auto <N小时> 开启)${autoErrorNote()}`;
2850
+ // 绝对节奏:下次 = 锚点 + 周期;错过(重启间隔超周期)则按现在显示,调度会在启动宽限后补跑。
2851
+ return `自动备份已开启:每 ${autoHours} 小时一次(已持久化,重启续跑),下次 ${formatWhen(nextAutoAt())}${lastAuto ? `;上次自动备份: ${lastAuto}` : ''}${autoErrorNote()}`;
2699
2852
  }
2700
2853
 
2701
- /** 链式 timeout 调度:每次触发后按上次执行时间推算下一次,重启后节奏不重置。 */
2702
- function scheduleAuto() {
2854
+ /**
2855
+ * 链式 timeout 调度:每次触发后按锚点推算下一次,重启后节奏不重置。
2856
+ * #103 两条断路,缺一不可:
2857
+ * 1. boot 宽限——启动路径的首次触发绝不落在宿主 boot 窗口内(delay≤0 的补跑
2858
+ * 正是把 GB 级 tar 塞进 boot 窗口、拖垮 HostRpc 的元凶);
2859
+ * 2. 尝试冷却——上次**起跑**(不要求成功)之后 30 分钟(失败按 2^n 退避)内
2860
+ * 不再起跑。锚点落盘,所以"干到一半被强杀 → 下次启动又立刻重跑"的循环断开。
2861
+ */
2862
+ function scheduleAuto(opts = {}) {
2703
2863
  // 关闭竞态保护:auto off 时 in-flight 备份完成后的续链会以 autoHours=0
2704
2864
  // 算出 delay=0,形成无限备份循环——关闭后绝不续链。
2705
2865
  if (autoHours <= 0) return;
2706
- const delay = nextAutoAt() - Date.now();
2866
+ const cycle = autoHours * 3600 * 1000;
2867
+ let delay = nextAutoAt() - Date.now();
2868
+ const attemptAt = Number(new Date(lastAutoAttemptAt)) || 0;
2869
+ const cooldown = autoFailStreak > 0
2870
+ ? Math.min(AUTO_RETRY_MS * 2 ** (autoFailStreak - 1), cycle)
2871
+ : AUTO_RETRY_MS;
2872
+ if (attemptAt) delay = Math.max(delay, attemptAt + cooldown - Date.now());
2873
+ if (opts.boot) delay = Math.max(delay, AUTO_BOOT_GRACE_MS);
2707
2874
  autoDispose = ctx.timeout(async () => {
2708
- await runAutoBackup();
2875
+ // 无人值守重活串行:与列车快照等其它无人值守任务排队,不并行抢机器
2876
+ await enqueueHeavy(runAutoBackup);
2709
2877
  scheduleAuto();
2710
2878
  }, delay);
2711
2879
  }
2712
2880
 
2713
2881
  async function runAutoBackup() {
2882
+ // 起跑留痕(先写盘再干活):进程被强杀也不丢"刚试过"这个事实,
2883
+ // 下次启动据此冷却,不会在 boot 窗口里重跑(#103)。
2884
+ lastAutoAttemptAt = new Date().toISOString();
2885
+ await saveAutoState().catch(() => { /* 记录失败不阻断备份本身 */ });
2714
2886
  try {
2715
2887
  const r = await doBackup(autoKeep());
2716
2888
  lastAuto = r.path.split('/').pop();
2717
2889
  lastAutoAt = new Date().toISOString();
2890
+ lastAutoError = null;
2891
+ autoFailStreak = 0;
2892
+ newestArchiveAt = Date.now();
2718
2893
  await saveAutoState();
2719
2894
  console.log(`[dsh-backup] 自动备份完成: ${r.path} (sha ${r.sha.slice(0, 12)}…)`);
2720
2895
  } catch (err) {
2721
- // 失败也推进内存锚点:否则下次 delay=0 立即重试形成热循环。不落盘
2722
- // (失败路径 auto.json 大概率也写失败),下次成功备份会持久化。
2723
- lastAutoAt = new Date().toISOString();
2724
- console.error(`[dsh-backup] 自动备份失败: ${String(err && err.message ? err.message : err)}`);
2896
+ // 失败不再"只推进内存锚点"(旧实现不落盘 → 每次启动 delay≤0 重跑,
2897
+ // 3GB 归档必然失败时就是无限重跑;#103)。改记失败原因 + 退避重试,
2898
+ // 由 lastAutoAttemptAt(已落盘)负责跨重启冷却。
2899
+ autoFailStreak += 1;
2900
+ const message = String(err && err.message ? err.message : err);
2901
+ lastAutoError = { at: new Date().toISOString(), message };
2902
+ await saveAutoState().catch(() => { /* 失败路径写盘也可能失败,不影响内存退避 */ });
2903
+ console.error(`[dsh-backup] 自动备份失败(第 ${autoFailStreak} 次,${Math.round(Math.min(AUTO_RETRY_MS * 2 ** (autoFailStreak - 1), autoHours * 3600 * 1000) / 60000)} 分钟后重试): ${message}`);
2725
2904
  }
2726
2905
  }
2727
2906
 
2728
2907
  async function setAuto(h) {
2729
2908
  if (autoDispose) { autoDispose(); autoDispose = null; }
2730
2909
  autoHours = h;
2910
+ // 开启/重设 = 用户要一次基线备份:清掉旧锚点与失败退避,让下次触发落在"现在"
2731
2911
  lastAutoAt = null;
2912
+ lastAutoAttemptAt = null;
2913
+ newestArchiveAt = 0;
2914
+ autoFailStreak = 0;
2915
+ lastAutoError = null;
2732
2916
  if (h > 0) scheduleAuto();
2733
2917
  await saveAutoState();
2734
2918
  }
@@ -2918,9 +3102,17 @@ export function apply(ctx, pluginConfig) {
2918
3102
  const typesMatch = input.match(/--(?:types|only)\s+([\w,]+)/);
2919
3103
  const types = typesMatch ? [...new Set(typesMatch[1].split(',').map((s) => s.trim()).filter(Boolean))] : undefined;
2920
3104
  const r = await doBackup(keep, invocation.signal, types ? { types } : {});
3105
+ // 增长(每周增长仪表盘配套):首次手动备份成功后给一次 star 提示,此后永不再现;
3106
+ // 自动备份的日志不加(噪音),面板有常驻入口。
3107
+ let starNote = '';
3108
+ if (!starHinted) {
3109
+ starNote = `\n\n💡 觉得好用的话,给个 Star 是对我们最大的支持: ${STAR_URL}`;
3110
+ starHinted = true;
3111
+ await saveAutoState().catch(() => { /* 记录失败不影响回执内容 */ });
3112
+ }
2921
3113
  return {
2922
3114
  kind: 'success',
2923
- text: `✅ 备份完成${types ? `(类型: ${types.join(', ')}${r.hasCredentials ? ',含明文凭据·不进 GitHub 同步' : ''})` : ''}\n 文件: ${r.path}\n 校验和: ${r.sha.slice(0, 16)}…\n 轮换: 删除 ${r.stale} 份旧备份(保留 ${r.keep} 份)${quarantineNote(r)}\n ${autoSummary()}`,
3115
+ text: `✅ 备份完成${types ? `(类型: ${types.join(', ')}${r.hasCredentials ? ',含明文凭据·不进 GitHub 同步' : ''})` : ''}\n 文件: ${r.path}\n 校验和: ${r.sha.slice(0, 16)}…\n 轮换: 删除 ${r.stale} 份旧备份(保留 ${r.keep} 份)${quarantineNote(r)}\n ${autoSummary()}${starNote}`,
2924
3116
  };
2925
3117
  } catch (err) {
2926
3118
  return { kind: 'error', text: `备份失败: ${friendlyReason(err)}` };
@@ -3306,8 +3498,22 @@ export function apply(ctx, pluginConfig) {
3306
3498
  sendJson(res, 503, { error: 'settings-unavailable' });
3307
3499
  return;
3308
3500
  }
3501
+ // 0.1.7 起宿主换了 settings 模型:插件的 register(ns, schema) 被移除,
3502
+ // 描述符改由「profile 条目自带的 Config schema」派生——我们自注册的
3503
+ // 命名空间因此不在 describe() 里(0.1.6-alpha.1 及更早仍是旧模型)。
3504
+ // 旧实现在这里直接抛 TypeError(读 undefined.value),面板只看到一句
3505
+ // 原始报错、编辑还静默失败。改为显式降级:如实说明并指向配置出路。
3506
+ const desc = describeOwn(settingsService, NS);
3507
+ if (!desc) {
3508
+ sendJson(res, 200, {
3509
+ unavailable: true,
3510
+ reason: 'settings-namespace-missing',
3511
+ note: '此宿主版本的设置面板由宿主自带的配置表单接管(0.1.7 起插件自注册的设置命名空间不再生效);本插件的选项请写在 profile 的 cordis.yml / cordis.patch.yml 的插件条目 config 下。',
3512
+ });
3513
+ return;
3514
+ }
3309
3515
  if (req.method === 'GET' || req.method === 'HEAD') {
3310
- sendJson(res, 200, describeResponse(describeOwn(settingsService, NS)));
3516
+ sendJson(res, 200, describeResponse(desc));
3311
3517
  return;
3312
3518
  }
3313
3519
  if (req.method !== 'POST') {
@@ -3340,7 +3546,7 @@ export function apply(ctx, pluginConfig) {
3340
3546
  }
3341
3547
  throw err;
3342
3548
  }
3343
- sendJson(res, 200, describeResponse(describeOwn(settingsService, NS)));
3549
+ sendJson(res, 200, describeResponse(describeOwn(settingsService, NS) ?? desc));
3344
3550
  } catch (err) {
3345
3551
  sendJson(res, 400, { error: String(err && err.message ? err.message : err) });
3346
3552
  }
@@ -3348,7 +3554,8 @@ export function apply(ctx, pluginConfig) {
3348
3554
  }), 'dsh-backup: settings route');
3349
3555
  });
3350
3556
 
3351
- // 启动时恢复持久化的定时备份计划(不阻塞插件装配);错过则 delay=0 立即补跑。
3557
+ // 启动时恢复持久化的定时备份计划(不阻塞插件装配);错过则在**启动宽限期之后**
3558
+ // 补跑——绝不在宿主 boot 窗口内启动 GB 级 tar(#103)。
3352
3559
  // 同处做升级前快照:宿主列车变化(升/降级)先拍一份 dsh-pre-upgrade- 安全网
3353
3560
  // 再继续——命中社区"想试新版怕搞坏"的最大恐惧。首见列车只记录不拍。
3354
3561
  void (async () => {
@@ -3357,7 +3564,7 @@ export function apply(ctx, pluginConfig) {
3357
3564
  const h = await loadAutoState();
3358
3565
  if (h > 0 && !autoDispose) {
3359
3566
  autoHours = h;
3360
- scheduleAuto();
3567
+ scheduleAuto({ boot: true });
3361
3568
  }
3362
3569
  // 凭据哨兵:宿主首次读档会把扁平布局的 .credentials.yaml 原子替换成
3363
3570
  // version:1(不可逆——旧宿主读不回 versioned,官方 #6358)。启动即检测,
@@ -3379,14 +3586,21 @@ export function apply(ctx, pluginConfig) {
3379
3586
  }
3380
3587
  if (lastTrain === train) return;
3381
3588
  const oldTrain = lastTrain;
3382
- try {
3383
- const snap = await doBackup(undefined, undefined, { namePrefix: 'dsh-pre-upgrade-' });
3384
- await prunePrefixedSnapshots('dsh-pre-upgrade-', 2, snap.name);
3385
- lastTrain = train;
3386
- await saveAutoState();
3387
- console.log(`[dsh-backup] 宿主列车变化(${oldTrain} → ${train}),已自动拍升级前快照: ${snap.name}${snap.quarantined?.length ? `(${snap.quarantined.length} 个损坏会话文件未入档)` : ''}`);
3388
- } catch (err) {
3389
- console.warn(`[dsh-backup] 升级前快照失败(不影响运行,下次启动会重试): ${String(err && err.message ? err.message : err)}`);
3390
- }
3589
+ // 列车快照同样是 GB 级重活:与自动补跑一样推迟到 boot 窗口之外,并走同一条
3590
+ // 无人值守队列(两者同时到点时串行而非并行)。失败时不更新 lastTrain,
3591
+ // 下次启动重试——语义与旧版一致。
3592
+ ctx.timeout(() => {
3593
+ void enqueueHeavy(async () => {
3594
+ try {
3595
+ const snap = await doBackup(undefined, undefined, { namePrefix: 'dsh-pre-upgrade-' });
3596
+ await prunePrefixedSnapshots('dsh-pre-upgrade-', 2, snap.name);
3597
+ lastTrain = train;
3598
+ await saveAutoState();
3599
+ console.log(`[dsh-backup] 宿主列车变化(${oldTrain} → ${train}),已自动拍升级前快照: ${snap.name}${snap.quarantined?.length ? `(${snap.quarantined.length} 个损坏会话文件未入档)` : ''}`);
3600
+ } catch (err) {
3601
+ console.warn(`[dsh-backup] 升级前快照失败(不影响运行,下次启动会重试): ${String(err && err.message ? err.message : err)}`);
3602
+ }
3603
+ });
3604
+ }, AUTO_BOOT_GRACE_MS);
3391
3605
  })();
3392
3606
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaoyuyu6420/dsh-backup",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "一键备份与恢复 DeepSeek Harness(dsh)的全部用户数据(~/.dsh):定时自动备份、升级快照、会话日志体检修复、迁移预检、宿主起不来也能用的救援通道、凭据脱敏、GitHub 同步跨机迁移,附可视化面板。 One command to back up & restore all of ~/.dsh: /backup, scheduled auto-backup, upgrade snapshots, session doctor & repair, migrate precheck, out-of-process rescue console, credential redaction, GitHub sync.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.24.0",
@@ -53,12 +53,12 @@
53
53
  ],
54
54
  "peerDependencies": {
55
55
  "@deepseek-ai/cordis": "^4.0.1",
56
- "@deepseek-ai/dsh-commands": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1",
57
- "@deepseek-ai/dsh-fs": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1",
58
- "@deepseek-ai/dsh-settings": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1",
59
- "@deepseek-ai/dsh-subprocess": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1",
60
- "@deepseek-ai/dsh-tools": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1",
61
- "@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1",
56
+ "@deepseek-ai/dsh-commands": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1",
57
+ "@deepseek-ai/dsh-fs": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1",
58
+ "@deepseek-ai/dsh-settings": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1",
59
+ "@deepseek-ai/dsh-subprocess": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1",
60
+ "@deepseek-ai/dsh-tools": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1",
61
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1",
62
62
  "@deepseek-ai/schemastery": "^3.18.1"
63
63
  },
64
64
  "devDependencies": {
@@ -69,7 +69,7 @@
69
69
  },
70
70
  "engines": {
71
71
  "node": ">=20",
72
- "dsh": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1"
72
+ "dsh": "^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || ^0.2.0-rc.1"
73
73
  },
74
74
  "license": "MIT",
75
75
  "repository": {