dsh-config-manager 0.2.3 → 0.3.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.
Files changed (132) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/lib/adapters/self.d.ts +44 -4
  4. package/lib/adapters/self.js +63 -3
  5. package/lib/client.d.ts +11 -6
  6. package/lib/client.js +1256 -1185
  7. package/lib/core/analyzer.d.ts +3 -0
  8. package/lib/core/analyzer.js +32 -56
  9. package/lib/core/backup.d.ts +36 -0
  10. package/lib/core/backup.js +125 -3
  11. package/lib/core/import-defaults.d.ts +13 -0
  12. package/lib/core/import-defaults.js +14 -0
  13. package/lib/core/importer.d.ts +10 -1
  14. package/lib/core/importer.js +7 -1
  15. package/lib/core/messages.d.ts +4 -0
  16. package/lib/core/messages.js +8 -0
  17. package/lib/core/model-tools.js +19 -3
  18. package/lib/index.d.ts +1 -1
  19. package/lib/index.js +12 -22
  20. package/lib/routes/import.js +3 -1
  21. package/lib/routes/recovery.js +226 -2
  22. package/lib/routes/sync.js +26 -7
  23. package/lib/security/integrity.d.ts +24 -0
  24. package/lib/security/integrity.js +30 -0
  25. package/lib/security/unverified-file-blocks.d.ts +73 -0
  26. package/lib/security/unverified-file-blocks.js +151 -0
  27. package/lib/sync/autosync-config.d.ts +8 -4
  28. package/lib/sync/autosync-config.js +2 -2
  29. package/lib/sync/autosync-scheduler.d.ts +61 -3
  30. package/lib/sync/autosync-scheduler.js +87 -10
  31. package/lib/sync/config-issue-reporting.d.ts +32 -0
  32. package/lib/sync/config-issue-reporting.js +63 -0
  33. package/lib/sync/gc-skip.d.ts +42 -0
  34. package/lib/sync/gc-skip.js +57 -0
  35. package/lib/sync/git/git-transport.d.ts +56 -2
  36. package/lib/sync/git/git-transport.js +344 -73
  37. package/lib/sync/merge-preview-text.d.ts +11 -0
  38. package/lib/sync/merge-preview-text.js +38 -0
  39. package/lib/sync/s3/s3-transport.d.ts +8 -1
  40. package/lib/sync/s3/s3-transport.js +19 -5
  41. package/lib/sync/session-tombstones.d.ts +53 -0
  42. package/lib/sync/session-tombstones.js +52 -0
  43. package/lib/sync/sync-config.d.ts +55 -5
  44. package/lib/sync/sync-config.js +188 -15
  45. package/lib/sync/sync-engine.d.ts +21 -7
  46. package/lib/sync/sync-engine.js +63 -85
  47. package/lib/sync/webdav/webdav-transport.d.ts +11 -1
  48. package/lib/sync/webdav/webdav-transport.js +22 -6
  49. package/lib/ui/i18n.js +56 -36
  50. package/lib/ui/import-wizard.d.ts +1 -1
  51. package/lib/ui/import-wizard.js +2 -1
  52. package/lib/ui/overview-view.d.ts +6 -2
  53. package/lib/ui/overview-view.js +8 -1
  54. package/lib/ui/report.js +2 -1
  55. package/lib/utils/git-fast-forward.d.ts +7 -2
  56. package/lib/utils/git-fast-forward.js +8 -3
  57. package/package.json +2 -2
  58. package/src/adapters/self.test.ts +126 -85
  59. package/src/adapters/self.ts +73 -6
  60. package/src/client/ConfigManagerSection.tsx +0 -3
  61. package/src/client/common/Icon.tsx +3 -0
  62. package/src/client/common/SectionComposition.tsx +6 -1
  63. package/src/client/common/info-hint-guard.test.ts +7 -5
  64. package/src/client/config-manager.module.css +254 -13
  65. package/src/client/environment/EnvironmentPanel.tsx +57 -47
  66. package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
  67. package/src/client/export/ExportView.tsx +3 -1
  68. package/src/client/home/HomePanel.tsx +25 -5
  69. package/src/client/import/ImportWizardView.tsx +1 -3
  70. package/src/client/import/import-wizard-steps.tsx +1 -17
  71. package/src/client/library/ArtifactRow.tsx +13 -2
  72. package/src/client/library/LibraryPanel.tsx +12 -2
  73. package/src/client/locales.ts +53 -34
  74. package/src/client/lucide-icons.d.ts +2 -0
  75. package/src/client/market/MarketPanel.tsx +12 -11
  76. package/src/client/market/market-locales.ts +16 -16
  77. package/src/client/recovery/RollbackCard.tsx +4 -2
  78. package/src/client/recovery/recovery-locales.test.ts +4 -2
  79. package/src/client/recovery/recovery-locales.ts +23 -23
  80. package/src/client/sync/AutosyncCard.tsx +36 -10
  81. package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
  82. package/src/client/sync/SyncPanel.tsx +32 -33
  83. package/src/client/sync/SyncProbeRow.tsx +23 -6
  84. package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
  85. package/src/client/sync/sync-locales.ts +24 -24
  86. package/src/core/analyzer.ts +37 -56
  87. package/src/core/backup.test.ts +139 -1
  88. package/src/core/backup.ts +145 -4
  89. package/src/core/import-defaults.ts +13 -0
  90. package/src/core/importer.ts +12 -2
  91. package/src/core/integrity-extra-entries.test.ts +205 -0
  92. package/src/core/messages.test.ts +3 -1
  93. package/src/core/messages.ts +8 -0
  94. package/src/core/model-tools.test.ts +33 -0
  95. package/src/core/model-tools.ts +20 -4
  96. package/src/index.ts +12 -21
  97. package/src/routes/import.ts +4 -1
  98. package/src/routes/recovery.test.ts +198 -1
  99. package/src/routes/recovery.ts +194 -1
  100. package/src/routes/sync.ts +27 -8
  101. package/src/security/integrity.ts +37 -0
  102. package/src/security/unverified-file-blocks.ts +192 -0
  103. package/src/sync/autosync-config.ts +9 -5
  104. package/src/sync/autosync-scheduler.test.ts +133 -6
  105. package/src/sync/autosync-scheduler.ts +118 -14
  106. package/src/sync/config-issue-reporting.test.ts +148 -0
  107. package/src/sync/config-issue-reporting.ts +75 -0
  108. package/src/sync/gc-skip.test.ts +66 -0
  109. package/src/sync/gc-skip.ts +64 -0
  110. package/src/sync/git/git-probe.test.ts +6 -1
  111. package/src/sync/git/git-transport.test.ts +351 -14
  112. package/src/sync/git/git-transport.ts +343 -79
  113. package/src/sync/merge-preview-text.ts +37 -0
  114. package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
  115. package/src/sync/s3/s3-transport.ts +23 -4
  116. package/src/sync/session-tombstones.ts +90 -0
  117. package/src/sync/sync-config.test.ts +170 -1
  118. package/src/sync/sync-config.ts +184 -14
  119. package/src/sync/sync-engine.test.ts +238 -0
  120. package/src/sync/sync-engine.ts +79 -83
  121. package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
  122. package/src/sync/webdav/webdav-transport.ts +26 -5
  123. package/src/ui/disk-usage-view.test.ts +2 -1
  124. package/src/ui/i18n.test.ts +47 -0
  125. package/src/ui/i18n.ts +58 -38
  126. package/src/ui/import-wizard.test.ts +18 -1
  127. package/src/ui/import-wizard.ts +3 -2
  128. package/src/ui/overview-view.test.ts +9 -0
  129. package/src/ui/overview-view.ts +9 -2
  130. package/src/ui/report.test.ts +4 -3
  131. package/src/ui/report.ts +2 -1
  132. package/src/utils/git-fast-forward.ts +8 -3
@@ -101,14 +101,18 @@ export type OverviewHealthKind = 'ok' | 'warn' | 'error';
101
101
  export interface OverviewHealth {
102
102
  kind: OverviewHealthKind;
103
103
  /** UI 渲染 key(`overview.health.*`)。 */
104
- textKey: 'health.ok' | 'health.noBackup' | 'health.scheduleFailed' | 'health.recovery';
104
+ textKey: 'health.ok' | 'health.noBackup' | 'health.scheduleFailed' | 'health.autoBackupOff' | 'health.recovery';
105
105
  }
106
106
  /**
107
107
  * 配置健康判定(保守优先级):
108
108
  * 1. SAFE MODE 激活(有未解决恢复事项)→ error(最高优先,需用户处理);
109
109
  * 2. 已加载且备份与快照均为空 → warn「尚无任何备份」(新装机引导场景);
110
110
  * 3. 定时备份开启但上次运行失败 → warn;
111
- * 4. 其余 → ok。
111
+ * 4. 手上已有备份、但定时备份是关的 → warn「自动备份已关闭」(2026-11-01 视觉 pass 新增:
112
+ * 此前关闭档不判定,于是「All healthy」与「Auto backup Off / Backups 1」同屏并列,
113
+ * 状态条给出的结论与它脚下的指标自相矛盾 —— 一页只在回答「现在安全吗」,
114
+ * 在没有任何后续保护时报健康等于放弃这个结论);
115
+ * 5. 其余 → ok。
112
116
  * 数据未加载(null)不降级健康状态(避免加载闪红)。
113
117
  */
114
118
  export declare function overviewHealth(inputs: OverviewInputs): OverviewHealth;
@@ -149,7 +149,11 @@ export function buildOverviewMetrics(inputs) {
149
149
  * 1. SAFE MODE 激活(有未解决恢复事项)→ error(最高优先,需用户处理);
150
150
  * 2. 已加载且备份与快照均为空 → warn「尚无任何备份」(新装机引导场景);
151
151
  * 3. 定时备份开启但上次运行失败 → warn;
152
- * 4. 其余 → ok。
152
+ * 4. 手上已有备份、但定时备份是关的 → warn「自动备份已关闭」(2026-11-01 视觉 pass 新增:
153
+ * 此前关闭档不判定,于是「All healthy」与「Auto backup Off / Backups 1」同屏并列,
154
+ * 状态条给出的结论与它脚下的指标自相矛盾 —— 一页只在回答「现在安全吗」,
155
+ * 在没有任何后续保护时报健康等于放弃这个结论);
156
+ * 5. 其余 → ok。
153
157
  * 数据未加载(null)不降级健康状态(避免加载闪红)。
154
158
  */
155
159
  export function overviewHealth(inputs) {
@@ -163,6 +167,9 @@ export function overviewHealth(inputs) {
163
167
  if (inputs.schedule !== null && inputs.schedule.enabled && inputs.schedule.lastRunStatus === 'failed') {
164
168
  return { kind: 'warn', textKey: 'health.scheduleFailed' };
165
169
  }
170
+ if (inputs.schedule !== null && !inputs.schedule.enabled) {
171
+ return { kind: 'warn', textKey: 'health.autoBackupOff' };
172
+ }
166
173
  return { kind: 'ok', textKey: 'health.ok' };
167
174
  }
168
175
  /* ---------------- 最近活动 ---------------- */
package/lib/ui/report.js CHANGED
@@ -31,7 +31,8 @@ export function exportCountsText(counts, t) {
31
31
  if (typeof value !== 'number' || !Number.isFinite(value))
32
32
  continue;
33
33
  const unitKey = COUNT_UNIT_KEY[key];
34
- parts.push(unitKey === undefined ? String(value) + ' ' + key : String(value) + ' ' + t(unitKey));
34
+ // 单位词按数量选单复数(en 的 `{n, plural, file|files}`);中文单位不受影响
35
+ parts.push(unitKey === undefined ? String(value) + ' ' + key : String(value) + ' ' + t(unitKey, { n: value }));
35
36
  }
36
37
  // 分隔符也走字典:英文用半角逗号(全角标点在英文界面同样是缺陷)
37
38
  return parts.join(t('report.unitSeparator'));
@@ -8,8 +8,13 @@
8
8
  * 显式 refspec fetch + 显式远端跟踪分支 merge 完全不读 FETCH_HEAD(同样 5 并发实测全成功),
9
9
  * 语义与 ff-only pull 一致:能快进就快进,不能快进就报「历史分叉」。
10
10
  */
11
- /** `git fetch` 参数:只更新 origin/<branch> 这个远端跟踪引用,不写 FETCH_HEAD */
12
- export declare function fastForwardFetchArgs(branch: string): string[];
11
+ /**
12
+ * `git fetch` 参数:只更新 origin/<branch> 这个远端跟踪引用,不写 FETCH_HEAD。
13
+ *
14
+ * `flags` 是给调用方补的拉取姿态(如 `--filter=blob:none`/`--no-tags`),必须排在 `origin` 之前:
15
+ * git 只认「子命令后、URL 前」的选项,放到 refspec 之后会被当成 refspec 解析。
16
+ */
17
+ export declare function fastForwardFetchArgs(branch: string, flags?: readonly string[]): string[];
13
18
  /** `git merge` 参数:快进到显式远端跟踪分支(不依赖 upstream 配置,也不读 FETCH_HEAD) */
14
19
  export declare function fastForwardMergeArgs(branch: string): string[];
15
20
  /** 远端还没有同名分支(全新空仓库 / 分支被改名):调用方按「无 upstream」同姿态静默跳过 */
@@ -8,9 +8,14 @@
8
8
  * 显式 refspec fetch + 显式远端跟踪分支 merge 完全不读 FETCH_HEAD(同样 5 并发实测全成功),
9
9
  * 语义与 ff-only pull 一致:能快进就快进,不能快进就报「历史分叉」。
10
10
  */
11
- /** `git fetch` 参数:只更新 origin/<branch> 这个远端跟踪引用,不写 FETCH_HEAD */
12
- export function fastForwardFetchArgs(branch) {
13
- return ['fetch', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`];
11
+ /**
12
+ * `git fetch` 参数:只更新 origin/<branch> 这个远端跟踪引用,不写 FETCH_HEAD。
13
+ *
14
+ * `flags` 是给调用方补的拉取姿态(如 `--filter=blob:none`/`--no-tags`),必须排在 `origin` 之前:
15
+ * git 只认「子命令后、URL 前」的选项,放到 refspec 之后会被当成 refspec 解析。
16
+ */
17
+ export function fastForwardFetchArgs(branch, flags = []) {
18
+ return ['fetch', ...flags, 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`];
14
19
  }
15
20
  /** `git merge` 参数:快进到显式远端跟踪分支(不依赖 upstream 配置,也不读 FETCH_HEAD) */
16
21
  export function fastForwardMergeArgs(branch) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-config-manager",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "description": "Backup, restore, export, import, migrate and sync your DeepSeek Harness (DSH) configuration: settings, plugins, MCP servers, skills, agent presets and workspaces. One-click migration to another machine. DSH 配置一键备份、恢复、导出、导入、迁移与同步插件。",
6
6
  "icon": "./icon.svg",
@@ -203,7 +203,7 @@
203
203
  "react": "^18.3.1",
204
204
  "react-dom": "^18.3.1",
205
205
  "schemastery": "^3.18.0",
206
- "tsdown": "0.23.1",
206
+ "tsdown": "0.23.4",
207
207
  "typescript": "^7.0.2"
208
208
  }
209
209
  }
@@ -3,19 +3,27 @@
3
3
  * 白名单收集(存在才收、白名单外不收、不递归)、Create/Skip/Conflict 分析、
4
4
  * applyItem 写回 $DSH_HOME/dsh-config-manager/<rel>、默认包含 + portable 语义、
5
5
  * createAdapters 的 selfDir 挂载行为(缺省挂载 / '' 不挂)。
6
+ *
7
+ * SEC-01(P0):sync/sync-config.json 与 sync/sync-autosync.json 都含「同步目标地址」,
8
+ * 允许导入写盘 = 备份包可以改本机同步目标,下一次同步把 token 送进包控制的主机。
9
+ * 因此两者退出裸写白名单:既不导出,也不允许导入写盘(包内出现 → 报告级 warning,绝不静默)。
6
10
  */
7
11
  import test from 'node:test';
8
12
  import assert from 'node:assert/strict';
9
13
  import { createAdapters } from './index.ts';
10
- import { SelfAdapter, SELF_CONFIG_FILES } from './self.ts';
14
+ import { SelfAdapter, SELF_CONFIG_FILES, SELF_IMPORT_BLOCKED_RELS, SELF_IMPORT_BLOCKED_CODE } from './self.ts';
11
15
  import { hashSection } from '../sync/sync-state.ts';
16
+ import { sha256Hex } from '../utils/hashing.ts';
12
17
  import { makeContext, makeImportContext } from './test-helpers.ts';
13
18
 
14
19
  test('self: 白名单收集(存在才收,子目录路径保留,白名单外不收集)', async () => {
15
20
  const src = makeContext('win32', 'C:\\Users\\alice');
16
- await src.fs.writeFile('dsh-config-manager/sync/sync-config.json', Buffer.from('{"transport":"git","git":{"repoUrl":"https://x"},"webdav":{}}', 'utf8'));
17
21
  await src.fs.writeFile('dsh-config-manager/sync/sync-selection.json', Buffer.from('{"schemaVersion":1,"mode":"default"}', 'utf8'));
18
22
  await src.fs.writeFile('dsh-config-manager/sync/ui-prefs.json', Buffer.from('{"schemaVersion":1,"lastSyncChannel":"webdav"}', 'utf8'));
23
+ await src.fs.writeFile('dsh-config-manager/market/market-config.json', Buffer.from('{"schemaVersion":1,"sources":[]}', 'utf8'));
24
+ // SEC-01:含同步目标地址的两个文件不再进载荷(否则备份包可借它改本机同步目标)
25
+ await src.fs.writeFile('dsh-config-manager/sync/sync-config.json', Buffer.from('{"transport":"git","git":{"repoUrl":"https://x"}}', 'utf8'));
26
+ await src.fs.writeFile('dsh-config-manager/sync/sync-autosync.json', Buffer.from('{"schemaVersion":2}', 'utf8'));
19
27
  // 白名单外:历史/缓存/快照/临时产物不得收集
20
28
  await src.fs.writeFile('dsh-config-manager/sync/sync-history.json', Buffer.from('{"schemaVersion":1}', 'utf8'));
21
29
  await src.fs.writeFile('dsh-config-manager/market/cache/index.json', Buffer.from('{}', 'utf8'));
@@ -26,14 +34,16 @@ test('self: 白名单收集(存在才收,子目录路径保留,白名单
26
34
  const out = await adapter.export(src, { includeSecrets: false });
27
35
  const rels = out.data.files.map((f) => f.relativePath).sort();
28
36
  assert.deepEqual(rels, [
29
- 'sync/sync-config.json',
37
+ 'market/market-config.json',
30
38
  'sync/sync-selection.json',
31
39
  'sync/ui-prefs.json',
32
40
  ]);
33
41
  assert.equal(out.counts.files, 3);
34
42
  assert.equal(out.warnings.length, 0, '存在文件时不告警');
35
- // 白名单常量齐全(sync-autosync / market-config 未创建时自然跳过)
36
- assert.ok(SELF_CONFIG_FILES.includes('sync/sync-autosync.json'));
43
+ // 白名单常量:SEC-01 移出两个同步目标文件,其余照旧
44
+ assert.ok(!SELF_CONFIG_FILES.includes('sync/sync-config.json'), 'SEC-01:sync-config.json 不得在裸写白名单里');
45
+ assert.ok(!SELF_CONFIG_FILES.includes('sync/sync-autosync.json'), 'SEC-01:sync-autosync.json 不得在裸写白名单里');
46
+ assert.ok(SELF_CONFIG_FILES.includes('sync/backup-schedule.json'), '调度配置(无同步目标地址)仍在白名单');
37
47
  assert.ok(SELF_CONFIG_FILES.includes('market/market-config.json'));
38
48
  });
39
49
 
@@ -53,7 +63,7 @@ test('self: 全部缺失 → 空分区 + dirEmpty 警告', async () => {
53
63
 
54
64
  test('self: 导入往返(Create → 写回 $DSH_HOME/dsh-config-manager/<rel>;幂等 Skip;不同 Conflict)', async () => {
55
65
  const src = makeContext('win32', 'C:\\Users\\alice');
56
- await src.fs.writeFile('dsh-config-manager/sync/sync-config.json', Buffer.from('{"transport":"git"}', 'utf8'));
66
+ await src.fs.writeFile('dsh-config-manager/sync/ui-prefs.json', Buffer.from('{"schemaVersion":1}', 'utf8'));
57
67
  const adapter = new SelfAdapter();
58
68
  const out = await adapter.export(src, { includeSecrets: false });
59
69
 
@@ -65,9 +75,9 @@ test('self: 导入往返(Create → 写回 $DSH_HOME/dsh-config-manager/<rel>
65
75
  const r = await adapter.applyItem(items[0]!, makeImportContext(dst, sections));
66
76
  assert.equal(r.ok, true);
67
77
  assert.equal(
68
- Buffer.from(await dst.fs.readFile('dsh-config-manager/sync/sync-config.json')).toString(),
69
- '{"transport":"git"}',
70
- '导入写回 $DSH_HOME/dsh-config-manager/sync/sync-config.json(基准目录 + 相对路径)',
78
+ Buffer.from(await dst.fs.readFile('dsh-config-manager/sync/ui-prefs.json')).toString(),
79
+ '{"schemaVersion":1}',
80
+ '导入写回 $DSH_HOME/dsh-config-manager/<rel>(基准目录 + 相对路径)',
71
81
  );
72
82
 
73
83
  // 幂等:一致 → Skip
@@ -75,11 +85,52 @@ test('self: 导入往返(Create → 写回 $DSH_HOME/dsh-config-manager/<rel>
75
85
  assert.equal(items[0]?.kind, 'Skip');
76
86
 
77
87
  // 内容不同 → Conflict
78
- await dst.fs.writeFile('dsh-config-manager/sync/sync-config.json', Buffer.from('{"transport":"webdav"}', 'utf8'));
88
+ await dst.fs.writeFile('dsh-config-manager/sync/ui-prefs.json', Buffer.from('{"schemaVersion":1,"lastSyncChannel":"s3"}', 'utf8'));
79
89
  items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
80
90
  assert.equal(items[0]?.kind, 'Conflict');
81
91
  });
82
92
 
93
+ /* ---------------- SEC-01:导入包不得写同步目标文件 ---------------- */
94
+
95
+ test('SEC-01:包内含 sync-config.json / sync-autosync.json → 报告级 warning + 绝不写盘', async () => {
96
+ const adapter = new SelfAdapter();
97
+ const evil = '{"schemaVersion":3,"transport":"git","git":{"repoUrl":"https://attacker.example/collect.git"}}';
98
+ const section = {
99
+ version: 1 as const,
100
+ files: SELF_IMPORT_BLOCKED_RELS.map((relativePath) => ({
101
+ relativePath,
102
+ data: Buffer.from(evil, 'utf8'),
103
+ contentHash: sha256Hex(Buffer.from(evil, 'utf8')),
104
+ })),
105
+ };
106
+ const dst = makeContext('linux', '/home/bob');
107
+ const localConfig = '{"schemaVersion":3,"transport":"git","git":{"repoUrl":"https://github.com/u/r.git"},"webdav":{}}';
108
+ await dst.fs.writeFile('dsh-config-manager/sync/sync-config.json', Buffer.from(localConfig, 'utf8'));
109
+ const ctx = makeImportContext(dst, new Map([['self', section]]));
110
+
111
+ const items = await adapter.analyzeImport(section, ctx);
112
+ assert.equal(items.length, SELF_IMPORT_BLOCKED_RELS.length);
113
+ for (const item of items) {
114
+ assert.equal(item.kind, 'Warning', '不写盘项必须是 Warning(引擎跳过:不进快照范围、不调 applyItem)');
115
+ assert.equal(item.severity, 'warning');
116
+ assert.ok((item.description ?? '').length > 0, '必须带可展示文案,不得静默');
117
+ assert.ok(String(item.detail ?? '').startsWith(SELF_IMPORT_BLOCKED_CODE), '必须带稳定机器码: ' + String(item.detail));
118
+ assert.ok(String(item.detail ?? '').includes(item.id.slice('self:'.length)), '机器码里带上具体 rel');
119
+ }
120
+
121
+ // 纵深防御:即使绕过引擎直接 apply,也一字节都不写
122
+ for (const item of items) {
123
+ const r = await adapter.applyItem(item, makeImportContext(dst, new Map([['self', section]])));
124
+ assert.equal(r.ok, false, '被挡下的项不得报告成功写入');
125
+ }
126
+ assert.equal(
127
+ Buffer.from(await dst.fs.readFile('dsh-config-manager/sync/sync-config.json')).toString(),
128
+ localConfig,
129
+ '本机既有的同步目标不得被包内地址覆盖',
130
+ );
131
+ assert.equal(await dst.fs.exists('dsh-config-manager/sync/sync-autosync.json'), false, '不得凭空创建同步调度配置');
132
+ });
133
+
83
134
  test('createAdapters: selfDir 缺省挂载 dsh-config-manager;空串不挂载', () => {
84
135
  const withSelf = createAdapters({ selfDir: undefined });
85
136
  assert.ok(withSelf.some((a) => a.id === 'self'), '缺省挂载 self adapter');
@@ -97,10 +148,10 @@ test('createAdapters: selfDir 缺省挂载 dsh-config-manager;空串不挂载'
97
148
 
98
149
  test('self: 预览只列白名单 —— 快照/同步工作副本等内部数据不得进入选择器(与导出逐项相等)', async () => {
99
150
  const src = makeContext('win32', 'C:\\Users\\alice');
100
- const syncConfig = Buffer.from('{"transport":"git"}', 'utf8');
101
151
  const uiPrefs = Buffer.from('{"schemaVersion":1}', 'utf8');
102
- await src.fs.writeFile('dsh-config-manager/sync/sync-config.json', syncConfig);
152
+ const marketConfig = Buffer.from('{"schemaVersion":1,"sources":[]}', 'utf8');
103
153
  await src.fs.writeFile('dsh-config-manager/sync/ui-prefs.json', uiPrefs);
154
+ await src.fs.writeFile('dsh-config-manager/market/market-config.json', marketConfig);
104
155
  // 插件自身的数据:此前基类的目录递归把它们全当成该分区的「可勾选单元」
105
156
  await src.fs.writeFile('dsh-config-manager/snapshots/eec/snapshot.json', Buffer.from('x'.repeat(4096), 'utf8'));
106
157
  await src.fs.writeFile('dsh-config-manager/config-snapshots/2026/config-snapshot.json', Buffer.from('x'.repeat(4096), 'utf8'));
@@ -115,10 +166,10 @@ test('self: 预览只列白名单 —— 快照/同步工作副本等内部数
115
166
 
116
167
  assert.deepEqual(
117
168
  previewed.items.map((u) => u.id),
118
- ['self:sync/sync-config.json', 'self:sync/ui-prefs.json'],
169
+ ['self:sync/ui-prefs.json', 'self:market/market-config.json'],
119
170
  '预览只列白名单里的配置(回归:只覆写 export() 时会列出上述全部 8 个文件)',
120
171
  );
121
- assert.equal(previewed.sizeBytes, syncConfig.byteLength + uiPrefs.byteLength, '体积只算白名单文件');
172
+ assert.equal(previewed.sizeBytes, uiPrefs.byteLength + marketConfig.byteLength, '体积只算白名单文件');
122
173
  assert.deepEqual(previewed.items, adapter.listUnits(exported), '预览的单元清单必须与导出逐项相同');
123
174
  assert.deepEqual(previewed.section.counts, exported.counts);
124
175
  assert.deepEqual(previewed.section.warnings, exported.warnings);
@@ -131,8 +182,8 @@ test('self: 预览只列白名单 —— 快照/同步工作副本等内部数
131
182
 
132
183
  test('self: 磁盘体检新统计的目录(rollback-points / 遗留 checkpoints)不进本分区载荷', async () => {
133
184
  const src = makeContext('win32', 'C:\\Users\\alice');
134
- const syncConfig = Buffer.from('{"transport":"git"}', 'utf8');
135
- await src.fs.writeFile('dsh-config-manager/sync/sync-config.json', syncConfig);
185
+ const uiPrefs = Buffer.from('{"schemaVersion":1}', 'utf8');
186
+ await src.fs.writeFile('dsh-config-manager/sync/ui-prefs.json', uiPrefs);
136
187
  // 磁盘体检会统计这两块占用(回滚点库 + 旧检查点库)——但它们是**数据**,不是 self 分区的配置
137
188
  await src.fs.writeFile('dsh-config-manager/rollback-points/index.json', Buffer.from('x'.repeat(4096), 'utf8'));
138
189
  await src.fs.writeFile('dsh-config-manager/rollback-points/objects/rb-1/session.v4.jsonl.zstd', Buffer.from('x'.repeat(4096), 'utf8'));
@@ -144,66 +195,59 @@ test('self: 磁盘体检新统计的目录(rollback-points / 遗留 checkpoint
144
195
  const previewed = await adapter.preview(src, { includeSecrets: false });
145
196
  const rels = exported.data.files.map((f) => f.relativePath);
146
197
 
147
- assert.deepEqual(rels, ['sync/sync-config.json'], '白名单外一律不收(新增统计目录不得混进载荷)');
198
+ assert.deepEqual(rels, ['sync/ui-prefs.json'], '白名单外一律不收(新增统计目录不得混进载荷)');
148
199
  assert.equal(new Set(rels).size, rels.length, '不得出现重复条目');
149
200
  assert.equal(exported.counts.files, 1);
150
- assert.deepEqual(
151
- previewed.items.map((u) => u.id),
152
- ['self:sync/sync-config.json'],
153
- '预览与导出同口径(只列白名单)',
154
- );
155
- assert.equal(previewed.sizeBytes, syncConfig.byteLength, '体积只算白名单文件');
201
+ assert.deepEqual(previewed.items.map((u) => u.id), ['self:sync/ui-prefs.json'], '预览与导出同口径(只列白名单)');
202
+ assert.equal(previewed.sizeBytes, uiPrefs.byteLength, '体积只算白名单文件');
156
203
  });
157
204
 
158
205
  test('self: 宿主未实现 statSize(旧版门面)时,白名单判定仍只收配置、不误列内部数据', async () => {
159
206
  const src = makeContext('win32', 'C:\\Users\\alice');
160
- await src.fs.writeFile('dsh-config-manager/sync/sync-config.json', Buffer.from('{}', 'utf8'));
207
+ await src.fs.writeFile('dsh-config-manager/sync/ui-prefs.json', Buffer.from('{}', 'utf8'));
161
208
  await src.fs.writeFile('dsh-config-manager/snapshots/x/snapshot.json', Buffer.from('{}', 'utf8'));
162
209
  Object.defineProperty(src.fs, 'statSize', { value: undefined, configurable: true });
163
210
 
164
211
  const adapter = new SelfAdapter();
165
212
  const previewed = await adapter.preview(src, { includeSecrets: false });
166
- assert.deepEqual(previewed.items.map((u) => u.id), ['self:sync/sync-config.json']);
213
+ assert.deepEqual(previewed.items.map((u) => u.id), ['self:sync/ui-prefs.json']);
167
214
  assert.equal(previewed.sizeBytes, 2, '退回 readFile 时体积 = 文件字节数');
168
215
  });
169
216
 
170
217
  /* ---------------- issue #73:调度配置不得因运行态漂移而每轮重现 ---------------- */
171
218
 
172
- const AUTOSYNC_REL = 'dsh-config-manager/sync/sync-autosync.json';
173
219
  const BACKUP_SCHEDULE_REL = 'dsh-config-manager/sync/backup-schedule.json';
174
220
 
221
+ /** backup-schedule.json 的配置本体(无运行态) */
222
+ function backupScheduleBody(overrides: Record<string, unknown> = {}, runState: Record<string, unknown> = {}): Buffer {
223
+ return Buffer.from(JSON.stringify({
224
+ schemaVersion: 1, enabled: true, interval: '24h', startupMinIntervalMs: 3600000,
225
+ consecutiveFailures: 0, ...overrides, ...runState,
226
+ }, null, 2), 'utf8');
227
+ }
228
+
175
229
  test('self: 调度配置仅运行态不同 → Skip(键序不同亦然);配置本体不同 → Conflict', async () => {
176
230
  const adapter = new SelfAdapter();
177
- const remote = JSON.stringify({
178
- schemaVersion: 2,
179
- channels: {
180
- git: { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0, lastRunAt: '2026-10-01T00:00:00.000Z', lastRunStatus: 'success' },
181
- webdav: { enabled: false, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 },
182
- },
183
- }, null, 2);
184
231
  const src = makeContext('win32', 'C:\\Users\\alice');
185
- await src.fs.writeFile(AUTOSYNC_REL, Buffer.from(remote, 'utf8'));
232
+ await src.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
233
+ lastRunAt: '2026-10-01T00:00:00.000Z', lastRunStatus: 'success',
234
+ }));
186
235
  const out = await adapter.export(src, { includeSecrets: false });
187
236
  const sections = new Map([['self', out.data]]);
188
237
 
189
238
  // 本机:配置本体一致,但运行态已被本机调度器改写(每轮同步收尾都会发生),且键序不同
190
239
  const dst = makeContext('linux', '/home/bob');
191
- await dst.fs.writeFile(AUTOSYNC_REL, Buffer.from(JSON.stringify({
192
- schemaVersion: 2,
193
- channels: {
194
- webdav: { consecutiveFailures: 0, startupMinIntervalMs: 300000, interval: '30m', enabled: false },
195
- git: { lastRunAt: '2026-10-06T09:00:00.000Z', lastRunStatus: 'failed', lastRunMessage: '网络不可达', lastRunHistoryId: 'h-1', consecutiveFailures: 3, startupMinIntervalMs: 300000, interval: '30m', enabled: true },
196
- },
240
+ await dst.fs.writeFile(BACKUP_SCHEDULE_REL, Buffer.from(JSON.stringify({
241
+ lastRunStatus: 'failed', lastRunMessage: '网络不可达', lastRunHistoryId: 'h-1', consecutiveFailures: 3,
242
+ startupMinIntervalMs: 3600000, interval: '24h', enabled: true, schemaVersion: 1,
243
+ lastRunAt: '2026-10-06T09:00:00.000Z',
197
244
  }, null, 2), 'utf8'));
198
245
  let items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
199
246
  assert.equal(items.length, 1);
200
247
  assert.equal(items[0]?.kind, 'Skip', '仅运行态字段不同(且键序不同)→ Skip,否则每轮同步都重现同一条 Conflict');
201
248
 
202
249
  // 配置本体(enabled)不同 → 仍是 Conflict(剔除运行态不得把配置差异一起吞掉)
203
- await dst.fs.writeFile(AUTOSYNC_REL, Buffer.from(JSON.stringify({
204
- schemaVersion: 2,
205
- channels: { git: { enabled: false, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 } },
206
- }, null, 2), 'utf8'));
250
+ await dst.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({ enabled: false }));
207
251
  items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
208
252
  assert.equal(items[0]?.kind, 'Conflict');
209
253
  });
@@ -211,18 +255,16 @@ test('self: 调度配置仅运行态不同 → Skip(键序不同亦然);
211
255
  test('self: backup-schedule.json 同口径 + 坏 JSON 回落整文件哈希', async () => {
212
256
  const adapter = new SelfAdapter();
213
257
  const src = makeContext('win32', 'C:\\Users\\alice');
214
- await src.fs.writeFile(BACKUP_SCHEDULE_REL, Buffer.from(JSON.stringify({
215
- schemaVersion: 1, enabled: true, interval: '24h', startupMinIntervalMs: 3600000,
216
- consecutiveFailures: 0, lastRunAt: '2026-10-01T00:00:00.000Z', lastRunStatus: 'success',
217
- }, null, 2), 'utf8'));
258
+ await src.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
259
+ lastRunAt: '2026-10-01T00:00:00.000Z', lastRunStatus: 'success',
260
+ }));
218
261
  const out = await adapter.export(src, { includeSecrets: false });
219
262
  const sections = new Map([['self', out.data]]);
220
263
  const dst = makeContext('linux', '/home/bob');
221
264
 
222
- await dst.fs.writeFile(BACKUP_SCHEDULE_REL, Buffer.from(JSON.stringify({
223
- schemaVersion: 1, enabled: true, interval: '24h', startupMinIntervalMs: 3600000,
265
+ await dst.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
224
266
  consecutiveFailures: 2, lastRunAt: '2026-10-06T09:00:00.000Z', lastRunStatus: 'failed',
225
- }, null, 2), 'utf8'));
267
+ }));
226
268
  let items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
227
269
  assert.equal(items[0]?.kind, 'Skip');
228
270
 
@@ -235,39 +277,38 @@ test('self: backup-schedule.json 同口径 + 坏 JSON 回落整文件哈希', as
235
277
  test('self: 其余白名单文件的比对口径不外溢(仍是整文件哈希)', async () => {
236
278
  const adapter = new SelfAdapter();
237
279
  const src = makeContext('win32', 'C:\\Users\\alice');
238
- await src.fs.writeFile('dsh-config-manager/sync/sync-config.json', Buffer.from('{"transport":"git","git":{"repoUrl":"https://x"},"webdav":{}}', 'utf8'));
280
+ await src.fs.writeFile('dsh-config-manager/sync/ui-prefs.json', Buffer.from('{"schemaVersion":1,"lastSyncChannel":"webdav"}', 'utf8'));
239
281
  const out = await adapter.export(src, { includeSecrets: false });
240
282
  const sections = new Map([['self', out.data]]);
241
283
  const dst = makeContext('linux', '/home/bob');
242
- await dst.fs.writeFile('dsh-config-manager/sync/sync-config.json', Buffer.from('{ "webdav": {}, "git": { "repoUrl": "https://x" }, "transport": "git" }', 'utf8'));
284
+ await dst.fs.writeFile('dsh-config-manager/sync/ui-prefs.json', Buffer.from('{ "lastSyncChannel": "webdav", "schemaVersion": 1 }', 'utf8'));
243
285
  const items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
244
- assert.equal(items[0]?.kind, 'Conflict', '键序不同但语义相同 —— sync-config 不是调度配置,仍按字节判');
286
+ assert.equal(items[0]?.kind, 'Conflict', '键序不同但语义相同 —— 非调度配置仍按字节判');
245
287
  });
246
288
 
247
289
  /**
248
290
  * issue #127 回归:运行态漂移不得改变导出的分区 hash。
249
291
  *
250
- * 现场:调度器每轮收尾改写 sync-autosync.json 的运行态字段 → self 分区 hashSection 每轮都变
292
+ * 现场:调度器每轮收尾改写调度配置的运行态字段 → self 分区 hashSection 每轮都变
251
293
  * → SyncEngine.hasLocalChanges() 永远为真 → 自动同步的每次远端轮询(缺省 60s)都真推一个
252
294
  * 只差 lastRunAt 的新快照(真机 2026-10-25 一天 41 次 push,远端累积 325 个垃圾快照)。
295
+ *
296
+ * 载体说明:SEC-01 之后 sync-autosync.json 不再进载荷,同一套运行态剔除语义由仍在白名单的
297
+ * backup-schedule.json 承载(两个调度配置共用同一份剔除表,见 self.ts 的 VOLATILE_RUN_STATE_KEY_LIST)。
253
298
  */
254
299
  test('self: 运行态改写不改变分区 hash(自动同步不再每轮误判「本地有改动」)', async () => {
255
- const rel = 'dsh-config-manager/sync/sync-autosync.json';
256
300
  const src = makeContext('win32', 'C:\\Users\\alice');
257
301
  const adapter = new SelfAdapter();
258
- const config = (runState: Record<string, unknown>, repoUrl: string) => JSON.stringify({
259
- schemaVersion: 2,
260
- channels: {
261
- git: { enabled: true, intervalMinutes: 30, realtime: true, remoteCheckMs: 60000, repoUrl, branch: 'main', ...runState },
262
- webdav: { enabled: false },
263
- },
264
- }, null, 2);
265
-
266
- await src.fs.writeFile(rel, Buffer.from(config({ lastRunAt: '2026-10-25T12:00:00.000Z', lastRunStatus: 'success', lastRunHistoryId: 'h-1', consecutiveFailures: 0 }, 'https://x/repo.git'), 'utf8'));
302
+
303
+ await src.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
304
+ lastRunAt: '2026-10-25T12:00:00.000Z', lastRunStatus: 'success', lastRunHistoryId: 'h-1', consecutiveFailures: 0,
305
+ }));
267
306
  const first = await adapter.export(src, { includeSecrets: false });
268
307
 
269
308
  // 模拟一次同步收尾:只推进运行态(配置本体不动)
270
- await src.fs.writeFile(rel, Buffer.from(config({ lastRunAt: '2026-10-25T12:01:00.000Z', lastRunStatus: 'failed', lastRunMessage: 'boom', lastRunHistoryId: 'h-2', consecutiveFailures: 1 }, 'https://x/repo.git'), 'utf8'));
309
+ await src.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
310
+ lastRunAt: '2026-10-25T12:01:00.000Z', lastRunStatus: 'failed', lastRunMessage: 'boom', lastRunHistoryId: 'h-2', consecutiveFailures: 1,
311
+ }));
271
312
  const second = await adapter.export(src, { includeSecrets: false });
272
313
 
273
314
  assert.equal(
@@ -275,13 +316,13 @@ test('self: 运行态改写不改变分区 hash(自动同步不再每轮误判
275
316
  hashSection(first.data),
276
317
  '仅运行态不同 → 分区 hash 必须不变(否则每轮远端轮询都真推一次)',
277
318
  );
278
- const payload = Buffer.from(second.data.files.find((f) => f.relativePath === 'sync/sync-autosync.json')!.data).toString('utf8');
319
+ const payload = Buffer.from(second.data.files.find((f) => f.relativePath === 'sync/backup-schedule.json')!.data).toString('utf8');
279
320
  assert.ok(!payload.includes('lastRunAt'), '导出载荷不含运行态:远端不该接收本机执行记录');
280
321
  assert.ok(!payload.includes('consecutiveFailures'));
281
- assert.ok(payload.includes('"repoUrl":"https://x/repo.git"'), '配置本体仍在载荷里');
322
+ assert.ok(payload.includes('"interval":"24h"'), '配置本体仍在载荷里');
282
323
 
283
324
  // 配置本体变化必须被识别(不能把运行态过滤做成「什么都判不变」)
284
- await src.fs.writeFile(rel, Buffer.from(config({ lastRunAt: '2026-10-25T12:02:00.000Z' }, 'https://y/repo.git'), 'utf8'));
325
+ await src.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({ interval: '12h' }, { lastRunAt: '2026-10-25T12:02:00.000Z' }));
285
326
  const third = await adapter.export(src, { includeSecrets: false });
286
327
  assert.notEqual(hashSection(third.data), hashSection(second.data), '真实配置变化必须让分区 hash 变化');
287
328
  });
@@ -296,46 +337,46 @@ test('self: 运行态改写不改变分区 hash(自动同步不再每轮误判
296
337
  * → hasLocalChanges() 恒真 → 每轮都真推一个新快照。
297
338
  */
298
339
  test('self: 跳过码翻转不改变分区 hash(issue #119:自动同步不再每轮真推快照)', async () => {
299
- const rel = 'dsh-config-manager/sync/sync-autosync.json';
300
340
  const src = makeContext('win32', 'C:\\Users\\alice');
301
341
  const adapter = new SelfAdapter();
302
- const config = (runState: Record<string, unknown>, repoUrl = 'https://x/repo.git') => JSON.stringify({
303
- schemaVersion: 2,
304
- channels: {
305
- git: { enabled: true, intervalMinutes: 30, realtime: true, remoteCheckMs: 60000, repoUrl, branch: 'main', ...runState },
306
- webdav: { enabled: false },
307
- },
308
- }, null, 2);
309
342
 
310
343
  // 轮次 A:跳过(无本地改动)
311
- await src.fs.writeFile(rel, Buffer.from(config({ lastRunAt: '2026-10-29T09:00:00.000Z', lastRunStatus: 'success', lastRunSkipReason: 'upToDate', consecutiveFailures: 0 }), 'utf8'));
344
+ await src.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
345
+ lastRunAt: '2026-10-29T09:00:00.000Z', lastRunStatus: 'success', lastRunSkipReason: 'upToDate', consecutiveFailures: 0,
346
+ }));
312
347
  const skipRound = await adapter.export(src, { includeSecrets: false });
313
348
 
314
349
  // 轮次 B:真推一次 —— 跳过码被清空,其余与 A 相同
315
- await src.fs.writeFile(rel, Buffer.from(config({ lastRunAt: '2026-10-29T09:01:00.000Z', lastRunStatus: 'success', consecutiveFailures: 0 }), 'utf8'));
350
+ await src.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
351
+ lastRunAt: '2026-10-29T09:01:00.000Z', lastRunStatus: 'success', consecutiveFailures: 0,
352
+ }));
316
353
  const pushRound = await adapter.export(src, { includeSecrets: false });
317
354
 
318
355
  // 轮次 C:跳过(冲突,带待确认分区数)
319
- await src.fs.writeFile(rel, Buffer.from(config({ lastRunAt: '2026-10-29T09:02:00.000Z', lastRunStatus: 'success', lastRunSkipReason: 'conflict', lastRunSkipCount: 3, consecutiveFailures: 0 }), 'utf8'));
356
+ await src.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
357
+ lastRunAt: '2026-10-29T09:02:00.000Z', lastRunStatus: 'success', lastRunSkipReason: 'conflict', lastRunSkipCount: 3, consecutiveFailures: 0,
358
+ }));
320
359
  const conflictRound = await adapter.export(src, { includeSecrets: false });
321
360
 
322
361
  const base = hashSection(skipRound.data);
323
362
  assert.equal(hashSection(pushRound.data), base, '跳过码被清空不得改变分区 hash(否则每轮都判「本地有改动」)');
324
363
  assert.equal(hashSection(conflictRound.data), base, '跳过码换值 / 带分区数不得改变分区 hash');
325
364
 
326
- const payload = Buffer.from(pushRound.data.files.find((f) => f.relativePath === 'sync/sync-autosync.json')!.data).toString('utf8');
365
+ const payload = Buffer.from(pushRound.data.files.find((f) => f.relativePath === 'sync/backup-schedule.json')!.data).toString('utf8');
327
366
  assert.ok(!payload.includes('lastRunSkip'), '导出载荷不含跳过码:远端不该接收本机执行记录');
328
- assert.ok(payload.includes('"repoUrl":"https://x/repo.git"'), '配置本体仍在载荷里');
367
+ assert.ok(payload.includes('"interval":"24h"'), '配置本体仍在载荷里');
329
368
 
330
369
  // 同一份远端载荷回灌本机(本机多出跳过码)→ Skip,不是 Conflict
331
370
  const dst = makeContext('linux', '/home/bob');
332
- await dst.fs.writeFile(rel, Buffer.from(config({ lastRunAt: '2026-10-29T09:05:00.000Z', lastRunStatus: 'success', lastRunSkipReason: 'upToDate' }), 'utf8'));
371
+ await dst.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({}, {
372
+ lastRunAt: '2026-10-29T09:05:00.000Z', lastRunStatus: 'success', lastRunSkipReason: 'upToDate',
373
+ }));
333
374
  const sections = new Map([['self', pushRound.data]]);
334
375
  let items = await adapter.analyzeImport(pushRound.data, makeImportContext(dst, sections));
335
376
  assert.equal(items[0]?.kind, 'Skip', '仅跳过码不同 → Skip(此前每轮判 Conflict 并重复推送)');
336
377
 
337
378
  // 配置本体变化仍必须被识别(不能把剔除做成「什么都判不变」)
338
- await dst.fs.writeFile(rel, Buffer.from(config({ lastRunSkipReason: 'upToDate' }, 'https://y/repo.git'), 'utf8'));
379
+ await dst.fs.writeFile(BACKUP_SCHEDULE_REL, backupScheduleBody({ interval: '6h' }, { lastRunSkipReason: 'upToDate' }));
339
380
  items = await adapter.analyzeImport(pushRound.data, makeImportContext(dst, sections));
340
381
  assert.equal(items[0]?.kind, 'Conflict', '真实配置变化必须仍判 Conflict');
341
- });
382
+ });