@xiaoyuyu6420/dsh-backup 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,941 @@
1
+ /**
2
+ * dsh-backup — 一键备份与恢复 DeepSeek Harness 用户数据。
3
+ *
4
+ * 备份 ~/.dsh(会话、配置、技能、凭据、插件配置),排除可重装的
5
+ * node_modules,生成 sha256 校验和,自动轮换旧备份;定时自动备份
6
+ * 状态落盘、重启续跑;跨平台(macOS / Linux / Windows)。
7
+ *
8
+ * - `/backup` 立即备份(默认到 ~/Desktop/dsh-backups/)
9
+ * - `/backup list` 列出已有备份(含大小)+ 自动备份状态
10
+ * - `/backup verify [前缀|all]` 校验备份完整性(缺省校验最新一份)
11
+ * - `/backup restore <前缀|latest> [--dry-run]` 恢复(先校验 + 自动快照当前数据)
12
+ * - `/backup auto <N小时>|off|status` 定时自动备份(1~720;<24h 保留 3 份,否则 7 份)
13
+ * - `/backup github status|sync` GitHub 同步状态 / 立即同步
14
+ * - `/backup --keep N` 覆盖本次保留份数
15
+ * - `backup_dsh` 模型工具:mode=backup|list|verify|restore|auto
16
+ * - cordis.yml `config`:destination / keep / exclude / githubRepo(见 README)
17
+ * - Settings「备份」标签页(Web):状态、立即备份、校验、恢复、下载、
18
+ * 自动备份开关与 GitHub 同步,经 `backupPanel` Typert Remote 命名空间访问
19
+ * - Web 下载路由:GET /backup-download/<归档名>(仅 loopback,附件形式)
20
+ *
21
+ * 安全说明:备份包含明文凭据(.credentials.yaml、qq-bridge/config.json),
22
+ * 归档与边车在 POSIX 上 chmod 600 仅本人可读写;请勿将备份目录同步到不受信位置。
23
+ * GitHub 同步前请确认目标仓库是私有仓库。restore 会拒绝恢复含归档根目录之外
24
+ * 条目的文件(tar 路径穿越防护)。
25
+ *
26
+ * 存储说明:插件自有数据(归档、校验和、auto.json)直接经 node:fs 写入
27
+ * (与 dsh-session 持久化、skill-filesystem 同一模式)——ctx.fs 能力是
28
+ * 模型面的沙箱 surface(workspace-write 会拒绝 Desktop),不适用于宿主
29
+ * 插件的自有存储。
30
+ */
31
+ import { createHash } from 'node:crypto';
32
+ import { createReadStream } from 'node:fs';
33
+ import { mkdir, copyFile, readdir, readFile, stat as fsStat, writeFile } from 'node:fs/promises';
34
+ import { dirname, join } from 'node:path';
35
+ import { defineTool } from '@deepseek-ai/dsh-tools';
36
+ import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
37
+
38
+ export const name = 'dsh-backup';
39
+ export const inject = ['subprocess', 'commands', 'timer', 'tools'];
40
+
41
+ /** Windows 下的删除/移动没有 POSIX rm/mv 可用,统一走 cmd.exe 内置命令。 */
42
+ const IS_WIN = process.platform === 'win32';
43
+ /** sha256 回退路径(node:fs 读取 + node:crypto)的内存上限。 */
44
+ const HASH_MAX_BYTES = 256 * 1024 * 1024;
45
+ /** GitHub 单个文件 100MB 上限,留 10MB 余量。 */
46
+ const MAX_GITHUB_BYTES = 90 * 1024 * 1024;
47
+ /** 同步工作树目录名(位于备份目录下)。 */
48
+ const SYNC_DIR = '.github-sync';
49
+ /** Web 下载路由前缀(无尾斜杠:prefix 匹配语义为 pathname.startsWith(prefix + '/'))。 */
50
+ const DOWNLOAD_PREFIX = '/backup-download';
51
+
52
+ /**
53
+ * `backupPanel` Remote 命名空间的调用描述符(src-json codec)。手工经
54
+ * `ctx.typert.register()` 注册——运行时 registry 接受 src-json,免去 zod
55
+ * 依赖;Web 客户端半边(lib/client.js)携带同一套端点的 strict zod 定义。
56
+ */
57
+ function panelDescriptor(method, parameters, cancellation) {
58
+ return Object.freeze({
59
+ id: `dsh-backup#backupPanel/${method}`,
60
+ service: 'backupPanel',
61
+ namespace: 'backupPanel',
62
+ method,
63
+ invocation: Object.freeze({ kind: 'direct' }),
64
+ parameters: Object.freeze(parameters.map((p) => Object.freeze({
65
+ name: p,
66
+ wire: p,
67
+ source: 'json',
68
+ codec: Object.freeze({ mode: 'src-json' }),
69
+ }))),
70
+ ...(cancellation ? { cancellation: Object.freeze({ parameter: 'signal' }) } : {}),
71
+ result: Object.freeze({ mode: 'src-json' }),
72
+ });
73
+ }
74
+
75
+ const PANEL_INVOCATIONS = Object.freeze([
76
+ panelDescriptor('status', []),
77
+ panelDescriptor('backup', ['keep'], true),
78
+ panelDescriptor('verify', ['selector'], true),
79
+ panelDescriptor('restore', ['selector', 'dryRun'], true),
80
+ panelDescriptor('setAuto', ['hours']),
81
+ panelDescriptor('githubStatus', []),
82
+ panelDescriptor('githubSyncNow', [], true),
83
+ panelDescriptor('removeEntry', ['selector'], true),
84
+ panelDescriptor('setGithubRepo', ['repo']),
85
+ ]);
86
+
87
+ /**
88
+ * `backupPanel` 宿主服务:Settings 标签页的 RPC 面。方法签名与描述符的
89
+ * parameters 顺序一致(取消型方法末位是 signal),实现全部委托 ops 闭包,
90
+ * 与 `/backup` 命令、`backup_dsh` 工具共用同一套核心操作。
91
+ */
92
+ class BackupPanelService extends TypertRemoteService {
93
+ /** @param {import('@deepseek-ai/cordis').Context} ctx - 挂载上下文 */
94
+ constructor(ctx, ops) {
95
+ super(ctx, 'backupPanel');
96
+ this.ops = ops;
97
+ }
98
+
99
+ /** 面板快照:目标目录、自动备份状态、备份清单。 */
100
+ status() {
101
+ return this.ops.status();
102
+ }
103
+
104
+ /** 立即备份。 */
105
+ backup(keep, signal) {
106
+ return this.ops.backup(keep, signal);
107
+ }
108
+
109
+ /** 校验(selector=前缀|all|latest)。 */
110
+ verify(selector, signal) {
111
+ return this.ops.verify(selector, signal);
112
+ }
113
+
114
+ /** 恢复(dryRun 仅预览)。 */
115
+ restore(selector, dryRun, signal) {
116
+ return this.ops.restore(selector, dryRun, signal);
117
+ }
118
+
119
+ /** 设置自动备份(0=关闭)。 */
120
+ setAuto(hours) {
121
+ return this.ops.setAuto(hours);
122
+ }
123
+
124
+ /** GitHub 同步状态。 */
125
+ githubStatus() {
126
+ return this.ops.githubStatus();
127
+ }
128
+
129
+ /** 立即推送到 GitHub。 */
130
+ githubSyncNow(signal) {
131
+ return this.ops.githubSyncNow(signal);
132
+ }
133
+
134
+ /** 删除指定备份(归档 + 校验边车)。 */
135
+ removeEntry(selector, signal) {
136
+ return this.ops.removeEntry(selector, signal);
137
+ }
138
+
139
+ /** 设置 GitHub 同步仓库(空串/off 清除,回退配置默认)。 */
140
+ setGithubRepo(repo) {
141
+ return this.ops.setGithubRepo(repo);
142
+ }
143
+ }
144
+
145
+ export function apply(ctx, pluginConfig) {
146
+ // ---------- 工具函数 ----------
147
+ async function spawnRun(argv, cwd, signal) {
148
+ const proc = ctx.subprocess.spawn({
149
+ argv,
150
+ cwd,
151
+ graceMs: 5000,
152
+ signal,
153
+ stdio: {
154
+ stdin: 'ignore',
155
+ stdout: { maxBytes: 8192, spill: { maxBytes: 1 << 20 } },
156
+ stderr: { maxBytes: 8192, spill: { maxBytes: 1 << 20 } },
157
+ },
158
+ });
159
+ const outcome = await proc.done;
160
+ const out = proc.collected.stdout?.readFrom(0).text ?? '';
161
+ const err = proc.collected.stderr?.readFrom(0).text ?? '';
162
+ if (outcome.exitCode !== 0) {
163
+ throw new Error(`命令失败 exit=${outcome.exitCode}: ${err || out}`);
164
+ }
165
+ return { out, err };
166
+ }
167
+
168
+ function paths() {
169
+ const env = ctx.get('launchEnvironment');
170
+ // Windows 上 launchEnvironment 无 HOME 时退回 USERPROFILE。
171
+ // 统一成正斜杠:msys GNU tar 对反斜杠盘符路径的参数转换不可靠,
172
+ // 而正斜杠路径 msys/bsdtar/POSIX tar 与 Node fs 都接受。
173
+ const toFwd = (p) => (p.includes('\\') ? p.split('\\').join('/') : p);
174
+ const homeRaw = env?.get('HOME')?.value || env?.get('USERPROFILE')?.value;
175
+ const home = homeRaw ? toFwd(homeRaw.replace(/\/+$/, '')) : undefined;
176
+ const dshHome = env?.get('DSH_HOME')?.value || (home ? `${home}/.dsh` : undefined);
177
+ if (!home || !dshHome) throw new Error('无法解析 HOME/USERPROFILE 或 DSH_HOME(launchEnvironment 缺失)');
178
+ const raw = typeof pluginConfig?.destination === 'string' && pluginConfig.destination.trim()
179
+ ? pluginConfig.destination.trim()
180
+ : '~/Desktop/dsh-backups';
181
+ const root = raw.startsWith('~') ? `${home}${raw.slice(1)}` : toFwd(raw);
182
+ return { home, dshHome: toFwd(dshHome), root };
183
+ }
184
+
185
+ function defaultKeep() {
186
+ const k = Number(pluginConfig?.keep);
187
+ return Number.isFinite(k) && k > 0 ? Math.floor(k) : 7;
188
+ }
189
+
190
+ function extraExcludes() {
191
+ const list = Array.isArray(pluginConfig?.exclude) ? pluginConfig.exclude : [];
192
+ return list.filter((p) => typeof p === 'string' && p.length > 0).map((p) => `--exclude=${p}`);
193
+ }
194
+
195
+ function stampNow() {
196
+ const now = new Date();
197
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
198
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}${pad(now.getMilliseconds(), 3)}`;
199
+ }
200
+
201
+ /**
202
+ * 删除 dir 下的文件。Windows 走 cmd del:cmd 内置命令不接受正斜杠路径、
203
+ * 经 argv 转义的内嵌引号也会被破坏,因此统一以 dir 为 cwd、用纯文件名调用。
204
+ */
205
+ async function removeFiles(names, dir, signal) {
206
+ if (!names.length) return;
207
+ if (IS_WIN) {
208
+ const cmd = await ctx.subprocess.resolveExecutable('cmd');
209
+ await spawnRun([cmd, '/d', '/c', `del /f /q ${names.join(' ')}`], dir, signal);
210
+ } else {
211
+ const rm = await ctx.subprocess.resolveExecutable('rm');
212
+ await spawnRun([rm, '-f', ...names.map((n) => `${dir}/${n}`)], dir, signal);
213
+ }
214
+ }
215
+
216
+ /** 把 dir 下的文件/目录改名为同目录下的另一个名字(恢复时挪开现有数据)。 */
217
+ async function renameBeside(dir, srcName, dstName, signal) {
218
+ if (IS_WIN) {
219
+ const cmd = await ctx.subprocess.resolveExecutable('cmd');
220
+ await spawnRun([cmd, '/d', '/c', `move /y ${srcName} ${dstName}`], dir, signal);
221
+ } else {
222
+ const mv = await ctx.subprocess.resolveExecutable('mv');
223
+ await spawnRun([mv, `${dir}/${srcName}`, `${dir}/${dstName}`], dir, signal);
224
+ }
225
+ }
226
+
227
+ async function listBackups() {
228
+ const { root } = paths();
229
+ let dirents;
230
+ try {
231
+ dirents = await readdir(root, { withFileTypes: true });
232
+ } catch {
233
+ // 备份目录尚不存在(首次使用)——视为空列表而非错误
234
+ return [];
235
+ }
236
+ const backups = [];
237
+ for (const d of dirents) {
238
+ if (!d.name.startsWith('dsh-') || !d.name.endsWith('.tar.gz')) continue;
239
+ let size;
240
+ try {
241
+ size = (await fsStat(`${root}/${d.name}`)).size;
242
+ } catch {
243
+ size = undefined;
244
+ }
245
+ backups.push({ name: d.name, size });
246
+ }
247
+ return backups.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
248
+ }
249
+
250
+ async function writeOwned(p, content) {
251
+ await mkdir(dirname(p), { recursive: true });
252
+ await writeFile(p, content, 'utf8');
253
+ }
254
+
255
+ // 校验和:POSIX 优先 sha256sum(macOS 回退 shasum)流式计算;
256
+ // 都不可用时(Windows)回退 node:fs 读取 + node:crypto 内存哈希。
257
+ async function sha256File(absPath, home, signal) {
258
+ for (const candidate of [['sha256sum', []], ['shasum', ['-a', '256']]]) {
259
+ try {
260
+ const bin = await ctx.subprocess.resolveExecutable(candidate[0]);
261
+ const r = await spawnRun([bin, ...candidate[1], absPath], home, signal);
262
+ const h = r.out.trim().split(/\s+/)[0];
263
+ if (/^[0-9a-f]{64}$/.test(h)) return h;
264
+ throw new Error(`无法解析 ${candidate[0]} 输出`);
265
+ } catch {
266
+ // 尝试下一个
267
+ }
268
+ }
269
+ const info = await fsStat(absPath);
270
+ if (info.size > HASH_MAX_BYTES) {
271
+ throw new Error(`计算 sha256 失败:文件 ${Math.floor(info.size / 1048576)}MB 超过 ${Math.floor(HASH_MAX_BYTES / 1048576)}MB 回退上限`);
272
+ }
273
+ const bytes = await readFile(absPath);
274
+ return createHash('sha256').update(bytes).digest('hex');
275
+ }
276
+
277
+ async function doBackup(keep, signal) {
278
+ const { home, dshHome, root } = paths();
279
+ const keepN = keep && keep > 0 ? Math.floor(keep) : defaultKeep();
280
+ // 状态文件先写:writeText 会自动创建备份目录,替代 mkdir -p(Windows 无 mkdir.exe)。
281
+ await saveAutoState();
282
+
283
+ const name = `dsh-${stampNow()}.tar.gz`;
284
+ const out = `${root}/${name}`;
285
+ const base = dshHome.split('/').pop();
286
+ const parent = dshHome.slice(0, -(base.length + 1)) || '/';
287
+
288
+ // tar 以备份目录为 cwd、用纯文件名传 -f:Windows 上 GNU tar(msys)会把
289
+ // 含盘符冒号的绝对路径当远程归档("Cannot connect to C"),bsdtar 则两者皆可。
290
+ const tar = await ctx.subprocess.resolveExecutable('tar');
291
+ await spawnRun([tar, '--exclude=*node_modules*', '--exclude=.system', ...extraExcludes(), '-czf', name, '-C', parent, base], root, signal);
292
+
293
+ const shaText = await sha256File(out, home, signal);
294
+ await writeOwned(`${out}.sha256`, `${shaText} ${out}\n`);
295
+
296
+ // 安全:备份含明文凭据(.credentials.yaml / qq-bridge/config.json),收紧为仅本人可读写。
297
+ // Windows 无 chmod,用户目录 ACL 默认私有。
298
+ if (!IS_WIN) {
299
+ const chmod = await ctx.subprocess.resolveExecutable('chmod');
300
+ await spawnRun([chmod, '600', out, `${out}.sha256`], home, signal);
301
+ }
302
+
303
+ // 轮换:只保留最近 keepN 份
304
+ const all = await listBackups();
305
+ const stale = all.slice(keepN).map((b) => b.name);
306
+ if (stale.length) {
307
+ await removeFiles(stale.flatMap((n) => [n, `${n}.sha256`]), root, signal);
308
+ }
309
+
310
+ // GitHub 同步(失败不回滚备份;状态记入 auto.json)
311
+ let sync = null;
312
+ try {
313
+ sync = await githubSync(signal);
314
+ if (sync.pushed) {
315
+ githubState = { ...githubState, lastPush: sync.at, lastError: null };
316
+ await saveAutoState();
317
+ } else if (sync.error) {
318
+ githubState = { ...githubState, lastError: sync.error };
319
+ await saveAutoState();
320
+ }
321
+ } catch (err) {
322
+ const message = String(err && err.message ? err.message : err);
323
+ sync = { error: message };
324
+ githubState = { ...githubState, lastError: message };
325
+ await saveAutoState();
326
+ }
327
+
328
+ return { path: out, sha: shaText, total: all.length, stale: stale.length, keep: keepN, sync };
329
+ }
330
+
331
+ // ---------- GitHub 同步 ----------
332
+ function githubConfig() {
333
+ // 运行时设置(面板/命令,存 auto.json)优先,cordis.yml 的 githubRepo 只是初始默认。
334
+ const raw = githubState && typeof githubState.repo === 'string' && githubState.repo.trim()
335
+ ? githubState.repo.trim()
336
+ : (typeof pluginConfig?.githubRepo === 'string' && pluginConfig.githubRepo.trim()
337
+ ? pluginConfig.githubRepo.trim()
338
+ : '');
339
+ if (!raw) return null;
340
+ const env = ctx.get('launchEnvironment');
341
+ const token = env?.get('DSH_BACKUP_GITHUB_TOKEN')?.value || env?.get('GITHUB_TOKEN')?.value;
342
+ // 本地路径(测试/自托管)或 http(s) 全 URL 直接使用,否则视为 owner/repo。
343
+ const repo = raw.includes('://') || /^[A-Za-z]:[\\/]|^\//.test(raw) ? raw : `https://github.com/${raw}.git`;
344
+ return { repo: repo.split('\\').join('/'), token };
345
+ }
346
+
347
+ /** 校验仓库地址格式(owner/repo、完整 URL 或本地路径),非法返回原因。 */
348
+ function validateRepo(raw) {
349
+ if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(raw)) return null;
350
+ if (raw.includes('://') || /^[A-Za-z]:[\\/]|^\//.test(raw)) return null;
351
+ return '仓库地址应为 owner/repo、完整 URL(http(s)://...)或本地路径';
352
+ }
353
+
354
+ /**
355
+ * 把当前备份集推送到 GitHub 仓库。工作树位于 `<备份目录>/.github-sync`:
356
+ * 归档与边车复制进去,git add -A 同时记录轮换删除,commit 后
357
+ * `push HEAD:main --force-with-lease`。https 远端的 token 只写入工作树内
358
+ * 的 .git-credentials(credential helper),不进进程参数。
359
+ */
360
+ async function githubSync(signal) {
361
+ const { root } = paths();
362
+ const cfg = githubConfig();
363
+ if (!cfg) return { skipped: '未配置 githubRepo(cordis.yml config.githubRepo)' };
364
+ if (cfg.repo.startsWith('https://') && !cfg.token) {
365
+ return { skipped: 'https 远端缺少 token(环境变量 DSH_BACKUP_GITHUB_TOKEN 或 GITHUB_TOKEN)' };
366
+ }
367
+ const syncDir = `${root}/${SYNC_DIR}`;
368
+ const git = await ctx.subprocess.resolveExecutable('git');
369
+ await mkdir(syncDir, { recursive: true });
370
+
371
+ const hasGit = await fsStat(`${syncDir}/.git`).then(() => true, () => false);
372
+ if (!hasGit) {
373
+ try {
374
+ await spawnRun([git, 'init', '-b', 'main'], syncDir, signal);
375
+ } catch {
376
+ await spawnRun([git, 'init'], syncDir, signal);
377
+ await spawnRun([git, 'branch', '-M', 'main'], syncDir, signal);
378
+ }
379
+ }
380
+ const remotes = await spawnRun([git, 'remote', '-v'], syncDir, signal);
381
+ if (!remotes.out.includes('origin')) {
382
+ await spawnRun([git, 'remote', 'add', 'origin', cfg.repo], syncDir, signal);
383
+ }
384
+ if (cfg.token) {
385
+ // 正斜杠路径:msys git 会把反斜杠绝对路径当相对路径解析(怪名化)
386
+ const creds = `${syncDir}/.git-credentials`;
387
+ await writeOwned(creds, `https://x-access-token:${cfg.token}@github.com\n`);
388
+ if (!IS_WIN) {
389
+ const chmod = await ctx.subprocess.resolveExecutable('chmod');
390
+ await spawnRun([chmod, '600', creds], syncDir, signal);
391
+ }
392
+ await spawnRun([git, 'config', 'credential.helper', `store --file=${creds}`], syncDir, signal);
393
+ }
394
+ // token 文件绝不能进仓库:git add -A 会把它当普通文件提交
395
+ await writeOwned(`${syncDir}/.gitignore`, '.git-credentials\n');
396
+
397
+ // 镜像工作树:只保留 .gitignore 与当前备份集(归档+边车),其余一切
398
+ // 文件(旧副本、凭据残留、误入杂物)清理——git add -A 因此只会收录
399
+ // 归档;轮换删除与误入文件一并同步移除。
400
+ const keep = new Set(['.gitignore']);
401
+ for (const b of await listBackups()) {
402
+ keep.add(b.name);
403
+ keep.add(`${b.name}.sha256`);
404
+ }
405
+ let entries = [];
406
+ try {
407
+ entries = await readdir(syncDir, { withFileTypes: true });
408
+ } catch {
409
+ entries = [];
410
+ }
411
+ const stale = entries
412
+ .filter((e) => e.isFile() && !keep.has(e.name))
413
+ .map((e) => e.name);
414
+ if (stale.length) await removeFiles(stale, syncDir, signal);
415
+
416
+ const tooBig = [];
417
+ for (const b of await listBackups()) {
418
+ const size = b.size ?? (await fsStat(`${root}/${b.name}`).then((s) => s.size, () => 0));
419
+ if (size > MAX_GITHUB_BYTES) {
420
+ tooBig.push(b.name);
421
+ continue;
422
+ }
423
+ await copyFile(`${root}/${b.name}`, `${syncDir}/${b.name}`);
424
+ await copyFile(`${root}/${b.name}.sha256`, `${syncDir}/${b.name}.sha256`);
425
+ }
426
+ await spawnRun([git, 'add', '-A'], syncDir, signal);
427
+ const status = await spawnRun([git, 'status', '--porcelain'], syncDir, signal);
428
+ if (!status.out.trim()) return { skipped: '无变更', tooBig };
429
+
430
+ const message = `backup ${new Date().toISOString()}`;
431
+ await spawnRun([git, '-c', 'user.name=dsh-backup', '-c', 'user.email=dsh-backup@users.noreply.github.com', 'commit', '-m', message], syncDir, signal);
432
+ await spawnRun([git, 'push', 'origin', 'HEAD:main', '--force-with-lease'], syncDir, signal);
433
+ return { pushed: true, at: new Date().toISOString(), tooBig };
434
+ }
435
+
436
+ function summarizeSync(s) {
437
+ if (s.error) return `⚠️ GitHub 同步失败: ${s.error}`;
438
+ if (s.pushed) return `✅ GitHub 同步完成: ${s.at}${s.tooBig?.length ? `\n跳过超大文件: ${s.tooBig.join(', ')}` : ''}`;
439
+ return `GitHub 同步: ${s.skipped || '无变更'}${s.tooBig?.length ? `\n跳过超大文件: ${s.tooBig.join(', ')}` : ''}`;
440
+ }
441
+
442
+ // ---------- Web 下载路由(仅 loopback,附件形式) ----------
443
+ async function handleDownload(req, res) {
444
+ try {
445
+ const host = String(req.headers?.host || '');
446
+ if (!/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(host)) {
447
+ res.writeHead(403);
448
+ res.end('forbidden');
449
+ return;
450
+ }
451
+ const pathname = new URL(req.url || '', 'http://x').pathname;
452
+ const name = decodeURIComponent(pathname.slice(DOWNLOAD_PREFIX.length).replace(/^\//, ''));
453
+ if (!/^dsh-[A-Za-z0-9._-]+\.tar\.gz$/.test(name)) {
454
+ res.writeHead(400);
455
+ res.end('bad name');
456
+ return;
457
+ }
458
+ const { root } = paths();
459
+ const abs = `${root}/${name}`;
460
+ await fsStat(abs);
461
+ res.writeHead(200, {
462
+ 'Content-Type': 'application/gzip',
463
+ 'Content-Disposition': `attachment; filename="${name}"`,
464
+ });
465
+ createReadStream(abs).on('error', () => { res.destroy(); }).pipe(res);
466
+ } catch {
467
+ if (!res.headersSent) res.writeHead(404);
468
+ res.end();
469
+ }
470
+ }
471
+
472
+ // ---------- 校验与恢复 ----------
473
+ async function verifyOne(name, home, signal) {
474
+ const { root } = paths();
475
+ const archive = `${root}/${name}`;
476
+ let expected = '';
477
+ try {
478
+ const text = await readFile(`${archive}.sha256`, 'utf8');
479
+ expected = text.trim().split(/\s+/)[0];
480
+ } catch {
481
+ // 边车缺失
482
+ }
483
+ if (!/^[0-9a-f]{64}$/.test(expected)) {
484
+ return { name, ok: false, note: '缺少或无效的 .sha256 边车文件' };
485
+ }
486
+ const actual = await sha256File(archive, home, signal);
487
+ return { name, ok: actual === expected, note: actual === expected ? '完整' : 'sha256 不匹配(归档已损坏)' };
488
+ }
489
+
490
+ async function pickArchive(selector) {
491
+ const all = await listBackups();
492
+ if (!all.length) throw new Error('暂无备份');
493
+ if (!selector || selector === 'latest') return all[0];
494
+ const exact = all.filter((b) => b.name === selector);
495
+ const hits = exact.length ? exact : all.filter((b) => b.name.startsWith(selector));
496
+ if (hits.length === 1) return hits[0];
497
+ if (!hits.length) throw new Error(`没有匹配 "${selector}" 的备份,/backup list 查看`);
498
+ throw new Error(`"${selector}" 匹配多份备份,请加长前缀:\n${hits.slice(0, 5).map((b) => ` ${b.name}`).join('\n')}`);
499
+ }
500
+
501
+ /** 删除指定备份(归档 + 校验边车);选择器经 pickArchive 精确匹配,杜绝路径穿越。 */
502
+ async function removeBackup(selector, signal) {
503
+ const { root } = paths();
504
+ const picked = await pickArchive(selector);
505
+ await removeFiles([picked.name, `${picked.name}.sha256`], root, signal);
506
+ return { ok: true, name: picked.name, summary: `已删除备份: ${picked.name}` };
507
+ }
508
+
509
+ async function restoreArchive(selector, dryRun, signal) {
510
+ const { home, dshHome, root } = paths();
511
+ const picked = await pickArchive(selector);
512
+ const archive = `${root}/${picked.name}`;
513
+
514
+ // 恢复前强制校验:损坏的归档绝不覆盖现有数据。
515
+ const v = await verifyOne(picked.name, home, signal);
516
+ if (!v.ok) throw new Error(`校验未通过(${v.note}),恢复已中止`);
517
+
518
+ const base = dshHome.split('/').pop();
519
+ const parent = dshHome.slice(0, -(base.length + 1)) || '/';
520
+ const tar = await ctx.subprocess.resolveExecutable('tar');
521
+ // 同 doBackup:cwd 为备份目录,-f 用纯文件名,规避 Windows GNU tar 的盘符冒号问题。
522
+ const listed = await spawnRun([tar, '-tzf', picked.name], root, signal);
523
+ const entries = listed.out.split('\n').map((s) => s.trim()).filter(Boolean);
524
+ // tar 路径穿越防护:归档由本插件生成,条目必须都在备份根目录之下。
525
+ const bad = entries.filter((e) => e !== base && !e.startsWith(`${base}/`));
526
+ if (bad.length) {
527
+ throw new Error(`归档包含 "${base}/" 之外的条目(疑似被替换):${bad.slice(0, 3).join(', ')},恢复已中止`);
528
+ }
529
+
530
+ if (dryRun) return { archive, files: entries.length, sample: entries.slice(0, 12), aside: null, snapshotPath: null, dryRun: true };
531
+
532
+ // 恢复前自动快照当前数据,并把当前数据移到旁边(而非合并覆盖)。
533
+ const snapshot = await doBackup(undefined, signal);
534
+ let aside = null;
535
+ let current = false;
536
+ try {
537
+ await fsStat(dshHome);
538
+ current = true;
539
+ } catch {
540
+ current = false;
541
+ }
542
+ if (current) {
543
+ const asideName = `${base}.pre-restore-${stampNow()}`;
544
+ await renameBeside(parent, base, asideName, signal);
545
+ aside = `${parent}/${asideName}`;
546
+ }
547
+ await spawnRun([tar, '-xzf', picked.name, '-C', parent], root, signal);
548
+ return { archive, files: entries.length, sample: [], aside, snapshotPath: snapshot.path, dryRun: false };
549
+ }
550
+
551
+ // ---------- 自动备份与 GitHub 同步状态(落盘,重启续跑) ----------
552
+ let autoDispose = null;
553
+ let autoHours = 0;
554
+ let lastAuto = null;
555
+ let githubState = { repo: null, lastPush: null, lastError: null };
556
+
557
+ async function saveAutoState() {
558
+ const { root } = paths();
559
+ await writeOwned(`${root}/auto.json`, `${JSON.stringify({ hours: autoHours, github: githubState })}\n`);
560
+ }
561
+
562
+ async function loadAutoState() {
563
+ try {
564
+ const { root } = paths();
565
+ const parsed = JSON.parse(await readFile(`${root}/auto.json`, 'utf8'));
566
+ const h = Number(parsed?.hours);
567
+ if (parsed?.github && typeof parsed.github === 'object') {
568
+ githubState = {
569
+ repo: typeof parsed.github.repo === 'string' ? parsed.github.repo : null,
570
+ lastPush: typeof parsed.github.lastPush === 'string' ? parsed.github.lastPush : null,
571
+ lastError: typeof parsed.github.lastError === 'string' ? parsed.github.lastError : null,
572
+ };
573
+ }
574
+ return Number.isFinite(h) && h >= 1 && h <= 720 ? Math.floor(h) : 0;
575
+ } catch {
576
+ return 0;
577
+ }
578
+ }
579
+
580
+ function autoSummary() {
581
+ if (!autoDispose) return '自动备份未开启(/backup auto <N小时> 开启)';
582
+ const next = new Date(Date.now() + autoHours * 3600 * 1000).toLocaleString();
583
+ return `自动备份已开启:每 ${autoHours} 小时一次(已持久化,重启续跑),下次约 ${next}${lastAuto ? `;上次自动备份: ${lastAuto}` : ''}`;
584
+ }
585
+
586
+ async function runAutoBackup() {
587
+ try {
588
+ const r = await doBackup(autoHours >= 24 ? 7 : 3);
589
+ lastAuto = r.path.split('/').pop();
590
+ console.log(`[dsh-backup] 自动备份完成: ${r.path} (sha ${r.sha.slice(0, 12)}…)`);
591
+ } catch (err) {
592
+ console.error(`[dsh-backup] 自动备份失败: ${String(err && err.message ? err.message : err)}`);
593
+ }
594
+ }
595
+
596
+ async function setAuto(h) {
597
+ if (autoDispose) { autoDispose(); autoDispose = null; }
598
+ autoHours = h;
599
+ if (h > 0) autoDispose = ctx.interval(runAutoBackup, h * 3600 * 1000);
600
+ await saveAutoState();
601
+ }
602
+
603
+ // ---------- /backup 命令 ----------
604
+ ctx.commands.register({
605
+ name: 'backup',
606
+ description: '备份/恢复 DSH 数据;子命令: list | verify [前缀|all] | restore <前缀|latest> [--dry-run] | auto [N小时|off] | [--keep N]',
607
+ handler: async (invocation) => {
608
+ const input = invocation.rawInput.trim();
609
+ try {
610
+ const parts = input.split(/\s+/).filter(Boolean);
611
+ const head = parts[0] || '';
612
+ const { home } = paths();
613
+
614
+ if (head === 'list') {
615
+ const all = await listBackups();
616
+ const total = all.reduce((s, b) => s + (b.size || 0), 0);
617
+ const lines = all.map((b) => ` ${b.name}${b.size !== undefined ? ` ${(b.size / 1048576).toFixed(1)}MB` : ''}`);
618
+ const text = all.length
619
+ ? `已有备份 (${all.length} 份,共 ${(total / 1048576).toFixed(1)}MB):\n${lines.join('\n')}\n\n${autoSummary()}`
620
+ : `暂无备份。输入 /backup 执行首次备份。\n\n${autoSummary()}`;
621
+ return { kind: 'success', text };
622
+ }
623
+
624
+ if (head === 'verify') {
625
+ const sel = parts[1] || 'latest';
626
+ const names = sel === 'all'
627
+ ? (await listBackups()).map((b) => b.name)
628
+ : [(await pickArchive(sel)).name];
629
+ const results = [];
630
+ for (const n of names) results.push(await verifyOne(n, home, invocation.signal));
631
+ const bad = results.filter((r) => !r.ok);
632
+ const text = results.map((r) => `${r.ok ? '✅' : '❌'} ${r.name} — ${r.note}`).join('\n');
633
+ return bad.length
634
+ ? { kind: 'error', text: `${text}\n${bad.length} 份校验失败;损坏归档可删除后重新 /backup。` }
635
+ : { kind: 'success', text: text || '暂无备份可校验。' };
636
+ }
637
+
638
+ if (head === 'restore') {
639
+ const dryRun = parts.includes('--dry-run');
640
+ const sel = parts.slice(1).find((t) => !t.startsWith('--')) || 'latest';
641
+ const r = await restoreArchive(sel, dryRun, invocation.signal);
642
+ if (r.dryRun) {
643
+ return { kind: 'success', text: `📦 恢复预览(未写入)\n 归档: ${r.archive}\n 条目: ${r.files} 项\n${r.sample.map((s) => ` ${s}`).join('\n')}` };
644
+ }
645
+ return {
646
+ kind: 'success',
647
+ text: `✅ 恢复完成\n 来源: ${r.archive}(${r.files} 项)\n 恢复前快照: ${r.snapshotPath}\n${r.aside ? ` 旧数据已移至: ${r.aside}\n` : ''} 请重启 dsh 使恢复的会话与配置生效。`,
648
+ };
649
+ }
650
+
651
+ if (head === 'github') {
652
+ const arg = parts[1] || 'status';
653
+ if (arg === 'repo') {
654
+ const value = parts.slice(2).join(' ');
655
+ if (!value || value === 'off') {
656
+ githubState = { ...githubState, repo: null, lastError: null };
657
+ await saveAutoState();
658
+ return { kind: 'success', text: 'GitHub 同步仓库已清除(回退到 cordis.yml 配置,若有)。' };
659
+ }
660
+ const invalid = validateRepo(value);
661
+ if (invalid) return { kind: 'error', text: invalid };
662
+ githubState = { ...githubState, repo: value, lastError: null };
663
+ await saveAutoState();
664
+ return { kind: 'success', text: `GitHub 同步仓库已设为: ${value}\n${autoSummary()}` };
665
+ }
666
+ if (arg === 'sync') {
667
+ const s = await githubSync(invocation.signal);
668
+ if (s.pushed) {
669
+ githubState = { ...githubState, lastPush: s.at, lastError: null };
670
+ await saveAutoState();
671
+ } else if (s.error) {
672
+ githubState = { ...githubState, lastError: s.error };
673
+ await saveAutoState();
674
+ }
675
+ return { kind: 'success', text: summarizeSync(s) };
676
+ }
677
+ if (arg === 'status') {
678
+ const cfg = githubConfig();
679
+ const text = cfg
680
+ ? `GitHub 同步: ${cfg.repo}\n token: ${cfg.token ? '已配置' : '未配置(https 远端需要)'}\n ${githubState.lastPush ? `上次推送: ${githubState.lastPush}` : '尚未推送过'}\n ${githubState.lastError ? `上次错误: ${githubState.lastError}` : ''}\n /backup github repo <地址> 可修改`
681
+ : 'GitHub 同步未配置:/backup github repo <owner/repo> 设置,或在 cordis.yml 的 config.githubRepo 配置。';
682
+ return { kind: 'success', text };
683
+ }
684
+ return { kind: 'error', text: '用法: /backup github status|sync|repo <地址|off>' };
685
+ }
686
+
687
+ if (head === 'delete' || head === 'rm') {
688
+ const sel = parts[1];
689
+ if (!sel) return { kind: 'error', text: '用法: /backup delete <归档名前缀|latest>' };
690
+ const r = await removeBackup(sel, invocation.signal);
691
+ return { kind: 'success', text: `🗑️ ${r.summary}` };
692
+ }
693
+
694
+ if (head === 'auto') {
695
+ const arg = parts[1];
696
+ if (!arg || arg === 'status') return { kind: 'success', text: autoSummary() };
697
+ if (arg === 'off' || arg === '0') {
698
+ await setAuto(0);
699
+ return { kind: 'success', text: '自动备份已关闭。' };
700
+ }
701
+ const h = Number(arg);
702
+ if (!Number.isFinite(h) || h < 1 || h > 720) {
703
+ return { kind: 'error', text: '小时数需为 1~720 之间的数字(如 /backup auto 12)' };
704
+ }
705
+ await setAuto(h);
706
+ return { kind: 'success', text: `✅ 自动备份已开启:每 ${h} 小时执行一次(保留 ${h >= 24 ? 7 : 3} 份,已持久化)。\n${autoSummary()}` };
707
+ }
708
+
709
+ let keep;
710
+ const m = input.match(/--keep\s+(\d+)/);
711
+ if (m) keep = Number(m[1]);
712
+ const r = await doBackup(keep, invocation.signal);
713
+ return {
714
+ kind: 'success',
715
+ text: `✅ 备份完成\n 文件: ${r.path}\n 校验和: ${r.sha.slice(0, 16)}…\n 轮换: 删除 ${r.stale} 份旧备份(保留 ${r.keep} 份)\n ${autoSummary()}`,
716
+ };
717
+ } catch (err) {
718
+ return { kind: 'error', text: `备份失败: ${String(err && err.message ? err.message : err)}` };
719
+ }
720
+ },
721
+ });
722
+
723
+ // ---------- backup_dsh 模型工具 ----------
724
+ ctx.tools.register(defineTool({
725
+ name: 'backup_dsh',
726
+ description: '备份、校验或恢复 DSH 用户数据(~/.dsh 的会话、配置、技能、凭据)。mode=backup 立即备份(keep 指定保留份数);mode=list 列出备份;mode=verify 校验完整性(selector=前缀或 all,缺省最新一份);mode=restore 恢复(selector=前缀或 latest,dryRun 仅预览;恢复前自动校验并快照当前数据);mode=auto 设置定时备份(hours 间隔小时数,0=关闭,缺省查询)。注意:备份包含明文凭据,请勿将备份目录同步到不受信位置。',
727
+ parameters: {
728
+ mode: { type: 'string', required: true, enum: ['backup', 'list', 'verify', 'restore', 'auto'], description: 'backup=执行备份,list=列出备份,verify=校验完整性,restore=恢复,auto=定时备份' },
729
+ keep: { type: 'number', description: '保留的备份份数(mode=backup,默认 7)' },
730
+ hours: { type: 'number', description: '定时备份间隔小时数(mode=auto;0=关闭;缺省=查询状态)' },
731
+ selector: { type: 'string', description: '备份选择器(mode=verify/restore):归档名前缀、latest 或 all' },
732
+ dryRun: { type: 'boolean', description: 'mode=restore 时仅预览恢复内容,不写入' },
733
+ },
734
+ output: {
735
+ schema: { type: 'object', additionalProperties: true },
736
+ render: (_args, value) => [{ type: 'text', text: String(value.summary) }],
737
+ },
738
+ execute: async (args, exec) => {
739
+ const mode = args && args.mode ? args.mode : 'backup';
740
+ const signal = exec && exec.signal ? exec.signal : undefined;
741
+ const selector = args && typeof args.selector === 'string' && args.selector ? args.selector : undefined;
742
+ try {
743
+ if (mode === 'list') {
744
+ const all = await listBackups();
745
+ return { ok: true, summary: `已有 ${all.length} 份备份:\n${all.map((b) => ` ${b.name}`).join('\n') || '(无)'}\n\n${autoSummary()}` };
746
+ }
747
+ if (mode === 'verify') {
748
+ const { home } = paths();
749
+ const sel = selector || 'latest';
750
+ const names = sel === 'all' ? (await listBackups()).map((b) => b.name) : [(await pickArchive(sel)).name];
751
+ const lines = [];
752
+ let bad = 0;
753
+ for (const n of names) {
754
+ const r = await verifyOne(n, home, signal);
755
+ if (!r.ok) bad += 1;
756
+ lines.push(`${r.ok ? '✅' : '❌'} ${r.name} — ${r.note}`);
757
+ }
758
+ return { ok: bad === 0, summary: lines.join('\n') || '暂无备份可校验。' };
759
+ }
760
+ if (mode === 'restore') {
761
+ const r = await restoreArchive(selector || 'latest', Boolean(args && args.dryRun), signal);
762
+ if (r.dryRun) return { ok: true, summary: `恢复预览(未写入): ${r.archive}\n条目 ${r.files} 项,含:\n${r.sample.map((s) => ` ${s}`).join('\n')}` };
763
+ return {
764
+ ok: true,
765
+ path: r.archive,
766
+ summary: `恢复完成: ${r.archive}(${r.files} 项)\n恢复前快照: ${r.snapshotPath}\n${r.aside ? `旧数据已移至: ${r.aside}\n` : ''}请重启 dsh 生效。`,
767
+ };
768
+ }
769
+ if (mode === 'auto') {
770
+ const h = args && args.hours !== undefined ? args.hours : null;
771
+ if (h === null) return { ok: true, summary: autoSummary() };
772
+ if (h === 0) {
773
+ await setAuto(0);
774
+ return { ok: true, summary: '自动备份已关闭。' };
775
+ }
776
+ if (!Number.isFinite(h) || h < 1 || h > 720) return { ok: false, summary: 'hours 需为 1~720' };
777
+ await setAuto(h);
778
+ return { ok: true, summary: `自动备份已开启:每 ${h} 小时一次(已持久化)。\n${autoSummary()}` };
779
+ }
780
+ const r = await doBackup(args && args.keep ? args.keep : undefined, signal);
781
+ return { ok: true, path: r.path, sha: r.sha, summary: `备份完成: ${r.path}\nsha256: ${r.sha}\n轮换删除 ${r.stale} 份(保留 ${r.keep} 份)` };
782
+ } catch (err) {
783
+ return { ok: false, summary: `操作失败: ${String(err && err.message ? err.message : err)}` };
784
+ }
785
+ },
786
+ }));
787
+
788
+ // ---------- Settings 面板(Web):backupPanel Remote ----------
789
+ const panelOps = {
790
+ status: async () => {
791
+ const all = await listBackups();
792
+ const { root, dshHome } = paths();
793
+ return {
794
+ destination: root,
795
+ dshHome,
796
+ keepDefault: defaultKeep(),
797
+ autoHours,
798
+ lastAuto,
799
+ backups: all.map((b) => ({ name: b.name, size: typeof b.size === 'number' ? b.size : null })),
800
+ };
801
+ },
802
+ backup: async (keep, signal) => {
803
+ const r = await doBackup(keep, signal);
804
+ return {
805
+ ok: true,
806
+ summary: `备份完成: ${r.path}\nsha256: ${r.sha}\n轮换删除 ${r.stale} 份(保留 ${r.keep} 份)`,
807
+ path: r.path,
808
+ sha: r.sha,
809
+ stale: r.stale,
810
+ keep: r.keep,
811
+ };
812
+ },
813
+ verify: async (selector, signal) => {
814
+ const { home } = paths();
815
+ const sel = selector || 'latest';
816
+ const names = sel === 'all'
817
+ ? (await listBackups()).map((b) => b.name)
818
+ : [(await pickArchive(sel)).name];
819
+ const results = [];
820
+ let bad = 0;
821
+ for (const n of names) {
822
+ const r = await verifyOne(n, home, signal);
823
+ if (!r.ok) bad += 1;
824
+ results.push({ name: r.name, ok: r.ok, note: r.note });
825
+ }
826
+ return { ok: bad === 0, summary: results.map((r) => `${r.ok ? '✅' : '❌'} ${r.name} — ${r.note}`).join('\n') || '暂无备份可校验。', results };
827
+ },
828
+ restore: async (selector, dryRun, signal) => {
829
+ try {
830
+ const r = await restoreArchive(selector || 'latest', Boolean(dryRun), signal);
831
+ if (r.dryRun) {
832
+ return { ok: true, dryRun: true, archive: r.archive, files: r.files, sample: r.sample, summary: `归档 ${r.files} 项` };
833
+ }
834
+ return {
835
+ ok: true,
836
+ dryRun: false,
837
+ archive: r.archive,
838
+ files: r.files,
839
+ aside: r.aside,
840
+ snapshotPath: r.snapshotPath,
841
+ summary: `恢复完成(${r.files} 项)${r.aside ? `\n旧数据已移至 ${r.aside}` : ''}\n请重启 dsh 生效。`,
842
+ };
843
+ } catch (err) {
844
+ return { ok: false, dryRun: Boolean(dryRun), summary: String(err && err.message ? err.message : err) };
845
+ }
846
+ },
847
+ setAuto: async (hours) => {
848
+ if (hours === 0) {
849
+ await setAuto(0);
850
+ return { ok: true, hours: 0, summary: '自动备份已关闭。' };
851
+ }
852
+ if (!Number.isFinite(hours) || hours < 1 || hours > 720) {
853
+ return { ok: false, summary: 'hours 需为 1~720(0=关闭)' };
854
+ }
855
+ await setAuto(Math.floor(hours));
856
+ return { ok: true, hours: Math.floor(hours), summary: autoSummary() };
857
+ },
858
+ githubStatus: async () => {
859
+ const cfg = githubConfig();
860
+ const { root } = paths();
861
+ return {
862
+ // repoRaw 是用户原始输入(运行时值优先,否则 cordis.yml 默认),供面板编辑框回填
863
+ repoRaw: githubState.repo ?? (typeof pluginConfig?.githubRepo === 'string' ? pluginConfig.githubRepo : null),
864
+ repo: cfg ? cfg.repo : null,
865
+ tokenSet: Boolean(cfg?.token),
866
+ syncDir: `${root}/${SYNC_DIR}`,
867
+ lastPush: githubState.lastPush,
868
+ lastError: githubState.lastError,
869
+ };
870
+ },
871
+ githubSyncNow: async (signal) => {
872
+ try {
873
+ const s = await githubSync(signal);
874
+ if (s.pushed) {
875
+ githubState = { repo: githubState.repo ?? null, lastPush: s.at, lastError: null };
876
+ await saveAutoState();
877
+ } else if (s.error) {
878
+ githubState = { ...githubState, lastError: s.error };
879
+ await saveAutoState();
880
+ }
881
+ return { ok: true, summary: summarizeSync(s), pushed: Boolean(s.pushed), tooBig: s.tooBig ?? [] };
882
+ } catch (err) {
883
+ const message = String(err && err.message ? err.message : err);
884
+ githubState = { ...githubState, lastError: message };
885
+ await saveAutoState();
886
+ return { ok: false, summary: message, pushed: false, tooBig: [] };
887
+ }
888
+ },
889
+ removeEntry: async (selector, signal) => {
890
+ try {
891
+ const r = await removeBackup(selector || 'latest', signal);
892
+ return { ok: true, summary: r.summary };
893
+ } catch (err) {
894
+ return { ok: false, summary: String(err && err.message ? err.message : err) };
895
+ }
896
+ },
897
+ setGithubRepo: async (repo) => {
898
+ const raw = typeof repo === 'string' ? repo.trim() : '';
899
+ if (!raw) {
900
+ githubState = { ...githubState, repo: null, lastError: null };
901
+ await saveAutoState();
902
+ return { ok: true, repo: null, summary: 'GitHub 同步仓库已清除(回退到 cordis.yml 配置,若有)。' };
903
+ }
904
+ const invalid = validateRepo(raw);
905
+ if (invalid) return { ok: false, summary: invalid };
906
+ githubState = { ...githubState, repo: raw, lastError: null };
907
+ await saveAutoState();
908
+ return { ok: true, repo: raw, summary: `GitHub 同步仓库已设为: ${raw}` };
909
+ },
910
+ };
911
+
912
+ // 仅在装配了 Typert registry 的 profile(Web)里挂载面板服务;其余 profile 安静跳过。
913
+ ctx.inject(['typert'], (scope) => {
914
+ scope.effect(() => scope.typert.register({
915
+ package: 'dsh-backup',
916
+ face: 'host',
917
+ schemas: [],
918
+ invocations: PANEL_INVOCATIONS,
919
+ model: Object.freeze({ services: Object.freeze([]), events: Object.freeze([]), objects: Object.freeze([]) }),
920
+ }), 'dsh-backup: typert invocations');
921
+ scope.plugin(BackupPanelService, panelOps);
922
+ });
923
+
924
+ // Web 下载路由(仅 loopback;归档含明文凭据,绝不对非本机来源开放)。
925
+ ctx.inject(['webServer'], (scope) => {
926
+ scope.effect(() => scope.webServer.register({
927
+ kind: 'prefix',
928
+ path: DOWNLOAD_PREFIX,
929
+ handler: (req, res) => { void handleDownload(req, res); },
930
+ }), 'dsh-backup: download route');
931
+ });
932
+
933
+ // 启动时恢复持久化的定时备份计划(不阻塞插件装配)。
934
+ void (async () => {
935
+ const h = await loadAutoState();
936
+ if (h > 0 && !autoDispose) {
937
+ autoHours = h;
938
+ autoDispose = ctx.interval(runAutoBackup, h * 3600 * 1000);
939
+ }
940
+ })();
941
+ }