@xiaoyuyu6420/dsh-backup 0.13.1 → 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/README.en.md +6 -1
- package/README.md +8 -1
- package/lib/client.js +2 -2
- package/lib/index.js +292 -78
- package/package.json +8 -8
- package/rescue/rescue.mjs +10 -6
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
|
|
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
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
|
848
|
-
} catch {
|
|
849
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1852
|
-
|
|
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
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
2702
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
2722
|
-
//
|
|
2723
|
-
|
|
2724
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
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.
|
|
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 || ^0.1.6-alpha.1 || ^0.1.7-alpha.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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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 || ^0.1.6-alpha.1 || ^0.1.7-alpha.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": {
|