dsh-config-manager 0.1.19 → 0.1.21

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 (82) hide show
  1. package/lib/client.d.ts +319 -2
  2. package/lib/client.js +1271 -109
  3. package/lib/client.js.map +1 -1
  4. package/lib/core/run-registry.d.ts +2 -2
  5. package/lib/core/run-registry.js +5 -1
  6. package/lib/core/run-registry.js.map +1 -1
  7. package/lib/index.js +408 -2
  8. package/lib/index.js.map +1 -1
  9. package/lib/sync/ancestor.d.ts +17 -0
  10. package/lib/sync/ancestor.js +64 -0
  11. package/lib/sync/ancestor.js.map +1 -0
  12. package/lib/sync/autosync-config.d.ts +34 -0
  13. package/lib/sync/autosync-config.js +116 -0
  14. package/lib/sync/autosync-config.js.map +1 -0
  15. package/lib/sync/autosync-scheduler.d.ts +95 -0
  16. package/lib/sync/autosync-scheduler.js +379 -0
  17. package/lib/sync/autosync-scheduler.js.map +1 -0
  18. package/lib/sync/merge.d.ts +42 -0
  19. package/lib/sync/merge.js +325 -0
  20. package/lib/sync/merge.js.map +1 -0
  21. package/lib/sync/review-queue.d.ts +51 -0
  22. package/lib/sync/review-queue.js +119 -0
  23. package/lib/sync/review-queue.js.map +1 -0
  24. package/lib/sync/risk.d.ts +59 -0
  25. package/lib/sync/risk.js +76 -0
  26. package/lib/sync/risk.js.map +1 -0
  27. package/lib/sync/sync-config.d.ts +7 -2
  28. package/lib/sync/sync-config.js +24 -4
  29. package/lib/sync/sync-config.js.map +1 -1
  30. package/lib/sync/sync-engine.d.ts +88 -2
  31. package/lib/sync/sync-engine.js +333 -8
  32. package/lib/sync/sync-engine.js.map +1 -1
  33. package/lib/sync/sync-history.d.ts +39 -0
  34. package/lib/sync/sync-history.js +88 -0
  35. package/lib/sync/sync-history.js.map +1 -0
  36. package/lib/sync/sync-session.d.ts +39 -0
  37. package/lib/sync/sync-session.js +54 -0
  38. package/lib/sync/sync-session.js.map +1 -0
  39. package/lib/sync/sync-state.d.ts +7 -2
  40. package/lib/sync/sync-state.js +9 -5
  41. package/lib/sync/sync-state.js.map +1 -1
  42. package/lib/ui/i18n.d.ts +63 -0
  43. package/lib/ui/i18n.js +130 -1
  44. package/lib/ui/i18n.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/client/config-manager.module.css +18 -0
  47. package/src/client/sync/SyncConfirmView.tsx +301 -0
  48. package/src/client/sync/SyncHistoryView.test.ts +40 -0
  49. package/src/client/sync/SyncHistoryView.tsx +155 -0
  50. package/src/client/sync/SyncSettingsView.tsx +241 -12
  51. package/src/client/sync/history-model.test.ts +82 -0
  52. package/src/client/sync/history-model.ts +112 -0
  53. package/src/client/sync/sync-api.test.ts +155 -0
  54. package/src/client/sync/sync-api.ts +219 -0
  55. package/src/client/sync/sync-locales.ts +162 -1
  56. package/src/client/sync/sync-view-v2.test.ts +131 -0
  57. package/src/client/sync/sync-view.ts +151 -4
  58. package/src/core/run-registry.ts +7 -3
  59. package/src/index.ts +420 -3
  60. package/src/sync/ancestor.test.ts +152 -0
  61. package/src/sync/ancestor.ts +81 -0
  62. package/src/sync/autosync-config.test.ts +93 -0
  63. package/src/sync/autosync-config.ts +137 -0
  64. package/src/sync/autosync-scheduler.test.ts +191 -0
  65. package/src/sync/autosync-scheduler.ts +443 -0
  66. package/src/sync/merge.test.ts +204 -0
  67. package/src/sync/merge.ts +360 -0
  68. package/src/sync/review-queue.test.ts +120 -0
  69. package/src/sync/review-queue.ts +167 -0
  70. package/src/sync/risk.test.ts +131 -0
  71. package/src/sync/risk.ts +122 -0
  72. package/src/sync/sync-config.test.ts +106 -0
  73. package/src/sync/sync-config.ts +23 -4
  74. package/src/sync/sync-engine.test.ts +392 -3
  75. package/src/sync/sync-engine.ts +383 -10
  76. package/src/sync/sync-history.test.ts +85 -0
  77. package/src/sync/sync-history.ts +126 -0
  78. package/src/sync/sync-session.test.ts +137 -0
  79. package/src/sync/sync-session.ts +76 -0
  80. package/src/sync/sync-state.test.ts +48 -2
  81. package/src/sync/sync-state.ts +11 -5
  82. package/src/ui/i18n.ts +132 -3
@@ -0,0 +1,131 @@
1
+ /**
2
+ * m-sync-flow:分级自动应用策略测试。
3
+ * 覆盖:风险映射、首次强制预览、决策分流、形状 contract、互斥分组。
4
+ */
5
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+
8
+ import { SECTION_RISK_TIER, classifyMergePlan, summarizeApplyPlan } from './risk.ts';
9
+ import type { MergePlan, MergeSectionResult } from './merge.ts';
10
+
11
+ const sec = (
12
+ id: MergeSectionResult['id'],
13
+ decision: MergeSectionResult['decision'],
14
+ merged?: MergeSectionResult['merged'],
15
+ conflicts: MergeSectionResult['conflicts'] = [],
16
+ ): MergeSectionResult => ({ id, decision, conflicts, ...(merged !== undefined ? { merged } : {}) });
17
+
18
+ test('SECTION_RISK_TIER:规划表约定的映射正确', () => {
19
+ assert.equal(SECTION_RISK_TIER.settings, 'low');
20
+ assert.equal(SECTION_RISK_TIER.ui, 'low');
21
+ assert.equal(SECTION_RISK_TIER.providers, 'low');
22
+ assert.equal(SECTION_RISK_TIER.prompts, 'low');
23
+ assert.equal(SECTION_RISK_TIER.workspaces, 'medium');
24
+ assert.equal(SECTION_RISK_TIER.plugins, 'medium');
25
+ assert.equal(SECTION_RISK_TIER.mcp, 'medium');
26
+ assert.equal(SECTION_RISK_TIER.credentialsStatus, 'high');
27
+ assert.equal(SECTION_RISK_TIER.secrets, 'high');
28
+ });
29
+
30
+ test('classifyMergePlan:firstSync=false 时低风险 useRemote/keepLocal → autoApply', () => {
31
+ const plan: MergePlan = { sections: [
32
+ sec('settings', 'useRemote', { version: 1, namespaces: {} }),
33
+ sec('providers', 'useRemote', { version: 1, providers: {} }),
34
+ ]};
35
+ const out = classifyMergePlan(plan, { firstSync: false });
36
+ assert.equal(out.autoApply.length, 2);
37
+ assert.equal(out.review.length, 0);
38
+ assert.equal(out.skipped.length, 0);
39
+ });
40
+
41
+ test('classifyMergePlan:firstSync=false 时中风险 → review', () => {
42
+ const plan: MergePlan = { sections: [
43
+ sec('workspaces', 'useRemote', { version: 1, workspaces: [] }),
44
+ sec('plugins', 'keepLocal', { version: 1, plugins: [], patch: [] }),
45
+ sec('mcp', 'useRemote', { version: 1, servers: [] }),
46
+ ]};
47
+ const out = classifyMergePlan(plan, { firstSync: false });
48
+ assert.equal(out.autoApply.length, 0);
49
+ assert.equal(out.review.length, 3);
50
+ assert.equal(out.skipped.length, 0);
51
+ });
52
+
53
+ test('classifyMergePlan:firstSync=false 时 high → review(永不自动)', () => {
54
+ const plan: MergePlan = { sections: [
55
+ sec('credentialsStatus', 'useRemote', { version: 1, credentials: [] }),
56
+ sec('secrets', 'keepLocal', { version: 1, files: [] } as MergeSectionResult['merged']),
57
+ ]};
58
+ const out = classifyMergePlan(plan, { firstSync: false });
59
+ assert.equal(out.autoApply.length, 0);
60
+ assert.equal(out.review.length, 2);
61
+ });
62
+
63
+ test('classifyMergePlan:双向冲突永远进 review(无论 firstSync)', () => {
64
+ const plan: MergePlan = { sections: [
65
+ sec('settings', 'conflict', undefined, [{ path: 'general', kind: 'key' }]),
66
+ ]};
67
+ assert.equal(classifyMergePlan(plan, { firstSync: false }).review.length, 1);
68
+ assert.equal(classifyMergePlan(plan, { firstSync: true }).review.length, 1);
69
+ });
70
+
71
+ test('classifyMergePlan:firstSync=true 时所有非 skip 项一律进 review', () => {
72
+ const plan: MergePlan = { sections: [
73
+ sec('settings', 'useRemote', { version: 1, namespaces: {} }),
74
+ sec('providers', 'keepLocal', { version: 1, providers: {} }),
75
+ sec('workspaces', 'useRemote', { version: 1, workspaces: [] }),
76
+ sec('settings', 'skip'), // 重复测试:skip 永远进 skipped
77
+ ]};
78
+ const out = classifyMergePlan(plan, { firstSync: true });
79
+ assert.equal(out.review.length, 3, '低+中风险全部进 review');
80
+ assert.equal(out.autoApply.length, 0);
81
+ assert.equal(out.skipped.length, 1);
82
+ });
83
+
84
+ test('classifyMergePlan:决策为 skip → skipped(与 firstSync 无关)', () => {
85
+ const plan: MergePlan = { sections: [sec('settings', 'skip')] };
86
+ assert.equal(classifyMergePlan(plan, { firstSync: false }).skipped.length, 1);
87
+ assert.equal(classifyMergePlan(plan, { firstSync: true }).skipped.length, 1);
88
+ });
89
+
90
+ test('classifyMergePlan:同一 section 不出现在多个分组', () => {
91
+ const plan: MergePlan = { sections: [
92
+ sec('settings', 'useRemote', { version: 1, namespaces: {} }),
93
+ sec('providers', 'skip'),
94
+ sec('workspaces', 'conflict', undefined, [{ path: '$', kind: 'key' }]),
95
+ sec('mcp', 'keepLocal', { version: 1, servers: [] }),
96
+ ]};
97
+ const out = classifyMergePlan(plan, { firstSync: false });
98
+ const seen = new Set<string>();
99
+ for (const bucket of [out.autoApply, out.review, out.skipped]) {
100
+ for (const r of bucket) {
101
+ assert.ok(!seen.has(r.id), `section ${r.id} 出现在多个分组`);
102
+ seen.add(r.id);
103
+ }
104
+ }
105
+ assert.equal(out.autoApply.length, 1, 'settings → autoApply');
106
+ assert.equal(out.review.length, 2, 'workspaces conflict + mcp medium → review');
107
+ assert.equal(out.skipped.length, 1, 'providers skip → skipped');
108
+ });
109
+
110
+ test('classifyMergePlan:输出 SyncApplyPlan 形状符合 contract', () => {
111
+ const plan: MergePlan = { sections: [] };
112
+ const out = classifyMergePlan(plan, { firstSync: false });
113
+ assert.ok(Array.isArray(out.autoApply));
114
+ assert.ok(Array.isArray(out.review));
115
+ assert.ok(Array.isArray(out.skipped));
116
+ });
117
+
118
+ test('summarizeApplyPlan:计数正确', () => {
119
+ const plan: MergePlan = { sections: [
120
+ sec('settings', 'useRemote', { version: 1, namespaces: {} }),
121
+ sec('providers', 'useRemote', { version: 1, providers: {} }),
122
+ sec('workspaces', 'useRemote', { version: 1, workspaces: [] }),
123
+ sec('settings', 'skip'),
124
+ ]};
125
+ const apply = classifyMergePlan(plan, { firstSync: false });
126
+ const sum = summarizeApplyPlan(apply);
127
+ assert.equal(sum.autoApplyCount, 2);
128
+ assert.equal(sum.reviewCount, 1);
129
+ assert.equal(sum.skippedCount, 1);
130
+ assert.equal(sum.totalCount, 4);
131
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * m-sync-flow:分级自动应用策略 + 首次强制预览(纯函数)。
3
+ *
4
+ * 数据源:P2a 的 MergePlan(每分区 decision + conflicts)。
5
+ * 产出:SyncApplyPlan 三组(autoApply / review / skipped),供 SyncEngine.applyMergePlan 与 UI 使用。
6
+ *
7
+ * 规则:
8
+ * - SECTION_RISK_TIER 把每个 SectionId 静态归类到 low / medium / high;
9
+ * - 双向 conflict 分区永远进 review(无论风险与 firstSync 与否);
10
+ * - firstSync=true 时所有非 skip 项一律进 review(安全第一:人工确认后才开启自动);
11
+ * - firstSync=false 时按风险等级分流:低风险且 useRemote/keepLocal 进 autoApply;
12
+ * 中风险、high、双向冲突 → review;skip 或无变化 → skipped。
13
+ */
14
+ import type { MergePlan, MergeSectionResult } from './merge.ts';
15
+ import type { SectionId } from '../schema/types.ts';
16
+
17
+ /** 分区风险等级(驱动自动应用 vs 待审) */
18
+ export type RiskTier = 'low' | 'medium' | 'high';
19
+
20
+ /**
21
+ * 分区风险映射(v1 静态表;可后续接入适配器声明的 risk 字段动态化)。
22
+ * - low :低风险——自动应用(无冲突时)
23
+ * settings / ui / providers / prompts
24
+ * - medium :中风险——待审队列(路径映射、安装、变更)
25
+ * workspaces / plugins / mcp
26
+ * - high :高风险——永不自动(含双向冲突、设备专属、凭据/秘密)
27
+ * credentialsStatus / secrets / sessions / pluginFiles / workspaces? 不,
28
+ * 按规划表 workspaces 归 medium(中风险),credentialsStatus/secrets 归 high;
29
+ * agentPresets / skills 暂归 low(受 sync 通道的 portable 过滤实际不会携带 skills)。
30
+ */
31
+ export const SECTION_RISK_TIER: Readonly<Record<SectionId, RiskTier>> = {
32
+ settings: 'low',
33
+ ui: 'low',
34
+ providers: 'low',
35
+ prompts: 'low',
36
+ workspaces: 'medium',
37
+ plugins: 'medium',
38
+ mcp: 'medium',
39
+ skills: 'low',
40
+ agentPresets: 'low',
41
+ pluginFiles: 'high',
42
+ sessions: 'high',
43
+ credentialsStatus: 'high',
44
+ secrets: 'high',
45
+ };
46
+
47
+ export interface ClassifyOptions {
48
+ /**
49
+ * true = 首次同步:所有非 skip 项一律进 review(无论风险等级);
50
+ * false(默认)= 按风险等级分流。
51
+ * 调用方在 SyncEngine 中维护 firstSyncCompleted 标志。
52
+ */
53
+ firstSync: boolean;
54
+ }
55
+
56
+ /** 自动应用三组结果(互斥:同一 sectionId 只出现在一组) */
57
+ export interface SyncApplyPlan {
58
+ autoApply: MergeSectionResult[];
59
+ review: MergeSectionResult[];
60
+ skipped: MergeSectionResult[];
61
+ }
62
+
63
+ /**
64
+ * 按风险等级 + firstSync 标志将 MergePlan 分流到三组。
65
+ * 纯函数:相同输入 → 相同输出;不读 fs、不发网络。
66
+ */
67
+ export function classifyMergePlan(plan: MergePlan, opts: ClassifyOptions): SyncApplyPlan {
68
+ const autoApply: MergeSectionResult[] = [];
69
+ const review: MergeSectionResult[] = [];
70
+ const skipped: MergeSectionResult[] = [];
71
+ for (const r of plan.sections) {
72
+ // 跳过:决策为 skip(远端缺且本地未改 / 完全无变化)
73
+ if (r.decision === 'skip') {
74
+ skipped.push(r);
75
+ continue;
76
+ }
77
+ // 双向冲突永远进 review(最高优先级)
78
+ if (r.decision === 'conflict') {
79
+ review.push(r);
80
+ continue;
81
+ }
82
+ // 首次同步强制预览:所有非 skip 项一律进 review
83
+ if (opts.firstSync) {
84
+ review.push(r);
85
+ continue;
86
+ }
87
+ const tier = SECTION_RISK_TIER[r.id];
88
+ // 未知分区(防御性)一律进 review 而非自动应用
89
+ if (tier === undefined) {
90
+ review.push(r);
91
+ continue;
92
+ }
93
+ if (tier === 'low') {
94
+ // 低风险且决策明确(useRemote / keepLocal)→ 自动应用
95
+ autoApply.push(r);
96
+ } else {
97
+ // medium / high → 待审
98
+ review.push(r);
99
+ }
100
+ }
101
+ return { autoApply, review, skipped };
102
+ }
103
+
104
+ /**
105
+ * 分流摘要计数(供 UI 徽章显示)。
106
+ * 同一函数复用:纯函数无副作用。
107
+ */
108
+ export interface SyncApplySummary {
109
+ autoApplyCount: number;
110
+ reviewCount: number;
111
+ skippedCount: number;
112
+ totalCount: number;
113
+ }
114
+
115
+ export function summarizeApplyPlan(apply: SyncApplyPlan): SyncApplySummary {
116
+ return {
117
+ autoApplyCount: apply.autoApply.length,
118
+ reviewCount: apply.review.length,
119
+ skippedCount: apply.skipped.length,
120
+ totalCount: apply.autoApply.length + apply.review.length + apply.skipped.length,
121
+ };
122
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * m-sync-ui:sync-config.json schemaVersion 迁移与往返测试。
3
+ * - 写入 schemaVersion=1 + 字段;读回字段一致。
4
+ * - 旧文件无 schemaVersion 字段视为 v1 兼容读取。
5
+ * - 不支持的 schemaVersion 抛错(返回 null)。
6
+ * - 损坏 JSON 返回 null(不静默降级)。
7
+ */
8
+ import test from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs/promises';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+
14
+ import {
15
+ readSyncConfig, writeSyncConfig, SYNC_CONFIG_FILE, SYNC_CONFIG_SCHEMA_VERSION,
16
+ } from './sync-config.ts';
17
+
18
+ test('writeSyncConfig + readSyncConfig:写入 schemaVersion=1 + repoUrl/gitBin → 读回字段一致', async () => {
19
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-cfg-rt-'));
20
+ try {
21
+ await writeSyncConfig(dir, { repoUrl: 'git@github.com:foo/bar.git', gitBin: '/usr/bin/git' });
22
+ const loaded = await readSyncConfig(dir);
23
+ assert.deepEqual(loaded, { repoUrl: 'git@github.com:foo/bar.git', gitBin: '/usr/bin/git' });
24
+ const raw = JSON.parse(await fs.readFile(path.join(dir, SYNC_CONFIG_FILE), 'utf8'));
25
+ assert.equal(raw.schemaVersion, SYNC_CONFIG_SCHEMA_VERSION);
26
+ assert.equal(raw.repoUrl, 'git@github.com:foo/bar.git');
27
+ assert.equal(raw.gitBin, '/usr/bin/git');
28
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
29
+ });
30
+
31
+ test('writeSyncConfig:未传 gitBin 时不写入字段(保持文件精简)', async () => {
32
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-cfg-nogitbin-'));
33
+ try {
34
+ await writeSyncConfig(dir, { repoUrl: 'git@github.com:foo/bar.git' });
35
+ const raw = JSON.parse(await fs.readFile(path.join(dir, SYNC_CONFIG_FILE), 'utf8'));
36
+ assert.equal(raw.repoUrl, 'git@github.com:foo/bar.git');
37
+ assert.equal(raw.gitBin, undefined);
38
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
39
+ });
40
+
41
+ test('readSyncConfig:旧文件无 schemaVersion 字段 → 视为 v1 兼容读取', async () => {
42
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-cfg-legacy-'));
43
+ try {
44
+ // 旧版(无 schemaVersion 字段)
45
+ await fs.writeFile(
46
+ path.join(dir, SYNC_CONFIG_FILE),
47
+ JSON.stringify({ repoUrl: 'git@github.com:foo/bar.git', gitBin: '/bin/git' }),
48
+ 'utf8',
49
+ );
50
+ const loaded = await readSyncConfig(dir);
51
+ assert.deepEqual(loaded, { repoUrl: 'git@github.com:foo/bar.git', gitBin: '/bin/git' });
52
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
53
+ });
54
+
55
+ test('readSyncConfig:不支持的 schemaVersion → 返回 null(拒绝)', async () => {
56
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-cfg-badver-'));
57
+ try {
58
+ await fs.writeFile(
59
+ path.join(dir, SYNC_CONFIG_FILE),
60
+ JSON.stringify({ schemaVersion: 99, repoUrl: 'git@github.com:foo/bar.git' }),
61
+ 'utf8',
62
+ );
63
+ const loaded = await readSyncConfig(dir);
64
+ assert.equal(loaded, null);
65
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
66
+ });
67
+
68
+ test('readSyncConfig:schemaVersion 字段非数字 → 返回 null', async () => {
69
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-cfg-badtype-'));
70
+ try {
71
+ await fs.writeFile(
72
+ path.join(dir, SYNC_CONFIG_FILE),
73
+ JSON.stringify({ schemaVersion: 'v1', repoUrl: 'git@github.com:foo/bar.git' }),
74
+ 'utf8',
75
+ );
76
+ const loaded = await readSyncConfig(dir);
77
+ assert.equal(loaded, null);
78
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
79
+ });
80
+
81
+ test('readSyncConfig:损坏 JSON → 返回 null', async () => {
82
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-cfg-badjson-'));
83
+ try {
84
+ await fs.writeFile(path.join(dir, SYNC_CONFIG_FILE), '{not-json', 'utf8');
85
+ const loaded = await readSyncConfig(dir);
86
+ assert.equal(loaded, null);
87
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
88
+ });
89
+
90
+ test('readSyncConfig:文件不存在 → 返回 null(视为未配置)', async () => {
91
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-cfg-missing-'));
92
+ try {
93
+ const loaded = await readSyncConfig(dir);
94
+ assert.equal(loaded, null);
95
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
96
+ });
97
+
98
+ test('writeSyncConfig:自动创建目录', async () => {
99
+ const base = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-cfg-mkdir-'));
100
+ try {
101
+ const dir = path.join(base, 'nested', 'sync');
102
+ await writeSyncConfig(dir, { repoUrl: 'git@github.com:foo/bar.git' });
103
+ const loaded = await readSyncConfig(dir);
104
+ assert.ok(loaded);
105
+ } finally { await fs.rm(base, { recursive: true, force: true }); }
106
+ });
@@ -16,13 +16,19 @@ import { parseJsonSafe, stringifyJsonSafe } from '../utils/json.ts';
16
16
 
17
17
  export const SYNC_CONFIG_FILE = 'sync-config.json';
18
18
 
19
+ /** 当前 sync-config.json schema 版本号。 */
20
+ export const SYNC_CONFIG_SCHEMA_VERSION = 1;
21
+ /** 历史可读取版本:v1 缺 schemaVersion 字段做兼容读取(视为 v1)。 */
22
+ export const SYNC_CONFIG_SUPPORTED_VERSIONS: readonly number[] = [1];
23
+
19
24
  /** 持久化的仓库配置(不含任何凭据) */
20
25
  export interface SyncConfig {
21
26
  repoUrl: string;
22
27
  gitBin?: string;
23
28
  }
24
29
 
25
- /** 读取仓库配置;文件不存在/损坏 → null(视为未配置,UI 显示空表单) */
30
+ /** 读取仓库配置;文件不存在/损坏/不支持 schema → null(视为未配置,UI 显示空表单)。
31
+ * 兼容旧文件:缺 schemaVersion 字段视为 v1(保留字段)。 */
26
32
  export async function readSyncConfig(dir: string): Promise<SyncConfig | null> {
27
33
  const file = path.join(dir, SYNC_CONFIG_FILE);
28
34
  let raw: string;
@@ -31,19 +37,32 @@ export async function readSyncConfig(dir: string): Promise<SyncConfig | null> {
31
37
  } catch {
32
38
  return null;
33
39
  }
34
- const parsed = parseJsonSafe(raw);
40
+ let parsed: unknown;
41
+ try {
42
+ parsed = parseJsonSafe(raw);
43
+ } catch {
44
+ // 损坏 JSON / 体积超限 / 嵌套过深 → 视为未配置(不抛错)
45
+ return null;
46
+ }
35
47
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
36
48
  const obj = parsed as Record<string, unknown>;
49
+ // schemaVersion:缺省视为 v1(兼容旧文件);非缺省但不在支持列表 → 拒绝
50
+ if (obj['schemaVersion'] !== undefined && typeof obj['schemaVersion'] !== 'number') {
51
+ return null;
52
+ }
53
+ const ver = typeof obj['schemaVersion'] === 'number' ? obj['schemaVersion'] : 1;
54
+ if (!SYNC_CONFIG_SUPPORTED_VERSIONS.includes(ver)) return null;
37
55
  if (typeof obj['repoUrl'] !== 'string' || obj['repoUrl'] === '') return null;
38
56
  const cfg: SyncConfig = { repoUrl: obj['repoUrl'] };
39
57
  if (typeof obj['gitBin'] === 'string' && obj['gitBin'] !== '') cfg.gitBin = obj['gitBin'];
40
58
  return cfg;
41
59
  }
42
60
 
43
- /** 保存仓库配置(自动创建目录;覆盖旧值) */
61
+ /** 保存仓库配置(自动创建目录;覆盖旧值;写 schemaVersion=1) */
44
62
  export async function writeSyncConfig(dir: string, cfg: SyncConfig): Promise<void> {
45
63
  await fs.mkdir(dir, { recursive: true });
46
- await fs.writeFile(path.join(dir, SYNC_CONFIG_FILE), stringifyJsonSafe(cfg, { space: 2 }), 'utf8');
64
+ const payload = { schemaVersion: SYNC_CONFIG_SCHEMA_VERSION, repoUrl: cfg.repoUrl, ...(cfg.gitBin !== undefined && cfg.gitBin !== '' ? { gitBin: cfg.gitBin } : {}) };
65
+ await fs.writeFile(path.join(dir, SYNC_CONFIG_FILE), stringifyJsonSafe(payload, { space: 2 }), 'utf8');
47
66
  }
48
67
 
49
68
  /**