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,76 @@
1
+ /**
2
+ * 分区风险映射(v1 静态表;可后续接入适配器声明的 risk 字段动态化)。
3
+ * - low :低风险——自动应用(无冲突时)
4
+ * settings / ui / providers / prompts
5
+ * - medium :中风险——待审队列(路径映射、安装、变更)
6
+ * workspaces / plugins / mcp
7
+ * - high :高风险——永不自动(含双向冲突、设备专属、凭据/秘密)
8
+ * credentialsStatus / secrets / sessions / pluginFiles / workspaces? 不,
9
+ * 按规划表 workspaces 归 medium(中风险),credentialsStatus/secrets 归 high;
10
+ * agentPresets / skills 暂归 low(受 sync 通道的 portable 过滤实际不会携带 skills)。
11
+ */
12
+ export const SECTION_RISK_TIER = {
13
+ settings: 'low',
14
+ ui: 'low',
15
+ providers: 'low',
16
+ prompts: 'low',
17
+ workspaces: 'medium',
18
+ plugins: 'medium',
19
+ mcp: 'medium',
20
+ skills: 'low',
21
+ agentPresets: 'low',
22
+ pluginFiles: 'high',
23
+ sessions: 'high',
24
+ credentialsStatus: 'high',
25
+ secrets: 'high',
26
+ };
27
+ /**
28
+ * 按风险等级 + firstSync 标志将 MergePlan 分流到三组。
29
+ * 纯函数:相同输入 → 相同输出;不读 fs、不发网络。
30
+ */
31
+ export function classifyMergePlan(plan, opts) {
32
+ const autoApply = [];
33
+ const review = [];
34
+ const skipped = [];
35
+ for (const r of plan.sections) {
36
+ // 跳过:决策为 skip(远端缺且本地未改 / 完全无变化)
37
+ if (r.decision === 'skip') {
38
+ skipped.push(r);
39
+ continue;
40
+ }
41
+ // 双向冲突永远进 review(最高优先级)
42
+ if (r.decision === 'conflict') {
43
+ review.push(r);
44
+ continue;
45
+ }
46
+ // 首次同步强制预览:所有非 skip 项一律进 review
47
+ if (opts.firstSync) {
48
+ review.push(r);
49
+ continue;
50
+ }
51
+ const tier = SECTION_RISK_TIER[r.id];
52
+ // 未知分区(防御性)一律进 review 而非自动应用
53
+ if (tier === undefined) {
54
+ review.push(r);
55
+ continue;
56
+ }
57
+ if (tier === 'low') {
58
+ // 低风险且决策明确(useRemote / keepLocal)→ 自动应用
59
+ autoApply.push(r);
60
+ }
61
+ else {
62
+ // medium / high → 待审
63
+ review.push(r);
64
+ }
65
+ }
66
+ return { autoApply, review, skipped };
67
+ }
68
+ export function summarizeApplyPlan(apply) {
69
+ return {
70
+ autoApplyCount: apply.autoApply.length,
71
+ reviewCount: apply.review.length,
72
+ skippedCount: apply.skipped.length,
73
+ totalCount: apply.autoApply.length + apply.review.length + apply.skipped.length,
74
+ };
75
+ }
76
+ //# sourceMappingURL=risk.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"risk.js","sourceRoot":"","sources":["../../src/sync/risk.ts"],"names":[],"mappings":"AAmBA;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAA0C;IACtE,QAAQ,EAAE,KAAK;IACf,EAAE,EAAE,KAAK;IACT,SAAS,EAAE,KAAK;IAChB,OAAO,EAAE,KAAK;IACd,UAAU,EAAE,QAAQ;IACpB,OAAO,EAAE,QAAQ;IACjB,GAAG,EAAE,QAAQ;IACb,MAAM,EAAE,KAAK;IACb,YAAY,EAAE,KAAK;IACnB,WAAW,EAAE,MAAM;IACnB,QAAQ,EAAE,MAAM;IAChB,iBAAiB,EAAE,MAAM;IACzB,OAAO,EAAE,MAAM;CAChB,CAAC;AAkBF;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAAe,EAAE,IAAqB;IACtE,MAAM,SAAS,GAAyB,EAAE,CAAC;IAC3C,MAAM,MAAM,GAAyB,EAAE,CAAC;IACxC,MAAM,OAAO,GAAyB,EAAE,CAAC;IACzC,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;QAC9B,gCAAgC;QAChC,IAAI,CAAC,CAAC,QAAQ,KAAK,MAAM,EAAE,CAAC;YAC1B,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YAChB,SAAS;QACX,CAAC;QACD,wBAAwB;QACxB,IAAI,CAAC,CAAC,QAAQ,KAAK,UAAU,EAAE,CAAC;YAC9B,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YACf,SAAS;QACX,CAAC;QACD,gCAAgC;QAChC,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;YACnB,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YACf,SAAS;QACX,CAAC;QACD,MAAM,IAAI,GAAG,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QACrC,6BAA6B;QAC7B,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YACvB,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YACf,SAAS;QACX,CAAC;QACD,IAAI,IAAI,KAAK,KAAK,EAAE,CAAC;YACnB,wCAAwC;YACxC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACpB,CAAC;aAAM,CAAC;YACN,qBAAqB;YACrB,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACjB,CAAC;IACH,CAAC;IACD,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;AACxC,CAAC;AAaD,MAAM,UAAU,kBAAkB,CAAC,KAAoB;IACrD,OAAO;QACL,cAAc,EAAE,KAAK,CAAC,SAAS,CAAC,MAAM;QACtC,WAAW,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM;QAChC,YAAY,EAAE,KAAK,CAAC,OAAO,CAAC,MAAM;QAClC,UAAU,EAAE,KAAK,CAAC,SAAS,CAAC,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM;KAChF,CAAC;AACJ,CAAC"}
@@ -1,13 +1,18 @@
1
1
  import type { MsgFunc } from '../core/messages.ts';
2
2
  export declare const SYNC_CONFIG_FILE = "sync-config.json";
3
+ /** 当前 sync-config.json schema 版本号。 */
4
+ export declare const SYNC_CONFIG_SCHEMA_VERSION = 1;
5
+ /** 历史可读取版本:v1 缺 schemaVersion 字段做兼容读取(视为 v1)。 */
6
+ export declare const SYNC_CONFIG_SUPPORTED_VERSIONS: readonly number[];
3
7
  /** 持久化的仓库配置(不含任何凭据) */
4
8
  export interface SyncConfig {
5
9
  repoUrl: string;
6
10
  gitBin?: string;
7
11
  }
8
- /** 读取仓库配置;文件不存在/损坏 → null(视为未配置,UI 显示空表单) */
12
+ /** 读取仓库配置;文件不存在/损坏/不支持 schema → null(视为未配置,UI 显示空表单)。
13
+ * 兼容旧文件:缺 schemaVersion 字段视为 v1(保留字段)。 */
9
14
  export declare function readSyncConfig(dir: string): Promise<SyncConfig | null>;
10
- /** 保存仓库配置(自动创建目录;覆盖旧值) */
15
+ /** 保存仓库配置(自动创建目录;覆盖旧值;写 schemaVersion=1) */
11
16
  export declare function writeSyncConfig(dir: string, cfg: SyncConfig): Promise<void>;
12
17
  /**
13
18
  * 仓库地址合法性校验(返回错误消息;null = 合法)。
@@ -12,7 +12,12 @@ import path from 'node:path';
12
12
  import { zhMsg } from "../core/messages.js";
13
13
  import { parseJsonSafe, stringifyJsonSafe } from "../utils/json.js";
14
14
  export const SYNC_CONFIG_FILE = 'sync-config.json';
15
- /** 读取仓库配置;文件不存在/损坏 null(视为未配置,UI 显示空表单) */
15
+ /** 当前 sync-config.json schema 版本号。 */
16
+ export const SYNC_CONFIG_SCHEMA_VERSION = 1;
17
+ /** 历史可读取版本:v1 缺 schemaVersion 字段做兼容读取(视为 v1)。 */
18
+ export const SYNC_CONFIG_SUPPORTED_VERSIONS = [1];
19
+ /** 读取仓库配置;文件不存在/损坏/不支持 schema → null(视为未配置,UI 显示空表单)。
20
+ * 兼容旧文件:缺 schemaVersion 字段视为 v1(保留字段)。 */
16
21
  export async function readSyncConfig(dir) {
17
22
  const file = path.join(dir, SYNC_CONFIG_FILE);
18
23
  let raw;
@@ -22,10 +27,24 @@ export async function readSyncConfig(dir) {
22
27
  catch {
23
28
  return null;
24
29
  }
25
- const parsed = parseJsonSafe(raw);
30
+ let parsed;
31
+ try {
32
+ parsed = parseJsonSafe(raw);
33
+ }
34
+ catch {
35
+ // 损坏 JSON / 体积超限 / 嵌套过深 → 视为未配置(不抛错)
36
+ return null;
37
+ }
26
38
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
27
39
  return null;
28
40
  const obj = parsed;
41
+ // schemaVersion:缺省视为 v1(兼容旧文件);非缺省但不在支持列表 → 拒绝
42
+ if (obj['schemaVersion'] !== undefined && typeof obj['schemaVersion'] !== 'number') {
43
+ return null;
44
+ }
45
+ const ver = typeof obj['schemaVersion'] === 'number' ? obj['schemaVersion'] : 1;
46
+ if (!SYNC_CONFIG_SUPPORTED_VERSIONS.includes(ver))
47
+ return null;
29
48
  if (typeof obj['repoUrl'] !== 'string' || obj['repoUrl'] === '')
30
49
  return null;
31
50
  const cfg = { repoUrl: obj['repoUrl'] };
@@ -33,10 +52,11 @@ export async function readSyncConfig(dir) {
33
52
  cfg.gitBin = obj['gitBin'];
34
53
  return cfg;
35
54
  }
36
- /** 保存仓库配置(自动创建目录;覆盖旧值) */
55
+ /** 保存仓库配置(自动创建目录;覆盖旧值;写 schemaVersion=1) */
37
56
  export async function writeSyncConfig(dir, cfg) {
38
57
  await fs.mkdir(dir, { recursive: true });
39
- await fs.writeFile(path.join(dir, SYNC_CONFIG_FILE), stringifyJsonSafe(cfg, { space: 2 }), 'utf8');
58
+ const payload = { schemaVersion: SYNC_CONFIG_SCHEMA_VERSION, repoUrl: cfg.repoUrl, ...(cfg.gitBin !== undefined && cfg.gitBin !== '' ? { gitBin: cfg.gitBin } : {}) };
59
+ await fs.writeFile(path.join(dir, SYNC_CONFIG_FILE), stringifyJsonSafe(payload, { space: 2 }), 'utf8');
40
60
  }
41
61
  /**
42
62
  * 仓库地址合法性校验(返回错误消息;null = 合法)。
@@ -1 +1 @@
1
- {"version":3,"file":"sync-config.js","sourceRoot":"","sources":["../../src/sync/sync-config.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sync-config.js","sourceRoot":"","sources":["../../src/sync/sync-config.ts"],"names":[],"mappings":"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"}
@@ -1,9 +1,12 @@
1
1
  import { Importer } from '../core/importer.ts';
2
- import type { ConfigAdapter, GlobalConflictStrategy, HostContext, PlanItem, PlanItemKind, SecretScanner } from '../core/types.ts';
2
+ import type { ConfigAdapter, GlobalConflictStrategy, HostContext, ImportAnalysis, ImportPlan, ImportResult, PlanItem, PlanItemKind, SecretScanner } from '../core/types.ts';
3
3
  import type { SectionId } from '../schema/types.ts';
4
4
  import type { SnapshotFs } from './fs.ts';
5
- import type { SyncTransport } from './transport.ts';
5
+ import type { SyncSnapshot, SyncSnapshotMeta, SyncTransport } from './transport.ts';
6
6
  import type { MsgFunc } from '../core/messages.ts';
7
+ import type { MergePlan } from './merge.ts';
8
+ import type { SyncApplyPlan } from './risk.ts';
9
+ import type { PlanItemProgress } from '../core/analyzer.ts';
7
10
  export interface SyncEngineOptions {
8
11
  ctx: HostContext;
9
12
  transport: SyncTransport;
@@ -64,6 +67,50 @@ export interface SyncPullReport {
64
67
  needsReview: boolean;
65
68
  message?: string;
66
69
  }
70
+ /** 一键同步预览结果(preview() 返回;临时 ZIP 由调用方持有并负责清理)。 */
71
+ export interface SyncPreviewResult {
72
+ ok: boolean;
73
+ /** 临时标准 ZIP 路径(apply-items 复用 executeImportPlan 需要;调用方清理) */
74
+ zipPath: string;
75
+ plan: ImportPlan | null;
76
+ analysis: ImportAnalysis | null;
77
+ snapshotId: string;
78
+ message?: string;
79
+ }
80
+ /** 自动应用执行器(P2b)报告 */
81
+ export interface ApplyReport {
82
+ ok: boolean;
83
+ /** 实际写入本地的分区 id 列表 */
84
+ applied: string[];
85
+ /** 应用前快照 id(UI 可借此一键回滚);失败时仍透传以便排查 */
86
+ restoreId: string;
87
+ /** 是否触发了整体回滚 */
88
+ rolledBack: boolean;
89
+ /** 失败时移到 review 队列的项(ReviewQueueItem 形态供 UI 直接渲染) */
90
+ review: import('./review-queue.ts').ReviewQueueItem[];
91
+ /** 来自 Importer.ImportResult 的 warnings;UI 可用于红条提示 */
92
+ warnings: string[];
93
+ }
94
+ /** applyItems 报告(§3.4 ApplyItemsResponse 的服务端形态) */
95
+ export interface ApplyItemsReport {
96
+ ok: boolean;
97
+ /** 实际写入的分区 id 列表(去重) */
98
+ applied: string[];
99
+ /** 未采纳的 itemId 列表 */
100
+ skipped?: string[];
101
+ /** 应用前快照 id(UI 一键回滚用;失败时仍透传以便排查) */
102
+ restoreId: string;
103
+ /** 任一失败是否整体回滚 */
104
+ rolledBack: boolean;
105
+ warnings: string[];
106
+ failed: {
107
+ itemId: string;
108
+ message?: string;
109
+ }[];
110
+ /** 透传 executeImportPlan 结果 */
111
+ result: ImportResult | null;
112
+ needsRestart?: boolean;
113
+ }
67
114
  export declare class SyncEngine {
68
115
  private readonly ctx;
69
116
  private readonly transport;
@@ -95,6 +142,45 @@ export declare class SyncEngine {
95
142
  * 绝不直接写配置、绝不执行导入;执行由上层按用户确认后走 Importer.executeImportPlan。
96
143
  */
97
144
  pull(opts?: SyncPullOptions): Promise<SyncPullReport>;
145
+ /** 列出远端已有快照(按 createdAt 升序)—— 供「选择历史快照」下拉。 */
146
+ listSnapshots(): Promise<SyncSnapshotMeta[]>;
147
+ /**
148
+ * 一键同步预览:拉取远端(最新或指定历史快照)→ 转临时 ZIP → Importer 分析出计划。
149
+ * 与 pull 的区别:临时 ZIP **不清理**(由调用方 / 会话持有,供 apply-items 复用),
150
+ * 并返回完整 plan/analysis/snapshotId 供会话登记。
151
+ * 调用方负责在会话消费或取消后清理 zipPath 所在目录。
152
+ */
153
+ preview(opts?: SyncPullOptions): Promise<SyncPreviewResult>;
154
+ merge(opts?: {
155
+ snapshotId?: string;
156
+ }): Promise<MergePlan>;
157
+ /**
158
+ * 记录祖先基线:写本地祖先副本 + 更新 sync-state(lastSnapshotId、每分区 hash/updatedAt、lastSyncAt、transport)+ 裁剪到 keep。
159
+ * 通常由 push() 在上传成功后调用,也可被上层(合并 apply 完成后)显式调用以更新基线到合并后的快照。
160
+ */
161
+ recordBaseline(snapshotId: string, sections: SyncSnapshot['sections'], nowIso?: string): Promise<void>;
162
+ /**
163
+ * 应用自动应用计划:写本地(走 Importer.executeImportPlan 标准路径;应用前调 backup.createSnapshot 兜底);
164
+ * 任一 auto 项失败 → 整体 rollback;成功后 recordBaseline 更新祖先基线。
165
+ * 返回 ApplyReport;不抛错到调用方(失败返回 ok:false + rolledBack:true)。
166
+ * 不再写 review-queue(§2.3/§7.4:待审语义改由同步历史 skipped 标记表达)。
167
+ */
168
+ applyMergePlan(apply: SyncApplyPlan): Promise<ApplyReport>;
169
+ /**
170
+ * applyItems:按用户对差异项的逐项决策执行导入(§3.4/§5.3)。
171
+ *
172
+ * 与 applyMergePlan 的区别:applyItems 接收「会话级临时 ZIP + 子计划」,
173
+ * 直接执行(backup.createSnapshot 兜底 → importer.executeImportPlan →
174
+ * 成功 recordBaseline / 失败 rollback),不构造中间 SyncApplyPlan。
175
+ *
176
+ * @param zipPath 会话级临时标准 ZIP(由 sync/sync 生成,包含采纳项的 merged payload)
177
+ * @param subPlan 子计划(仅含采纳项的 ImportPlan;globalStrategy/pathMappings/needsRestart 沿用会话 plan)
178
+ * @param opts 执行选项(onItem 进度回调)
179
+ * @returns ApplyItemsReport(ok/applied/restoreId/rolledBack/warnings/result)
180
+ */
181
+ applyItems(zipPath: string, subPlan: ImportPlan, opts?: {
182
+ onItem?: (info: PlanItemProgress) => void;
183
+ }): Promise<ApplyItemsReport>;
98
184
  /** 散文件快照 → 标准导出 ZIP(临时目录,用完即删):buildManifest + checksums + 平铺分区 */
99
185
  private snapshotToZip;
100
186
  }
@@ -30,6 +30,11 @@ import { createSnapshotFs, joinFs } from "./fs.js";
30
30
  import { writeSnapshotToDir } from "./layout.js";
31
31
  import { hashSection, loadSyncState, saveSyncState } from "./sync-state.js";
32
32
  import { msgOf, zhMsg } from "../core/messages.js";
33
+ import { DEFAULT_ANCESTOR_KEEP, loadAncestor, pruneAncestors, writeAncestor } from "./ancestor.js";
34
+ import { merge as mergeSections } from "./merge.js";
35
+ import { createSnapshot } from "../core/backup.js";
36
+ import { rollback } from "../core/rollback.js";
37
+ import { FileSnapshotStore } from "../core/backup.js";
33
38
  /** 同步通道结构性排除的敏感分区(即使 portable 判定有误也双保险拒绝;
34
39
  * 注:'credentials' 不是合法 SectionId——凭据状态分区为 credentialsStatus) */
35
40
  const FORBIDDEN_SECTIONS = ['credentialsStatus', 'secrets'];
@@ -133,14 +138,8 @@ export class SyncEngine {
133
138
  }
134
139
  // ② 上传远端(传输通道负责散文件落盘 + 提交推送)
135
140
  await this.transport.upload(snapshot);
136
- // ③ 更新 sync-state:每分区 hash + updatedAt;lastSyncAttransport 绑定
137
- const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
138
- for (const [sid, data] of Object.entries(sections)) {
139
- state.sections[sid] = { hash: hashSection(data), updatedAt: nowIso };
140
- }
141
- state.lastSyncAt = nowIso;
142
- state.transport = { type: this.transport.type, ref: this.transportRef };
143
- await saveSyncState(this.stateDir, state, this.fsx);
141
+ // ③ 记录祖先基线:写 sync-state(lastSnapshotId + 每分区 hash/updatedAt + lastSyncAt + transport)+ 裁剪
142
+ await this.recordBaseline(id, snapshot.sections, nowIso);
144
143
  return { ok: true, snapshotId: id, sections: Object.keys(sections), warnings };
145
144
  }
146
145
  /**
@@ -189,6 +188,332 @@ export class SyncEngine {
189
188
  await fs.rm(path.dirname(zipPath), { recursive: true, force: true });
190
189
  }
191
190
  }
191
+ /** 列出远端已有快照(按 createdAt 升序)—— 供「选择历史快照」下拉。 */
192
+ async listSnapshots() {
193
+ return this.transport.list();
194
+ }
195
+ /**
196
+ * 一键同步预览:拉取远端(最新或指定历史快照)→ 转临时 ZIP → Importer 分析出计划。
197
+ * 与 pull 的区别:临时 ZIP **不清理**(由调用方 / 会话持有,供 apply-items 复用),
198
+ * 并返回完整 plan/analysis/snapshotId 供会话登记。
199
+ * 调用方负责在会话消费或取消后清理 zipPath 所在目录。
200
+ */
201
+ async preview(opts = {}) {
202
+ if (!this.importer) {
203
+ throw new Error(this.msg('sync.missingImporter'));
204
+ }
205
+ const metas = await this.transport.list();
206
+ if (metas.length === 0) {
207
+ return { ok: false, zipPath: '', plan: null, analysis: null, snapshotId: '', message: this.msg('sync.remoteEmpty') };
208
+ }
209
+ const targetId = opts.snapshotId ?? metas[metas.length - 1].id;
210
+ const snapshot = await this.transport.download(targetId);
211
+ if (snapshot.manifest.containsSecrets) {
212
+ throw new Error(this.msg('sync.remoteContainsSecrets', { id: targetId }));
213
+ }
214
+ const portableIds = new Set(this.portableAdapters().map((a) => a.id));
215
+ const zipPath = await this.snapshotToZip(snapshot, portableIds);
216
+ const analysis = await this.importer.analyzeImport(zipPath);
217
+ const plan = await this.importer.createImportPlan(zipPath, {
218
+ strategy: opts.strategy ?? 'merge',
219
+ resolutions: {},
220
+ pathMappings: [],
221
+ });
222
+ return { ok: analysis.valid, zipPath, plan, analysis, snapshotId: targetId, message: undefined };
223
+ }
224
+ async merge(opts = {}) {
225
+ const metas = await this.transport.list();
226
+ if (metas.length === 0) {
227
+ return { sections: [] };
228
+ }
229
+ const targetId = opts.snapshotId ?? metas[metas.length - 1].id;
230
+ const remote = await this.transport.download(targetId);
231
+ if (remote.manifest.containsSecrets) {
232
+ throw new Error(`远端快照 ${targetId} 声明 containsSecrets=true,拒绝合并(同步通道永不携带秘密)`);
233
+ }
234
+ // 共同祖先:从 sync-state.lastSnapshotId 读本地副本;空 = 首次/无祖先
235
+ const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
236
+ let ancestor;
237
+ if (state.lastSnapshotId !== '' && this.localSnapshotsDir !== undefined) {
238
+ ancestor = await loadAncestor(this.localSnapshotsDir, state.lastSnapshotId, this.fsx);
239
+ }
240
+ // 本地当前:现场 export(含 s​e​c​r​e​t 剥离),与 push 同口径
241
+ const localSections = {};
242
+ for (const adapter of this.portableAdapters()) {
243
+ let section;
244
+ try {
245
+ section = await adapter.export(this.ctx, { includeSecrets: false });
246
+ }
247
+ catch {
248
+ continue;
249
+ }
250
+ const data = isFileSection(adapter.id) ? section.data : this.scanner.scanAndRedact(section.data).sanitized;
251
+ localSections[adapter.id] = data;
252
+ }
253
+ const portableIds = new Set(this.portableAdapters().map((a) => a.id));
254
+ const remotePortable = {};
255
+ for (const [id, data] of Object.entries(remote.sections)) {
256
+ if (portableIds.has(id))
257
+ remotePortable[id] = data;
258
+ }
259
+ const ancestorPortable = {};
260
+ if (ancestor) {
261
+ for (const [id, data] of Object.entries(ancestor.sections)) {
262
+ if (portableIds.has(id))
263
+ ancestorPortable[id] = data;
264
+ }
265
+ }
266
+ return mergeSections(localSections, remotePortable, ancestorPortable);
267
+ }
268
+ /**
269
+ * 记录祖先基线:写本地祖先副本 + 更新 sync-state(lastSnapshotId、每分区 hash/updatedAt、lastSyncAt、transport)+ 裁剪到 keep。
270
+ * 通常由 push() 在上传成功后调用,也可被上层(合并 apply 完成后)显式调用以更新基线到合并后的快照。
271
+ */
272
+ async recordBaseline(snapshotId, sections, nowIso) {
273
+ const ts = nowIso ?? this.now().toISOString();
274
+ // 1) 写本地祖先副本(如有 localSnapshotsDir)
275
+ if (this.localSnapshotsDir !== undefined) {
276
+ const snapshot = {
277
+ id: snapshotId,
278
+ createdAt: ts,
279
+ manifest: {
280
+ schemaVersion: CURRENT_SCHEMA_VERSION,
281
+ dshVersion: this.ctx.dshVersion,
282
+ platform: this.ctx.platform,
283
+ sectionIds: Object.keys(sections),
284
+ containsSecrets: false,
285
+ },
286
+ sections,
287
+ };
288
+ await writeAncestor(this.localSnapshotsDir, snapshot, this.fsx);
289
+ }
290
+ // 2) 更新 sync-state:lastSnapshotId + 每分区 hash/updatedAt + lastSyncAt + transport
291
+ const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
292
+ state.lastSnapshotId = snapshotId;
293
+ state.lastSyncAt = ts;
294
+ state.transport = { type: this.transport.type, ref: this.transportRef };
295
+ for (const [sid, data] of Object.entries(sections)) {
296
+ state.sections[sid] = { hash: hashSection(data), updatedAt: ts };
297
+ }
298
+ await saveSyncState(this.stateDir, state, this.fsx);
299
+ // 3) 裁剪祖先副本(最近 N 个)
300
+ if (this.localSnapshotsDir !== undefined) {
301
+ await pruneAncestors(this.localSnapshotsDir, DEFAULT_ANCESTOR_KEEP, this.fsx);
302
+ }
303
+ }
304
+ /**
305
+ * 应用自动应用计划:写本地(走 Importer.executeImportPlan 标准路径;应用前调 backup.createSnapshot 兜底);
306
+ * 任一 auto 项失败 → 整体 rollback;成功后 recordBaseline 更新祖先基线。
307
+ * 返回 ApplyReport;不抛错到调用方(失败返回 ok:false + rolledBack:true)。
308
+ * 不再写 review-queue(§2.3/§7.4:待审语义改由同步历史 skipped 标记表达)。
309
+ */
310
+ async applyMergePlan(apply) {
311
+ if (!this.importer) {
312
+ throw new Error('applyMergePlan: SyncEngine 缺少 importer(需在 options 中注​入)');
313
+ }
314
+ const appliedIds = apply.autoApply.map((r) => r.id);
315
+ // 0) 空 autoApply:无物可应用,直接短路(不构造 ZIP、不调 Importer)
316
+ if (appliedIds.length === 0) {
317
+ return { ok: true, applied: [], restoreId: '', rolledBack: false, review: [], warnings: [] };
318
+ }
319
+ // 1) 构造临时 ZIP(仅含 autoApply 项的 merged payload)+ 分析 + 计划
320
+ const portableIds = new Set(this.portableAdapters().map((a) => a.id));
321
+ const tempSnapshot = {
322
+ id: this.snapshotIdFn(),
323
+ createdAt: this.now().toISOString(),
324
+ manifest: {
325
+ schemaVersion: CURRENT_SCHEMA_VERSION,
326
+ dshVersion: this.ctx.dshVersion,
327
+ platform: this.ctx.platform,
328
+ sectionIds: apply.autoApply.map((r) => r.id),
329
+ containsSecrets: false,
330
+ },
331
+ sections: apply.autoApply.reduce((acc, r) => {
332
+ if (r.merged !== undefined)
333
+ acc[r.id] = r.merged;
334
+ return acc;
335
+ }, {}),
336
+ };
337
+ const zipPath = await this.snapshotToZip(tempSnapshot, portableIds);
338
+ try {
339
+ const analysis = await this.importer.analyzeImport(zipPath);
340
+ const plan = await this.importer.createImportPlan(zipPath, {
341
+ strategy: 'replace', // auto 路径:冲突已在外部分流;这里 replace = 接受导入值
342
+ resolutions: {},
343
+ pathMappings: [],
344
+ });
345
+ if (!plan.items.length) {
346
+ // 没有可执行项 → 视为 ok 但空 applied
347
+ return { ok: true, applied: [], restoreId: '', rolledBack: false, review: [], warnings: [] };
348
+ }
349
+ // 2) 兜底:先建快照(拿到 restoreId 给 UI 一键回滚用)
350
+ const store = new FileSnapshotStore({ dir: this.stateDir });
351
+ let snapshot;
352
+ try {
353
+ snapshot = await createSnapshot({
354
+ ctx: this.ctx,
355
+ plan,
356
+ sourceZip: zipPath,
357
+ store,
358
+ adapters: this.adapters,
359
+ });
360
+ }
361
+ catch (backupErr) {
362
+ return {
363
+ ok: false,
364
+ applied: [],
365
+ restoreId: '',
366
+ rolledBack: false,
367
+ review: [],
368
+ warnings: [`应用前快照失败:${backupErr instanceof Error ? backupErr.message : String(backupErr)}`],
369
+ };
370
+ }
371
+ // 3) 真正执行:Importer.executeImportPlan(rollbackOnError=true → 任一失败整体回滚)
372
+ const result = await this.importer.executeImportPlan(zipPath, plan, {
373
+ confirm: true,
374
+ rollbackOnError: true,
375
+ secretInputs: undefined,
376
+ decryptedCredentials: undefined,
377
+ });
378
+ if (!result.ok) {
379
+ try {
380
+ await rollback({ ctx: this.ctx, snapshot, store, adapters: this.adapters });
381
+ }
382
+ catch { /* noop */ }
383
+ // 失败路径不再写 review-queue(§7.4):历史 skipped/failed 标记由上层(路由/调度器)写入。
384
+ return {
385
+ ok: false,
386
+ applied: [],
387
+ restoreId: snapshot.id,
388
+ rolledBack: true,
389
+ review: [],
390
+ warnings: result.warnings ?? [],
391
+ };
392
+ }
393
+ // 5) 全成功 → recordBaseline(更新祖先基线指向合并后的快照)
394
+ const mergedSections = apply.autoApply.reduce((acc, r) => {
395
+ if (r.merged !== undefined)
396
+ acc[r.id] = r.merged;
397
+ return acc;
398
+ }, {});
399
+ await this.recordBaseline(tempSnapshot.id, mergedSections);
400
+ return {
401
+ ok: true,
402
+ applied: appliedIds,
403
+ restoreId: snapshot.id,
404
+ rolledBack: false,
405
+ review: [],
406
+ warnings: result.warnings ?? [],
407
+ };
408
+ }
409
+ finally {
410
+ try {
411
+ await fs.rm(path.dirname(zipPath), { recursive: true, force: true });
412
+ }
413
+ catch { /* noop */ }
414
+ }
415
+ }
416
+ /**
417
+ * applyItems:按用户对差异项的逐项决策执行导入(§3.4/§5.3)。
418
+ *
419
+ * 与 applyMergePlan 的区别:applyItems 接收「会话级临时 ZIP + 子计划」,
420
+ * 直接执行(backup.createSnapshot 兜底 → importer.executeImportPlan →
421
+ * 成功 recordBaseline / 失败 rollback),不构造中间 SyncApplyPlan。
422
+ *
423
+ * @param zipPath 会话级临时标准 ZIP(由 sync/sync 生成,包含采纳项的 merged payload)
424
+ * @param subPlan 子计划(仅含采纳项的 ImportPlan;globalStrategy/pathMappings/needsRestart 沿用会话 plan)
425
+ * @param opts 执行选项(onItem 进度回调)
426
+ * @returns ApplyItemsReport(ok/applied/restoreId/rolledBack/warnings/result)
427
+ */
428
+ async applyItems(zipPath, subPlan, opts = {}) {
429
+ if (!this.importer) {
430
+ throw new Error('applyItems: SyncEngine 缺少 importer(需在 options 中注​入)');
431
+ }
432
+ if (subPlan.items.length === 0) {
433
+ return { ok: true, applied: [], restoreId: '', rolledBack: false, warnings: [], failed: [], result: null };
434
+ }
435
+ // 兜底快照(拿到 restoreId 给 UI 一键回滚用)
436
+ const store = new FileSnapshotStore({ dir: this.stateDir });
437
+ let snapshot;
438
+ try {
439
+ snapshot = await createSnapshot({
440
+ ctx: this.ctx,
441
+ plan: subPlan,
442
+ sourceZip: zipPath,
443
+ store,
444
+ adapters: this.adapters,
445
+ });
446
+ }
447
+ catch (backupErr) {
448
+ return {
449
+ ok: false,
450
+ applied: [],
451
+ restoreId: '',
452
+ rolledBack: false,
453
+ warnings: [`应用前快照失败:${backupErr instanceof Error ? backupErr.message : String(backupErr)}`],
454
+ failed: subPlan.items.map((i) => ({ itemId: i.id })),
455
+ result: null,
456
+ };
457
+ }
458
+ // 真正执行:Importer.executeImportPlan(confirm:true + rollbackOnError:true → 任一失败整体回滚)
459
+ const result = await this.importer.executeImportPlan(zipPath, subPlan, {
460
+ confirm: true,
461
+ rollbackOnError: true,
462
+ secretInputs: undefined,
463
+ decryptedCredentials: undefined,
464
+ onItem: opts.onItem,
465
+ });
466
+ if (!result.ok) {
467
+ // executeImportPlan 已内部回滚(rollbackOnError);这里再显式 rollback 兜底(幂等)
468
+ try {
469
+ await rollback({ ctx: this.ctx, snapshot, store, adapters: this.adapters });
470
+ }
471
+ catch { /* noop */ }
472
+ return {
473
+ ok: false,
474
+ applied: [],
475
+ restoreId: snapshot.id,
476
+ rolledBack: true,
477
+ warnings: result.warnings ?? [],
478
+ failed: result.executed.filter((e) => e.status === 'failed').map((e) => ({ itemId: e.itemId, message: e.message })),
479
+ result,
480
+ };
481
+ }
482
+ // 成功:recordBaseline 更新祖先基线(合并后的快照)
483
+ const appliedIds = [...new Set(subPlan.items.map((i) => i.adapter))];
484
+ const mergedSections = {};
485
+ // 从 subPlan 中按 adapter 收集写入的分区数据(无法直接从 plan item 获取 merged data,
486
+ // 但这里不需要精确的 merged 数据——recordBaseline 只需要 sectionIds 与数据来源;
487
+ // 用现有应用后的 adapter export 作为快照数据更准确)
488
+ // 注意:recordBaseline 需要各分区内容 hash,因此导出当前各分区最新状态。
489
+ for (const adapter of this.portableAdapters()) {
490
+ if (!appliedIds.includes(adapter.id))
491
+ continue;
492
+ try {
493
+ const section = await adapter.export(this.ctx, { includeSecrets: false });
494
+ const data = isFileSection(adapter.id)
495
+ ? section.data
496
+ : this.scanner.scanAndRedact(section.data).sanitized;
497
+ mergedSections[adapter.id] = data;
498
+ }
499
+ catch {
500
+ // 单个分区导出失败不拖垮 recordBaseline(已应用的分区数据从 subPlan 兜底)
501
+ }
502
+ }
503
+ const snapshotId = this.snapshotIdFn();
504
+ await this.recordBaseline(snapshotId, mergedSections);
505
+ return {
506
+ ok: true,
507
+ applied: appliedIds,
508
+ skipped: subPlan.items.filter((i) => i.kind === 'Skip').map((i) => i.id),
509
+ restoreId: snapshot.id,
510
+ rolledBack: false,
511
+ warnings: result.warnings ?? [],
512
+ failed: [],
513
+ needsRestart: result.needsRestart,
514
+ result,
515
+ };
516
+ }
192
517
  /** 散文件快照 → 标准导出 ZIP(临时目录,用完即删):buildManifest + checksums + 平铺分区 */
193
518
  async snapshotToZip(snapshot, portableIds) {
194
519
  const entries = [];