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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/lib/adapters/self.d.ts +44 -4
- package/lib/adapters/self.js +63 -3
- package/lib/client.d.ts +11 -6
- package/lib/client.js +1256 -1185
- package/lib/core/analyzer.d.ts +3 -0
- package/lib/core/analyzer.js +32 -56
- package/lib/core/backup.d.ts +36 -0
- package/lib/core/backup.js +125 -3
- package/lib/core/import-defaults.d.ts +13 -0
- package/lib/core/import-defaults.js +14 -0
- package/lib/core/importer.d.ts +10 -1
- package/lib/core/importer.js +7 -1
- package/lib/core/messages.d.ts +4 -0
- package/lib/core/messages.js +8 -0
- package/lib/core/model-tools.js +19 -3
- package/lib/index.d.ts +1 -1
- package/lib/index.js +12 -22
- package/lib/routes/import.js +3 -1
- package/lib/routes/recovery.js +226 -2
- package/lib/routes/sync.js +26 -7
- package/lib/security/integrity.d.ts +24 -0
- package/lib/security/integrity.js +30 -0
- package/lib/security/unverified-file-blocks.d.ts +73 -0
- package/lib/security/unverified-file-blocks.js +151 -0
- package/lib/sync/autosync-config.d.ts +8 -4
- package/lib/sync/autosync-config.js +2 -2
- package/lib/sync/autosync-scheduler.d.ts +61 -3
- package/lib/sync/autosync-scheduler.js +87 -10
- package/lib/sync/config-issue-reporting.d.ts +32 -0
- package/lib/sync/config-issue-reporting.js +63 -0
- package/lib/sync/gc-skip.d.ts +42 -0
- package/lib/sync/gc-skip.js +57 -0
- package/lib/sync/git/git-transport.d.ts +56 -2
- package/lib/sync/git/git-transport.js +344 -73
- package/lib/sync/merge-preview-text.d.ts +11 -0
- package/lib/sync/merge-preview-text.js +38 -0
- package/lib/sync/s3/s3-transport.d.ts +8 -1
- package/lib/sync/s3/s3-transport.js +19 -5
- package/lib/sync/session-tombstones.d.ts +53 -0
- package/lib/sync/session-tombstones.js +52 -0
- package/lib/sync/sync-config.d.ts +55 -5
- package/lib/sync/sync-config.js +188 -15
- package/lib/sync/sync-engine.d.ts +21 -7
- package/lib/sync/sync-engine.js +63 -85
- package/lib/sync/webdav/webdav-transport.d.ts +11 -1
- package/lib/sync/webdav/webdav-transport.js +22 -6
- package/lib/ui/i18n.js +56 -36
- package/lib/ui/import-wizard.d.ts +1 -1
- package/lib/ui/import-wizard.js +2 -1
- package/lib/ui/overview-view.d.ts +6 -2
- package/lib/ui/overview-view.js +8 -1
- package/lib/ui/report.js +2 -1
- package/lib/utils/git-fast-forward.d.ts +7 -2
- package/lib/utils/git-fast-forward.js +8 -3
- package/package.json +2 -2
- package/src/adapters/self.test.ts +126 -85
- package/src/adapters/self.ts +73 -6
- package/src/client/ConfigManagerSection.tsx +0 -3
- package/src/client/common/Icon.tsx +3 -0
- package/src/client/common/SectionComposition.tsx +6 -1
- package/src/client/common/info-hint-guard.test.ts +7 -5
- package/src/client/config-manager.module.css +254 -13
- package/src/client/environment/EnvironmentPanel.tsx +57 -47
- package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
- package/src/client/export/ExportView.tsx +3 -1
- package/src/client/home/HomePanel.tsx +25 -5
- package/src/client/import/ImportWizardView.tsx +1 -3
- package/src/client/import/import-wizard-steps.tsx +1 -17
- package/src/client/library/ArtifactRow.tsx +13 -2
- package/src/client/library/LibraryPanel.tsx +12 -2
- package/src/client/locales.ts +53 -34
- package/src/client/lucide-icons.d.ts +2 -0
- package/src/client/market/MarketPanel.tsx +12 -11
- package/src/client/market/market-locales.ts +16 -16
- package/src/client/recovery/RollbackCard.tsx +4 -2
- package/src/client/recovery/recovery-locales.test.ts +4 -2
- package/src/client/recovery/recovery-locales.ts +23 -23
- package/src/client/sync/AutosyncCard.tsx +36 -10
- package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
- package/src/client/sync/SyncPanel.tsx +32 -33
- package/src/client/sync/SyncProbeRow.tsx +23 -6
- package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
- package/src/client/sync/sync-locales.ts +24 -24
- package/src/core/analyzer.ts +37 -56
- package/src/core/backup.test.ts +139 -1
- package/src/core/backup.ts +145 -4
- package/src/core/import-defaults.ts +13 -0
- package/src/core/importer.ts +12 -2
- package/src/core/integrity-extra-entries.test.ts +205 -0
- package/src/core/messages.test.ts +3 -1
- package/src/core/messages.ts +8 -0
- package/src/core/model-tools.test.ts +33 -0
- package/src/core/model-tools.ts +20 -4
- package/src/index.ts +12 -21
- package/src/routes/import.ts +4 -1
- package/src/routes/recovery.test.ts +198 -1
- package/src/routes/recovery.ts +194 -1
- package/src/routes/sync.ts +27 -8
- package/src/security/integrity.ts +37 -0
- package/src/security/unverified-file-blocks.ts +192 -0
- package/src/sync/autosync-config.ts +9 -5
- package/src/sync/autosync-scheduler.test.ts +133 -6
- package/src/sync/autosync-scheduler.ts +118 -14
- package/src/sync/config-issue-reporting.test.ts +148 -0
- package/src/sync/config-issue-reporting.ts +75 -0
- package/src/sync/gc-skip.test.ts +66 -0
- package/src/sync/gc-skip.ts +64 -0
- package/src/sync/git/git-probe.test.ts +6 -1
- package/src/sync/git/git-transport.test.ts +351 -14
- package/src/sync/git/git-transport.ts +343 -79
- package/src/sync/merge-preview-text.ts +37 -0
- package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
- package/src/sync/s3/s3-transport.ts +23 -4
- package/src/sync/session-tombstones.ts +90 -0
- package/src/sync/sync-config.test.ts +170 -1
- package/src/sync/sync-config.ts +184 -14
- package/src/sync/sync-engine.test.ts +238 -0
- package/src/sync/sync-engine.ts +79 -83
- package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
- package/src/sync/webdav/webdav-transport.ts +26 -5
- package/src/ui/disk-usage-view.test.ts +2 -1
- package/src/ui/i18n.test.ts +47 -0
- package/src/ui/i18n.ts +58 -38
- package/src/ui/import-wizard.test.ts +18 -1
- package/src/ui/import-wizard.ts +3 -2
- package/src/ui/overview-view.test.ts +9 -0
- package/src/ui/overview-view.ts +9 -2
- package/src/ui/report.test.ts +4 -3
- package/src/ui/report.ts +2 -1
- 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.
|
|
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;
|
package/lib/ui/overview-view.js
CHANGED
|
@@ -149,7 +149,11 @@ export function buildOverviewMetrics(inputs) {
|
|
|
149
149
|
* 1. SAFE MODE 激活(有未解决恢复事项)→ error(最高优先,需用户处理);
|
|
150
150
|
* 2. 已加载且备份与快照均为空 → warn「尚无任何备份」(新装机引导场景);
|
|
151
151
|
* 3. 定时备份开启但上次运行失败 → warn;
|
|
152
|
-
* 4.
|
|
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
|
-
|
|
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
|
-
/**
|
|
12
|
-
|
|
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
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
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.
|
|
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.
|
|
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
|
-
'
|
|
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
|
-
//
|
|
36
|
-
assert.ok(SELF_CONFIG_FILES.includes('sync/sync-
|
|
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/
|
|
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/
|
|
69
|
-
'{"
|
|
70
|
-
'导入写回 $DSH_HOME/dsh-config-manager
|
|
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/
|
|
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
|
-
|
|
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/
|
|
169
|
+
['self:sync/ui-prefs.json', 'self:market/market-config.json'],
|
|
119
170
|
'预览只列白名单里的配置(回归:只覆写 export() 时会列出上述全部 8 个文件)',
|
|
120
171
|
);
|
|
121
|
-
assert.equal(previewed.sizeBytes,
|
|
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
|
|
135
|
-
await src.fs.writeFile('dsh-config-manager/sync/
|
|
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/
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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(
|
|
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(
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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(
|
|
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,
|
|
215
|
-
|
|
216
|
-
|
|
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,
|
|
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
|
-
}
|
|
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/
|
|
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/
|
|
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', '键序不同但语义相同 ——
|
|
286
|
+
assert.equal(items[0]?.kind, 'Conflict', '键序不同但语义相同 —— 非调度配置仍按字节判');
|
|
245
287
|
});
|
|
246
288
|
|
|
247
289
|
/**
|
|
248
290
|
* issue #127 回归:运行态漂移不得改变导出的分区 hash。
|
|
249
291
|
*
|
|
250
|
-
*
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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(
|
|
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/
|
|
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('"
|
|
322
|
+
assert.ok(payload.includes('"interval":"24h"'), '配置本体仍在载荷里');
|
|
282
323
|
|
|
283
324
|
// 配置本体变化必须被识别(不能把运行态过滤做成「什么都判不变」)
|
|
284
|
-
await src.fs.writeFile(
|
|
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(
|
|
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(
|
|
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(
|
|
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/
|
|
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('"
|
|
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(
|
|
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(
|
|
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
|
+
});
|