@xiaoyuyu6420/dsh-backup 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -42,6 +42,8 @@ import { hostname } from 'node:os';
42
42
  import { dirname, join } from 'node:path';
43
43
  import { defineTool } from '@deepseek-ai/dsh-tools';
44
44
  import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
45
+ import { settingsNamespace, SettingsConflictError } from '@deepseek-ai/dsh-settings';
46
+ import z from '@deepseek-ai/schemastery';
45
47
 
46
48
  export const name = 'dsh-backup';
47
49
  export const inject = ['subprocess', 'commands', 'timer', 'tools'];
@@ -67,6 +69,97 @@ const SENSITIVE_DEFAULTS = ['.credentials.yaml', '.env', 'qq-bridge/config.json'
67
69
  /** 备份目录下保存明文敏感文件的子目录(随备份刷新为最新一份的副本)。 */
68
70
  const VAULT_DIR = 'vault';
69
71
 
72
+ /** Settings 命名空间:经 `ctx.settings` 提供(持久化到 $DSH_HOME/settings.yaml)。 */
73
+ const NS = settingsNamespace('dsh-backup');
74
+
75
+ /**
76
+ * 设置默认值。SCHEMA 的 .default() 与 resolveBase 的初始值都引用这里,
77
+ * 避免同一字面量散落多处导致迁移逻辑漏改。
78
+ */
79
+ const DEFAULTS = Object.freeze({
80
+ destination: '~/Desktop/dsh-backups',
81
+ exclude: [],
82
+ redact: [...SENSITIVE_DEFAULTS],
83
+ githubRepo: '',
84
+ });
85
+
86
+ /**
87
+ * 解析 settings 命名空间的 schema,同时定义默认值。
88
+ * Base 层:pluginConfig(cordis.patch.yml,插件级或 profile 级)。
89
+ * User 层:Settings 面板运行时编辑,持久化到 settings.yaml。
90
+ */
91
+ const SCHEMA = z.object({
92
+ destination: z.string().default(DEFAULTS.destination),
93
+ keep: z.number().min(0).default(0),
94
+ exclude: z.array(z.string()).default(DEFAULTS.exclude),
95
+ redact: z.union([z.array(z.string()), 'off']).default(DEFAULTS.redact),
96
+ githubRepo: z.string().default(DEFAULTS.githubRepo),
97
+ });
98
+
99
+ const SETTINGS_FIELDS = ['destination', 'keep', 'exclude', 'redact', 'githubRepo'];
100
+
101
+ /** 校验并归一化单个字段到 `out`;非法值忽略。 */
102
+ function normalizeField(out, field, value) {
103
+ if (field === 'destination') {
104
+ if (typeof value === 'string' && value.trim()) out.destination = value.trim();
105
+ } else if (field === 'keep') {
106
+ const n = Number(value);
107
+ if (Number.isFinite(n) && n >= 0) out.keep = Math.floor(n);
108
+ } else if (field === 'exclude') {
109
+ if (Array.isArray(value)) out.exclude = value.filter((v) => typeof v === 'string');
110
+ } else if (field === 'redact') {
111
+ // 'none' 为旧配置兼容:旧版允许 'none' 关闭脱敏,新版 SCHEMA 只接受数组或 'off'
112
+ if (value === 'off' || value === false || value === 'none') out.redact = 'off';
113
+ else if (Array.isArray(value)) out.redact = value.filter((v) => typeof v === 'string');
114
+ } else if (field === 'githubRepo') {
115
+ if (typeof value === 'string') out.githubRepo = value;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 将 loader 提供的 config(cordis.patch.yml)合并到 DEFAULTS 上,逐字段归一化。
121
+ * SCHEMA 的 .default() 与这里共用 DEFAULTS,避免迁移逻辑漏改。
122
+ */
123
+ function resolveBase(config) {
124
+ const out = { ...DEFAULTS, redact: [...DEFAULTS.redact] };
125
+ if (config && typeof config === 'object') {
126
+ for (const field of SETTINGS_FIELDS) normalizeField(out, field, config[field]);
127
+ }
128
+ return out;
129
+ }
130
+
131
+ /** 校验 partial 中提供的字段;非法字段名返回给调用方,用于 400 响应。 */
132
+ function validatePartial(partial) {
133
+ const invalid = [];
134
+ for (const field of SETTINGS_FIELDS) {
135
+ if (partial[field] === undefined) continue;
136
+ const probe = {};
137
+ normalizeField(probe, field, partial[field]);
138
+ if (probe[field] === undefined) invalid.push(field);
139
+ }
140
+ return invalid;
141
+ }
142
+
143
+ /** 判断是否为 settings seam 的乐观并发冲突错误。 */
144
+ function isSettingsConflict(err) {
145
+ return err instanceof SettingsConflictError || (err && err.code === 'SETTINGS_CONFLICT');
146
+ }
147
+
148
+ /** 从 settings 服务中取出本命名空间的描述符(脱敏视图)。 */
149
+ function describeOwn(settings, ns) {
150
+ const list = settings.describe({ redactSecrets: true });
151
+ return list.find((descriptor) => descriptor.ns === ns);
152
+ }
153
+
154
+ /** 将命名空间描述符整理为客户端使用的线格式。 */
155
+ function describeResponse(descriptor) {
156
+ return {
157
+ ...descriptor.value,
158
+ revision: descriptor.revision,
159
+ hasOverrides: !!(descriptor.user && Object.keys(descriptor.user).length > 0),
160
+ };
161
+ }
162
+
70
163
  /**
71
164
  * `backupPanel` Remote 命名空间的调用描述符(src-json codec)。手工经
72
165
  * `ctx.typert.register()` 注册——运行时 registry 接受 src-json,免去 zod
@@ -167,6 +260,68 @@ class BackupPanelService extends TypertRemoteService {
167
260
  }
168
261
 
169
262
  export function apply(ctx, pluginConfig) {
263
+ // ---------- Settings seam ----------
264
+ // settings 服务可选:无该服务的 profile(非 Web)回退到 pluginConfig base 层。
265
+ let settingsService;
266
+ ctx.inject(['settings'], (sctx) => {
267
+ settingsService = sctx.settings;
268
+ settingsService.register(NS, SCHEMA, { base: resolveBase(pluginConfig) });
269
+ // detach 清理:服务中途 dispose 时置空,resolvedConfig() 自动降级到 pluginConfig
270
+ return () => { settingsService = undefined; };
271
+ });
272
+
273
+ // 旧配置兼容警告:redact: 'none' 在新版映射为 'off'
274
+ if (pluginConfig && pluginConfig.redact === 'none') {
275
+ console.warn("[dsh-backup] config.redact: 'none' 已废弃,请改用 'off'(行为相同,'none' 将在未来版本移除)");
276
+ }
277
+
278
+ /**
279
+ * 解析后的生效配置:schema 默认值 ← base(cordis.patch.yml)←
280
+ * 用户层(settings.yaml)。settings 服务不可用时回退到 pluginConfig。
281
+ */
282
+ function resolvedConfig() {
283
+ if (settingsService) {
284
+ const descriptor = describeOwn(settingsService, NS);
285
+ if (descriptor && descriptor.value) return descriptor.value;
286
+ }
287
+ return resolveBase(pluginConfig);
288
+ }
289
+
290
+ /** 读取并解析 JSON 请求体(上限 1 MiB)。 */
291
+ function readJsonBody(req) {
292
+ return new Promise((resolve, reject) => {
293
+ const chunks = [];
294
+ let size = 0;
295
+ req.on('data', (chunk) => {
296
+ size += chunk.length;
297
+ if (size > 1024 * 1024) {
298
+ reject(new Error('request body too large'));
299
+ req.once('error', () => {});
300
+ req.destroy();
301
+ return;
302
+ }
303
+ chunks.push(chunk);
304
+ });
305
+ req.on('end', () => {
306
+ const text = Buffer.concat(chunks).toString('utf8');
307
+ if (text.trim() === '') return resolve({});
308
+ try {
309
+ resolve(JSON.parse(text));
310
+ } catch {
311
+ reject(new Error('invalid JSON body'));
312
+ }
313
+ });
314
+ req.on('error', reject);
315
+ });
316
+ }
317
+
318
+ /** 写入 JSON 响应。 */
319
+ function sendJson(res, status, data) {
320
+ const body = JSON.stringify(data);
321
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
322
+ res.end(body);
323
+ }
324
+
170
325
  // ---------- 工具函数 ----------
171
326
  /**
172
327
  * 读取 subprocess 收集的输出流。dsh-subprocess 的收集契约:输出超过
@@ -228,28 +383,32 @@ export function apply(ctx, pluginConfig) {
228
383
  const home = homeRaw ? toFwd(homeRaw.replace(/\/+$/, '')) : undefined;
229
384
  const dshHome = env?.get('DSH_HOME')?.value || (home ? `${home}/.dsh` : undefined);
230
385
  if (!home || !dshHome) throw new Error('无法解析 HOME/USERPROFILE 或 DSH_HOME(launchEnvironment 缺失)');
231
- const raw = typeof pluginConfig?.destination === 'string' && pluginConfig.destination.trim()
232
- ? pluginConfig.destination.trim()
233
- : '~/Desktop/dsh-backups';
386
+ const cfg = resolvedConfig();
387
+ const raw = typeof cfg.destination === 'string' && cfg.destination.trim()
388
+ ? cfg.destination.trim()
389
+ : DEFAULTS.destination;
234
390
  const root = raw.startsWith('~') ? `${home}${raw.slice(1)}` : toFwd(raw);
235
391
  return { home, dshHome: toFwd(dshHome), root };
236
392
  }
237
393
 
238
394
  function defaultKeep() {
239
- const k = Number(pluginConfig?.keep);
395
+ const cfg = resolvedConfig();
396
+ const k = Number(cfg.keep);
240
397
  return Number.isFinite(k) && k > 0 ? Math.floor(k) : 7;
241
398
  }
242
399
 
243
400
  function extraExcludes() {
244
- const list = Array.isArray(pluginConfig?.exclude) ? pluginConfig.exclude : [];
401
+ const cfg = resolvedConfig();
402
+ const list = Array.isArray(cfg.exclude) ? cfg.exclude : [];
245
403
  return list.filter((p) => typeof p === 'string' && p.length > 0).map((p) => `--exclude=${p}`);
246
404
  }
247
405
 
248
406
  /** 生效的脱敏清单:默认集 + config.redact 追加(false/'off' 整体关闭)。 */
249
407
  function sensitivePaths() {
250
- const cfg = pluginConfig?.redact;
251
- if (cfg === false || cfg === 'off' || cfg === 'none') return [];
252
- const extra = Array.isArray(cfg) ? cfg.filter((p) => typeof p === 'string' && p.trim().length) : [];
408
+ const cfg = resolvedConfig();
409
+ const redactCfg = cfg.redact;
410
+ if (redactCfg === false || redactCfg === 'off' || redactCfg === 'none') return [];
411
+ const extra = Array.isArray(redactCfg) ? redactCfg.filter((p) => typeof p === 'string' && p.trim().length) : [];
253
412
  return [...new Set([...SENSITIVE_DEFAULTS, ...extra.map((p) => p.trim())])];
254
413
  }
255
414
 
@@ -510,11 +669,12 @@ export function apply(ctx, pluginConfig) {
510
669
 
511
670
  // ---------- GitHub 同步 ----------
512
671
  function githubConfig() {
513
- // 运行时设置(面板/命令,存 auto.json)优先,cordis.yml 的 githubRepo 只是初始默认。
672
+ // 运行时设置(面板/命令,存 auto.json)优先,settings.yaml → cordis.yml 的 githubRepo 是初始默认。
673
+ const cfg = resolvedConfig();
514
674
  const raw = githubState && typeof githubState.repo === 'string' && githubState.repo.trim()
515
675
  ? githubState.repo.trim()
516
- : (typeof pluginConfig?.githubRepo === 'string' && pluginConfig.githubRepo.trim()
517
- ? pluginConfig.githubRepo.trim()
676
+ : (typeof cfg.githubRepo === 'string' && cfg.githubRepo.trim()
677
+ ? cfg.githubRepo.trim()
518
678
  : '');
519
679
  if (!raw) return null;
520
680
  const env = ctx.get('launchEnvironment');
@@ -1042,7 +1202,8 @@ export function apply(ctx, pluginConfig) {
1042
1202
 
1043
1203
  /** 自动备份的保留份数:config.keep 支配(未配置时 <24h 3 份、否则 7 份)。 */
1044
1204
  function autoKeep() {
1045
- const k = Number(pluginConfig?.keep);
1205
+ const cfg = resolvedConfig();
1206
+ const k = Number(cfg.keep);
1046
1207
  return Number.isFinite(k) && k > 0 ? Math.floor(k) : (autoHours >= 24 ? 7 : 3);
1047
1208
  }
1048
1209
 
@@ -1399,10 +1560,11 @@ export function apply(ctx, pluginConfig) {
1399
1560
  githubStatus: async () => {
1400
1561
  const cfg = githubConfig();
1401
1562
  const { root } = paths();
1563
+ const resolved = resolvedConfig();
1402
1564
  return {
1403
- // repoRaw 是用户原始输入(运行时值优先,否则 cordis.yml 默认),供面板编辑框回填
1565
+ // repoRaw 是用户原始输入(运行时值优先,否则 settings.yaml → cordis.yml 默认),供面板编辑框回填
1404
1566
  // 两路都经 stripUserinfo:避免 cordis.yml config.githubRepo 内嵌 token 时经面板泄露
1405
- repoRaw: githubState.repo ?? (typeof pluginConfig?.githubRepo === 'string' ? stripUserinfo(pluginConfig.githubRepo) : null),
1567
+ repoRaw: githubState.repo ?? (typeof resolved.githubRepo === 'string' ? stripUserinfo(resolved.githubRepo) : null),
1406
1568
  repo: cfg ? cfg.repo : null,
1407
1569
  tokenSet: Boolean(cfg?.token),
1408
1570
  syncDir: `${root}/${SYNC_DIR}`,
@@ -1454,13 +1616,33 @@ export function apply(ctx, pluginConfig) {
1454
1616
  if (!raw) {
1455
1617
  githubState = { ...githubState, repo: null, lastError: null };
1456
1618
  await saveAutoState();
1457
- return { ok: true, repo: null, summary: 'GitHub 同步仓库已清除(回退到 cordis.yml 配置,若有)。' };
1619
+ // 清除用户层的 githubRepo,让 base 层(cordis.patch.yml)透传回来
1620
+ if (settingsService) {
1621
+ try {
1622
+ // 读取当前用户层,移除 githubRepo 字段后 replace 回去
1623
+ const desc = describeOwn(settingsService, NS);
1624
+ const userLayer = desc?.user ? { ...desc.user } : {};
1625
+ delete userLayer.githubRepo;
1626
+ await settingsService.replace(NS, userLayer);
1627
+ } catch (err) {
1628
+ return { ok: false, summary: String(err && err.message ? err.message : err) };
1629
+ }
1630
+ }
1631
+ return { ok: true, repo: null, summary: 'GitHub 同步仓库已清除用户层覆盖(回退到 cordis.yml 配置,若有)。' };
1458
1632
  }
1459
1633
  const invalid = validateRepo(raw);
1460
1634
  if (invalid) return { ok: false, summary: invalid };
1461
1635
  const clean = stripUserinfo(raw);
1462
1636
  githubState = { ...githubState, repo: clean, lastError: null };
1463
1637
  await saveAutoState();
1638
+ // 同步写入 settings.yaml
1639
+ if (settingsService) {
1640
+ try {
1641
+ await settingsService.update(NS, { githubRepo: clean });
1642
+ } catch (err) {
1643
+ return { ok: false, summary: String(err && err.message ? err.message : err) };
1644
+ }
1645
+ }
1464
1646
  return { ok: true, repo: clean, summary: `GitHub 同步仓库已设为: ${clean}` };
1465
1647
  },
1466
1648
  };
@@ -1486,6 +1668,67 @@ export function apply(ctx, pluginConfig) {
1486
1668
  }), 'dsh-backup: download route');
1487
1669
  });
1488
1670
 
1671
+ // Settings 薄代理:经 ctx.settings 官方 seam 的 GET/POST 接口。
1672
+ // 用户编辑持久化到 $DSH_HOME/settings.yaml;base 层(cordis.patch.yml)
1673
+ // 本插件从不写入。
1674
+ ctx.inject(['webServer'], (scope) => {
1675
+ scope.effect(() => scope.webServer.register({
1676
+ kind: 'exact',
1677
+ path: '/dsh-backup/settings',
1678
+ handler: async (req, res) => {
1679
+ try {
1680
+ // 与 /backup-download 同一 loopback 校验:settings 路由更敏感,绝不对非本机暴露
1681
+ const host = String(req.headers?.host || '');
1682
+ if (!/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(host)) {
1683
+ sendJson(res, 403, { error: 'forbidden' });
1684
+ return;
1685
+ }
1686
+ if (!settingsService) {
1687
+ sendJson(res, 503, { error: 'settings-unavailable' });
1688
+ return;
1689
+ }
1690
+ if (req.method === 'GET' || req.method === 'HEAD') {
1691
+ sendJson(res, 200, describeResponse(describeOwn(settingsService, NS)));
1692
+ return;
1693
+ }
1694
+ if (req.method !== 'POST') {
1695
+ sendJson(res, 405, { error: 'method-not-allowed' });
1696
+ return;
1697
+ }
1698
+ const body = await readJsonBody(req);
1699
+ const expectedRevision = typeof body.revision === 'number' ? body.revision : undefined;
1700
+ try {
1701
+ if (body.reset === true) {
1702
+ // 重置:清除用户层,值回退到 base 层
1703
+ await settingsService.replace(NS, {}, expectedRevision);
1704
+ } else {
1705
+ const invalid = validatePartial(body);
1706
+ if (invalid.length > 0) {
1707
+ sendJson(res, 400, { error: 'invalid-field', fields: invalid });
1708
+ return;
1709
+ }
1710
+ // 保存:仅合并提供的字段到用户层
1711
+ const patch = {};
1712
+ for (const field of SETTINGS_FIELDS) {
1713
+ if (body[field] !== undefined) normalizeField(patch, field, body[field]);
1714
+ }
1715
+ await settingsService.update(NS, patch, expectedRevision);
1716
+ }
1717
+ } catch (err) {
1718
+ if (isSettingsConflict(err)) {
1719
+ sendJson(res, 409, { error: 'settings-conflict', revision: err.actual });
1720
+ return;
1721
+ }
1722
+ throw err;
1723
+ }
1724
+ sendJson(res, 200, describeResponse(describeOwn(settingsService, NS)));
1725
+ } catch (err) {
1726
+ sendJson(res, 400, { error: String(err && err.message ? err.message : err) });
1727
+ }
1728
+ },
1729
+ }), 'dsh-backup: settings route');
1730
+ });
1731
+
1489
1732
  // 启动时恢复持久化的定时备份计划(不阻塞插件装配);错过则 delay=0 立即补跑。
1490
1733
  void (async () => {
1491
1734
  const h = await loadAutoState();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaoyuyu6420/dsh-backup",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Backup, restore, download and GitHub-sync DeepSeek Harness user data (~/.dsh): /backup, scheduled auto-backup that survives restarts, sha256 checksums, integrity verify, rotation, credential redaction with a local vault (plaintext never leaves the machine), cross-machine restore preflight with github pull, and a visual Settings panel. Cross-platform (macOS/Linux/Windows). 一键备份与恢复 DSH 数据:定时自动备份、完整性校验、凭据默认脱敏(明文只存本机 vault)、跨机恢复预检与 github pull 拉取,附 Settings 可视面板。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -46,9 +46,11 @@
46
46
  "@deepseek-ai/cordis": "^4.0.1",
47
47
  "@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
48
48
  "@deepseek-ai/dsh-fs": "^0.1.1-rc.2",
49
+ "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
49
50
  "@deepseek-ai/dsh-subprocess": "^0.1.1-rc.2",
50
51
  "@deepseek-ai/dsh-tools": "^0.1.1-rc.2",
51
- "@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2"
52
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2",
53
+ "@deepseek-ai/schemastery": "^3.18.1"
52
54
  },
53
55
  "devDependencies": {
54
56
  "esbuild": "^0.25.0",