openclaw-sync-assistant 0.1.3 → 0.1.5

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/index.js CHANGED
@@ -6,97 +6,1110 @@ const {
6
6
  select,
7
7
  spinner,
8
8
  } = require("@clack/prompts");
9
+ const fs = require("fs");
9
10
  const pc = require("picocolors");
10
11
  const path = require("path");
11
12
  const os = require("os");
12
13
  const GitSyncService = require("./src/github-sync");
14
+ const P2PSyncService = require("./src/p2p-sync");
15
+ const {
16
+ normalizeSyncItems,
17
+ getRecommendedSyncItems,
18
+ getSyncItemDefinition,
19
+ } = require("./src/sync-items");
20
+
21
+ const CONFLICT_FILE_PATTERN = /\.(?:conflict|local-conflict|peer-conflict)\./;
22
+ const CONFLICT_FILE_DETAILS_PATTERN =
23
+ /^(.*)\.(conflict|local-conflict|peer-conflict)\.([^\\/]+)$/;
13
24
 
14
25
  let isWizardRunning = false;
15
26
  let gitSyncInstance = null;
16
27
 
28
+ function getStateDirFromContext(context) {
29
+ return (
30
+ context?.api?.paths?.stateDir ||
31
+ context?.paths?.stateDir ||
32
+ process.env.OPENCLAW_STATE_DIR ||
33
+ null
34
+ );
35
+ }
36
+
37
+ function getOpenClawDir(context) {
38
+ const stateDir = getStateDirFromContext(context);
39
+
40
+ if (stateDir) {
41
+ const normalizedStateDir = path.resolve(stateDir);
42
+ const lowerStateDir = normalizedStateDir.toLowerCase();
43
+
44
+ if (path.basename(lowerStateDir) === ".openclaw") {
45
+ return normalizedStateDir;
46
+ }
47
+
48
+ return path.dirname(normalizedStateDir);
49
+ }
50
+
51
+ return path.join(os.homedir(), ".openclaw");
52
+ }
53
+
54
+ function readJsonFile(filePath) {
55
+ if (!filePath || !fs.existsSync(filePath)) {
56
+ return null;
57
+ }
58
+
59
+ try {
60
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function readGitRemoteUrl(repoDir, remoteName = "origin") {
67
+ const configPath = path.join(repoDir, ".git", "config");
68
+ if (!fs.existsSync(configPath)) {
69
+ return null;
70
+ }
71
+
72
+ try {
73
+ const content = fs.readFileSync(configPath, "utf8");
74
+ const escapedRemoteName = remoteName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
75
+ const match = content.match(
76
+ new RegExp(`\\[remote "${escapedRemoteName}"\\][\\s\\S]*?url\\s*=\\s*(.+)`),
77
+ );
78
+ return match?.[1]?.trim() || null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ function loadFallbackConfig(context) {
85
+ const openclawDir = getOpenClawDir(context);
86
+ const legacyConfigPaths = [
87
+ path.join(openclawDir, "sync-data", "openclaw.json"),
88
+ path.join(openclawDir, "p2p-sync-data", "openclaw.json"),
89
+ ];
90
+
91
+ for (const configPath of legacyConfigPaths) {
92
+ const parsed = readJsonFile(configPath);
93
+ const entry =
94
+ parsed?.plugins?.entries?.["openclaw-sync-assistant"] ||
95
+ parsed?.["openclaw-sync-assistant"];
96
+
97
+ if (!entry || typeof entry !== "object") {
98
+ continue;
99
+ }
100
+
101
+ const syncMethod =
102
+ typeof entry.syncMethod === "string" ? entry.syncMethod : null;
103
+ const fallbackRepoDir = path.dirname(configPath);
104
+ const githubRepo =
105
+ readGitRemoteUrl(fallbackRepoDir) ||
106
+ (typeof entry.githubRepo === "string" ? entry.githubRepo : null);
107
+ const syncMode = typeof entry.syncMode === "string" ? entry.syncMode : null;
108
+ const syncSecret =
109
+ typeof entry.syncSecret === "string" ? entry.syncSecret : null;
110
+ const syncItems = normalizeSyncItems(entry.syncItems || []);
111
+
112
+ if (!syncMethod) {
113
+ continue;
114
+ }
115
+
116
+ return {
117
+ hasConfigured: true,
118
+ syncMethod,
119
+ githubRepo,
120
+ syncMode,
121
+ syncSecret,
122
+ syncItems,
123
+ };
124
+ }
125
+
126
+ return null;
127
+ }
128
+
129
+ function isConflictFile(filePath) {
130
+ return CONFLICT_FILE_PATTERN.test(filePath);
131
+ }
132
+
133
+ function getFileMetadata(filePath) {
134
+ if (!filePath || !fs.existsSync(filePath)) {
135
+ return {
136
+ exists: false,
137
+ size: null,
138
+ modifiedAt: null,
139
+ };
140
+ }
141
+
142
+ const stat = fs.statSync(filePath);
143
+
144
+ return {
145
+ exists: true,
146
+ size: stat.size,
147
+ modifiedAt: stat.mtime.toISOString(),
148
+ };
149
+ }
150
+
151
+ function formatFileMetadata(metadata) {
152
+ if (!metadata || !metadata.exists) {
153
+ return "不存在";
154
+ }
155
+
156
+ return `${metadata.size} B, ${metadata.modifiedAt}`;
157
+ }
158
+
159
+ function assessExperienceConsistency(syncItems) {
160
+ const normalizedItems = normalizeSyncItems(syncItems);
161
+ const recommendedItems = getRecommendedSyncItems();
162
+ const selectedSet = new Set(normalizedItems);
163
+ const missingItems = recommendedItems.filter((item) => !selectedSet.has(item));
164
+ const coverageRatio =
165
+ recommendedItems.length === 0
166
+ ? 1
167
+ : (recommendedItems.length - missingItems.length) / recommendedItems.length;
168
+ let level = "partial";
169
+
170
+ if (normalizedItems.length === 0) {
171
+ level = "unconfigured";
172
+ } else if (missingItems.length === 0) {
173
+ level = "full";
174
+ }
175
+
176
+ return {
177
+ level,
178
+ selectedItems: normalizedItems,
179
+ recommendedItems: [...recommendedItems],
180
+ missingItems,
181
+ coverageRatio,
182
+ };
183
+ }
184
+
185
+ function formatExperienceConsistencySummary(summary) {
186
+ if (!summary || summary.level === "unconfigured") {
187
+ return "未建立一致性保障";
188
+ }
189
+
190
+ if (summary.level === "full") {
191
+ return "完整";
192
+ }
193
+
194
+ return `部分一致 (${summary.selectedItems.length}/${summary.recommendedItems.length})`;
195
+ }
196
+
197
+ function assessExperienceBaseline(rootDir, syncItems) {
198
+ const selectedItems = normalizeSyncItems(syncItems);
199
+ const rootExists = Boolean(rootDir) && fs.existsSync(rootDir);
200
+ const items = getRecommendedSyncItems().map((item) => {
201
+ const definition = getSyncItemDefinition(item);
202
+ const paths = (definition?.paths || []).map((relativePath) => {
203
+ const targetPath = rootDir ? path.join(rootDir, relativePath) : null;
204
+ const exists = Boolean(targetPath) && fs.existsSync(targetPath);
205
+
206
+ return {
207
+ relativePath,
208
+ path: targetPath,
209
+ exists,
210
+ };
211
+ });
212
+ const matchedPaths = paths
213
+ .filter((entry) => entry.exists)
214
+ .map((entry) => entry.relativePath);
215
+ const exists = matchedPaths.length > 0;
216
+
217
+ return {
218
+ item,
219
+ paths,
220
+ selected: selectedItems.includes(item),
221
+ exists,
222
+ matchedPaths,
223
+ };
224
+ });
225
+ const selectedEntries = items.filter((entry) => entry.selected);
226
+ const presentSelectedItems = selectedEntries
227
+ .filter((entry) => entry.exists)
228
+ .map((entry) => entry.item);
229
+ const missingSelectedItems = selectedEntries
230
+ .filter((entry) => !entry.exists)
231
+ .map((entry) => entry.item);
232
+ let level = "partial";
233
+
234
+ if (selectedEntries.length === 0) {
235
+ level = "unconfigured";
236
+ } else if (missingSelectedItems.length === 0) {
237
+ level = "ready";
238
+ } else if (presentSelectedItems.length === 0) {
239
+ level = "missing";
240
+ }
241
+
242
+ return {
243
+ rootDir,
244
+ rootExists,
245
+ level,
246
+ selectedItems,
247
+ presentSelectedItems,
248
+ missingSelectedItems,
249
+ selectedCount: selectedEntries.length,
250
+ presentCount: presentSelectedItems.length,
251
+ items,
252
+ };
253
+ }
254
+
255
+ function formatExperienceBaselineSummary(baseline) {
256
+ if (!baseline || baseline.level === "unconfigured") {
257
+ return "未配置检查项";
258
+ }
259
+
260
+ if (baseline.level === "ready") {
261
+ return `完整 (${baseline.presentCount}/${baseline.selectedCount})`;
262
+ }
263
+
264
+ if (baseline.level === "missing") {
265
+ return `缺失 (${baseline.presentCount}/${baseline.selectedCount})`;
266
+ }
267
+
268
+ return `部分就绪 (${baseline.presentCount}/${baseline.selectedCount})`;
269
+ }
270
+
271
+ function findBaselineItem(baseline, item) {
272
+ return baseline?.items?.find((entry) => entry.item === item) || null;
273
+ }
274
+
275
+ function getMigrationReadinessSummary(level) {
276
+ if (level === "ready") {
277
+ return "就绪";
278
+ }
279
+
280
+ if (level === "at-risk") {
281
+ return "存在风险";
282
+ }
283
+
284
+ if (level === "partial") {
285
+ return "部分覆盖";
286
+ }
287
+
288
+ return "未验证";
289
+ }
290
+
291
+ function assessMigrationReadiness(status) {
292
+ if (!status) {
293
+ return {
294
+ level: "unavailable",
295
+ syncItems: [],
296
+ consistency: assessExperienceConsistency([]),
297
+ localBaseline: assessExperienceBaseline(null, []),
298
+ syncBaseline: assessExperienceBaseline(null, []),
299
+ automaticChecks: [],
300
+ manualChecks: [
301
+ {
302
+ label: "Gateway 运行状态",
303
+ state: "manual",
304
+ detail: "需在目标机器执行 openclaw status",
305
+ },
306
+ ],
307
+ failures: ["同步服务尚未启动或未完成配置"],
308
+ warnings: [],
309
+ };
310
+ }
311
+
312
+ const syncItems = normalizeSyncItems(status.syncItems);
313
+ const consistency = assessExperienceConsistency(syncItems);
314
+ const localBaseline = assessExperienceBaseline(status.openclawDir, syncItems);
315
+ const syncBaseline = assessExperienceBaseline(status.syncDir, syncItems);
316
+ const automaticChecks = getRecommendedSyncItems().map((item) => {
317
+ const definition = getSyncItemDefinition(item);
318
+ const localEntry = findBaselineItem(localBaseline, item);
319
+ const syncEntry = findBaselineItem(syncBaseline, item);
320
+ const selected = syncItems.includes(item);
321
+ let state = "not-covered";
322
+ let detail = "未纳入同步范围";
323
+
324
+ if (selected && localEntry?.exists && syncEntry?.exists) {
325
+ state = "ready";
326
+ detail = `本地与同步副本均已检测到 (${syncEntry.matchedPaths.join(", ")})`;
327
+ } else if (selected && localEntry?.exists) {
328
+ state = "partial";
329
+ detail = `本地已检测到,同步副本缺失 (${localEntry.matchedPaths.join(", ")})`;
330
+ } else if (selected && syncEntry?.exists) {
331
+ state = "partial";
332
+ detail = `同步副本已检测到,本地缺失 (${syncEntry.matchedPaths.join(", ")})`;
333
+ } else if (selected) {
334
+ state = "missing";
335
+ detail = "本地与同步副本都未检测到";
336
+ }
337
+
338
+ return {
339
+ item,
340
+ label: definition?.label || item,
341
+ sensitive: Boolean(definition?.sensitive),
342
+ selected,
343
+ state,
344
+ detail,
345
+ };
346
+ });
347
+ const manualChecks = [
348
+ {
349
+ label: "Gateway 运行状态",
350
+ state: "manual",
351
+ detail: "需在目标机器执行 openclaw status",
352
+ },
353
+ {
354
+ label: "Channels 连接状态",
355
+ state: syncItems.includes("ChannelState") ? "manual" : "not-covered",
356
+ detail: syncItems.includes("ChannelState")
357
+ ? "需在目标机器确认渠道仍保持登录"
358
+ : "未纳入 ChannelState,同步后大概率仍需重新登录",
359
+ },
360
+ {
361
+ label: "现有会话可见性",
362
+ state: syncItems.includes("Sessions") ? "manual" : "not-covered",
363
+ detail: syncItems.includes("Sessions")
364
+ ? "需在目标机器打开 Dashboard 检查会话列表"
365
+ : "未纳入 Sessions,历史对话与 Agent 状态不会完整迁移",
366
+ },
367
+ ];
368
+ const failures = [];
369
+ const warnings = [];
370
+
371
+ if (localBaseline.missingSelectedItems.length > 0) {
372
+ failures.push(`本地缺失: ${localBaseline.missingSelectedItems.join(", ")}`);
373
+ }
374
+
375
+ if (syncBaseline.missingSelectedItems.length > 0) {
376
+ failures.push(`同步副本缺失: ${syncBaseline.missingSelectedItems.join(", ")}`);
377
+ }
378
+
379
+ if (consistency.missingItems.length > 0) {
380
+ warnings.push(`未纳入迁移目标: ${consistency.missingItems.join(", ")}`);
381
+ }
382
+
383
+ const hasMissingSensitiveItems = automaticChecks.some(
384
+ (entry) => entry.selected && entry.sensitive && entry.state !== "ready",
385
+ );
386
+
387
+ if (hasMissingSensitiveItems) {
388
+ warnings.push("敏感同步项尚未全部就绪,请谨慎在新机器直接切换");
389
+ }
390
+
391
+ let level = "ready";
392
+
393
+ if (failures.length > 0) {
394
+ level = "at-risk";
395
+ } else if (warnings.length > 0) {
396
+ level = "partial";
397
+ }
398
+
399
+ return {
400
+ level,
401
+ syncItems,
402
+ consistency,
403
+ localBaseline,
404
+ syncBaseline,
405
+ automaticChecks,
406
+ manualChecks,
407
+ failures,
408
+ warnings,
409
+ };
410
+ }
411
+
412
+ function formatMigrationVerificationReport(report) {
413
+ if (!report || report.level === "unavailable") {
414
+ return [
415
+ "迁移验证结论: 未验证",
416
+ "失败项: 同步服务尚未启动或未完成配置",
417
+ "建议动作: 先运行 sync.setup 完成配置,再执行 sync.verify-migration",
418
+ ].join("\n");
419
+ }
420
+
421
+ const lines = [
422
+ `迁移验证结论: ${getMigrationReadinessSummary(report.level)}`,
423
+ `迁移目标覆盖: ${report.consistency.selectedItems.length}/${report.consistency.recommendedItems.length}`,
424
+ `已纳入同步: ${
425
+ report.syncItems.length > 0 ? report.syncItems.join(", ") : "无"
426
+ }`,
427
+ `本地关键数据: ${formatExperienceBaselineSummary(report.localBaseline)}`,
428
+ `同步副本关键数据: ${formatExperienceBaselineSummary(report.syncBaseline)}`,
429
+ ];
430
+
431
+ if (report.consistency.missingItems.length > 0) {
432
+ lines.push(`未纳入迁移目标: ${report.consistency.missingItems.join(", ")}`);
433
+ }
434
+
435
+ lines.push("自动检查:");
436
+ lines.push(
437
+ ...report.automaticChecks.map(
438
+ (entry) =>
439
+ `- ${entry.label}: ${
440
+ entry.state === "ready"
441
+ ? "已覆盖"
442
+ : entry.state === "partial"
443
+ ? "部分覆盖"
444
+ : entry.state === "missing"
445
+ ? "缺失"
446
+ : "未纳入"
447
+ } | ${entry.detail}`,
448
+ ),
449
+ );
450
+ lines.push("人工复核:");
451
+ lines.push(
452
+ ...report.manualChecks.map(
453
+ (entry) =>
454
+ `- ${entry.label}: ${
455
+ entry.state === "manual" ? "需人工确认" : "当前未覆盖"
456
+ } | ${entry.detail}`,
457
+ ),
458
+ );
459
+
460
+ if (report.failures.length > 0) {
461
+ lines.push(`失败项: ${report.failures.join(";")}`);
462
+ }
463
+
464
+ if (report.warnings.length > 0) {
465
+ lines.push(`风险提示: ${report.warnings.join(";")}`);
466
+ }
467
+
468
+ if (report.level === "ready") {
469
+ lines.push("建议动作: 可进入目标机器进行最终人工验收。");
470
+ } else {
471
+ lines.push("建议动作: 先补齐缺失同步项并执行一次完整同步,再复查迁移验证。");
472
+ }
473
+
474
+ return lines.join("\n");
475
+ }
476
+
477
+ function parseConflictFileDetails(relativePath) {
478
+ const directory = path.dirname(relativePath);
479
+ const fileName = path.basename(relativePath);
480
+ const match = fileName.match(CONFLICT_FILE_DETAILS_PATTERN);
481
+
482
+ if (!match) {
483
+ return {
484
+ baseRelativePath: relativePath,
485
+ conflictLabel: null,
486
+ conflictTimestamp: null,
487
+ };
488
+ }
489
+
490
+ const [, originalFileName, conflictLabel, conflictTimestamp] = match;
491
+
492
+ return {
493
+ baseRelativePath:
494
+ directory === "."
495
+ ? originalFileName
496
+ : path.join(directory, originalFileName),
497
+ conflictLabel,
498
+ conflictTimestamp,
499
+ };
500
+ }
501
+
502
+ async function loadConfig(context) {
503
+ const configApi = context?.api?.config;
504
+
505
+ if (!configApi || typeof configApi.get !== "function") {
506
+ return (
507
+ loadFallbackConfig(context) || {
508
+ hasConfigured: false,
509
+ syncMethod: null,
510
+ githubRepo: null,
511
+ syncMode: null,
512
+ syncSecret: null,
513
+ syncItems: [],
514
+ }
515
+ );
516
+ }
517
+
518
+ const syncMethod = await configApi.get("openclaw-sync-assistant.syncMethod");
519
+ const githubRepo = await configApi.get("openclaw-sync-assistant.githubRepo");
520
+ const syncMode = await configApi.get("openclaw-sync-assistant.syncMode");
521
+ const syncSecret = await configApi.get("openclaw-sync-assistant.syncSecret");
522
+ const syncItems =
523
+ normalizeSyncItems(
524
+ (await configApi.get("openclaw-sync-assistant.syncItems")) || [],
525
+ );
526
+
527
+ if (syncMethod) {
528
+ return {
529
+ hasConfigured: true,
530
+ syncMethod,
531
+ githubRepo,
532
+ syncMode,
533
+ syncSecret,
534
+ syncItems,
535
+ };
536
+ }
537
+
538
+ return (
539
+ loadFallbackConfig(context) || {
540
+ hasConfigured: false,
541
+ syncMethod: null,
542
+ githubRepo: null,
543
+ syncMode: null,
544
+ syncSecret: null,
545
+ syncItems: [],
546
+ }
547
+ );
548
+ }
549
+
550
+ function createSyncService(context, config) {
551
+ const openclawDir = getOpenClawDir(context);
552
+ const debug = process.env.DEBUG === "openclaw:sync";
553
+
554
+ if (config.syncMethod === "github" && config.githubRepo) {
555
+ return new GitSyncService(
556
+ path.join(openclawDir, "sync-data"),
557
+ config.githubRepo,
558
+ config.syncMode,
559
+ {
560
+ openclawDir,
561
+ syncItems: config.syncItems,
562
+ },
563
+ debug,
564
+ );
565
+ }
566
+
567
+ if (config.syncMethod === "p2p") {
568
+ return new P2PSyncService(
569
+ path.join(openclawDir, "p2p-sync-data"),
570
+ config.syncSecret,
571
+ config.syncMode,
572
+ {
573
+ openclawDir,
574
+ syncItems: config.syncItems,
575
+ },
576
+ debug,
577
+ );
578
+ }
579
+
580
+ return null;
581
+ }
582
+
583
+ function resolveSyncDir(openclawDir, syncMethod) {
584
+ if (syncMethod === "github") {
585
+ return path.join(openclawDir, "sync-data");
586
+ }
587
+
588
+ if (syncMethod === "p2p") {
589
+ return path.join(openclawDir, "p2p-sync-data");
590
+ }
591
+
592
+ return null;
593
+ }
594
+
595
+ function collectConflictFiles(rootDir, ignoredDirs = []) {
596
+ if (!rootDir || !fs.existsSync(rootDir)) {
597
+ return [];
598
+ }
599
+
600
+ const entries = [];
601
+ const normalizedIgnoredDirs = ignoredDirs.map((dirPath) => path.resolve(dirPath));
602
+ const walk = (currentDir) => {
603
+ for (const childName of fs.readdirSync(currentDir)) {
604
+ if (childName === ".git" || childName === ".p2p-storage") {
605
+ continue;
606
+ }
607
+
608
+ const childPath = path.join(currentDir, childName);
609
+ const normalizedChildPath = path.resolve(childPath);
610
+
611
+ if (normalizedIgnoredDirs.includes(normalizedChildPath)) {
612
+ continue;
613
+ }
614
+
615
+ const stat = fs.statSync(childPath);
616
+
617
+ if (stat.isDirectory()) {
618
+ walk(childPath);
619
+ continue;
620
+ }
621
+
622
+ if (stat.isFile() && isConflictFile(childName)) {
623
+ entries.push(childPath);
624
+ }
625
+ }
626
+ };
627
+
628
+ walk(rootDir);
629
+ return entries;
630
+ }
631
+
632
+ async function listConflictFiles(context) {
633
+ const config = await loadConfig(context);
634
+ const openclawDir = getOpenClawDir(context);
635
+ const syncDir = resolveSyncDir(openclawDir, config.syncMethod);
636
+ const roots = [
637
+ { label: "openclaw", dir: openclawDir },
638
+ ...(syncDir && syncDir !== openclawDir ? [{ label: "sync", dir: syncDir }] : []),
639
+ ];
640
+ const seenPaths = new Set();
641
+ const conflicts = [];
642
+
643
+ for (const root of roots) {
644
+ const ignoredDirs =
645
+ root.label === "openclaw" && syncDir && syncDir !== openclawDir ? [syncDir] : [];
646
+
647
+ for (const filePath of collectConflictFiles(root.dir, ignoredDirs)) {
648
+ const normalizedPath = path.resolve(filePath);
649
+
650
+ if (seenPaths.has(normalizedPath)) {
651
+ continue;
652
+ }
653
+
654
+ seenPaths.add(normalizedPath);
655
+ const relativePath = path.relative(root.dir, normalizedPath);
656
+ const details = parseConflictFileDetails(relativePath);
657
+ const resolutionTargetPath = path.join(root.dir, details.baseRelativePath);
658
+ conflicts.push({
659
+ scope: root.label,
660
+ filePath: normalizedPath,
661
+ relativePath,
662
+ baseRelativePath: details.baseRelativePath,
663
+ resolutionTargetPath,
664
+ conflictLabel: details.conflictLabel,
665
+ conflictTimestamp: details.conflictTimestamp,
666
+ conflictFileStats: getFileMetadata(normalizedPath),
667
+ targetFileStats: getFileMetadata(resolutionTargetPath),
668
+ });
669
+ }
670
+ }
671
+
672
+ return conflicts;
673
+ }
674
+
675
+ function formatConflictStatus(conflicts) {
676
+ if (!Array.isArray(conflicts) || conflicts.length === 0) {
677
+ return "未发现冲突文件。";
678
+ }
679
+
680
+ return [
681
+ `发现 ${conflicts.length} 个冲突文件:`,
682
+ ...conflicts.map(
683
+ (entry) => `- [${entry.scope}] ${entry.relativePath} -> ${entry.filePath}`,
684
+ ),
685
+ ].join("\n");
686
+ }
687
+
688
+ function getCommandOptions(context) {
689
+ return context?.commandOptions || context?.options || {};
690
+ }
691
+
692
+ function listConflictScopes(conflicts) {
693
+ return [...new Set(conflicts.map((entry) => entry.scope))];
694
+ }
695
+
696
+ function filterConflictsByScopes(conflicts, scopes) {
697
+ if (!Array.isArray(scopes) || scopes.length === 0) {
698
+ return conflicts;
699
+ }
700
+
701
+ const scopeSet = new Set(scopes);
702
+ return conflicts.filter((entry) => scopeSet.has(entry.scope));
703
+ }
704
+
705
+ function planConflictResolution(conflicts, options = {}) {
706
+ const strategy = options.strategy || "cleanup";
707
+
708
+ if (!["cleanup", "accept-conflict-copy", "keep"].includes(strategy)) {
709
+ throw new Error(`未知冲突处理策略: ${strategy}`);
710
+ }
711
+
712
+ const scopedConflicts = filterConflictsByScopes(conflicts, options.scopes);
713
+
714
+ const selectedPaths = Array.isArray(options.conflictFiles)
715
+ ? options.conflictFiles.map((filePath) => path.resolve(filePath))
716
+ : [];
717
+ const selectedPathSet =
718
+ selectedPaths.length > 0 ? new Set(selectedPaths) : null;
719
+
720
+ return {
721
+ strategy,
722
+ conflicts: selectedPathSet
723
+ ? scopedConflicts.filter((entry) =>
724
+ selectedPathSet.has(path.resolve(entry.filePath)),
725
+ )
726
+ : scopedConflicts,
727
+ };
728
+ }
729
+
730
+ function getConflictResolutionStrategyLabel(strategy) {
731
+ if (strategy === "cleanup") {
732
+ return "仅删除冲突副本";
733
+ }
734
+
735
+ if (strategy === "accept-conflict-copy") {
736
+ return "用冲突副本覆盖原文件";
737
+ }
738
+
739
+ if (strategy === "keep") {
740
+ return "暂不处理";
741
+ }
742
+
743
+ return strategy;
744
+ }
745
+
746
+ function formatConflictResolutionPreview(plan) {
747
+ if (!plan || plan.strategy === "keep") {
748
+ return "当前没有待执行的冲突处理操作。";
749
+ }
750
+
751
+ if (!Array.isArray(plan.conflicts) || plan.conflicts.length === 0) {
752
+ return `未选择任何冲突文件,策略: ${getConflictResolutionStrategyLabel(plan.strategy)}`;
753
+ }
754
+
755
+ const overwriteCount = plan.strategy === "accept-conflict-copy" ? plan.conflicts.length : 0;
756
+
757
+ return [
758
+ `即将处理 ${plan.conflicts.length} 个冲突文件`,
759
+ `处理策略: ${getConflictResolutionStrategyLabel(plan.strategy)}`,
760
+ ...(overwriteCount > 0
761
+ ? [`风险提示: 将尝试覆盖 ${overwriteCount} 个正式文件`]
762
+ : []),
763
+ ...plan.conflicts.map((entry) =>
764
+ plan.strategy === "cleanup"
765
+ ? `- 删除 [${entry.scope}] ${entry.relativePath} | 冲突副本: ${formatFileMetadata(
766
+ entry.conflictFileStats,
767
+ )}`
768
+ : `- 覆盖 [${entry.scope}] ${entry.baseRelativePath} <- ${entry.relativePath} | 目标文件: ${formatFileMetadata(
769
+ entry.targetFileStats,
770
+ )} | 冲突副本: ${formatFileMetadata(entry.conflictFileStats)}`,
771
+ ),
772
+ ].join("\n");
773
+ }
774
+
775
+ async function confirmConflictResolution(plan) {
776
+ console.log(formatConflictResolutionPreview(plan));
777
+
778
+ const confirmation = await select({
779
+ message: "确认执行以上冲突处理操作吗?",
780
+ options: [
781
+ {
782
+ value: "confirm",
783
+ label: "确认执行",
784
+ hint: "立即应用以上变更",
785
+ },
786
+ {
787
+ value: "cancel",
788
+ label: "取消",
789
+ hint: "保留现状,不修改任何文件",
790
+ },
791
+ ],
792
+ });
793
+
794
+ return confirmation === "confirm";
795
+ }
796
+
797
+ async function confirmOverwriteEntries(plan) {
798
+ const confirmedConflicts = [];
799
+
800
+ for (const entry of plan.conflicts) {
801
+ const confirmation = await select({
802
+ message: `确认覆盖 [${entry.scope}] ${entry.baseRelativePath} 吗?`,
803
+ options: [
804
+ {
805
+ value: "confirm",
806
+ label: "确认覆盖",
807
+ hint: `${entry.relativePath} -> ${entry.baseRelativePath}`,
808
+ },
809
+ {
810
+ value: "skip",
811
+ label: "跳过此文件",
812
+ hint: "保留该冲突副本,不覆盖正式文件",
813
+ },
814
+ ],
815
+ });
816
+
817
+ if (confirmation === "confirm") {
818
+ confirmedConflicts.push(entry);
819
+ }
820
+ }
821
+
822
+ return {
823
+ ...plan,
824
+ conflicts: confirmedConflicts,
825
+ };
826
+ }
827
+
828
+ async function promptConflictResolution(conflicts) {
829
+ const strategy = await select({
830
+ message: `检测到 ${conflicts.length} 个冲突文件,选择处理方式:`,
831
+ options: [
832
+ {
833
+ value: "cleanup",
834
+ label: "仅删除冲突副本",
835
+ hint: "推荐;保留当前正式文件不变",
836
+ },
837
+ {
838
+ value: "accept-conflict-copy",
839
+ label: "用冲突副本覆盖原文件",
840
+ hint: "会替换正式文件并删除冲突副本",
841
+ },
842
+ {
843
+ value: "keep",
844
+ label: "暂不处理",
845
+ hint: "退出,不做任何改动",
846
+ },
847
+ ],
848
+ });
849
+
850
+ if (typeof strategy === "symbol" || strategy === "keep") {
851
+ return {
852
+ strategy: "keep",
853
+ conflicts: [],
854
+ };
855
+ }
856
+
857
+ const availableScopes = listConflictScopes(conflicts);
858
+ let scopedConflicts = conflicts;
859
+
860
+ if (availableScopes.length > 1) {
861
+ const selectedScopes = await multiselect({
862
+ message: "请选择要处理的作用域:",
863
+ options: availableScopes.map((scope) => ({
864
+ value: scope,
865
+ label: scope,
866
+ hint: scope === "openclaw" ? "主目录冲突文件" : "同步目录冲突文件",
867
+ })),
868
+ required: false,
869
+ });
870
+
871
+ if (typeof selectedScopes === "symbol") {
872
+ return {
873
+ strategy: "keep",
874
+ conflicts: [],
875
+ };
876
+ }
877
+
878
+ scopedConflicts = filterConflictsByScopes(conflicts, selectedScopes);
879
+ }
880
+
881
+ const selectedPaths = await multiselect({
882
+ message: "请选择要处理的冲突文件:",
883
+ options: scopedConflicts.map((entry) => ({
884
+ value: entry.filePath,
885
+ label: `[${entry.scope}] ${entry.relativePath}`,
886
+ hint:
887
+ strategy === "cleanup"
888
+ ? "删除冲突副本"
889
+ : `覆盖 ${entry.baseRelativePath}`,
890
+ })),
891
+ required: false,
892
+ });
893
+
894
+ if (typeof selectedPaths === "symbol") {
895
+ return {
896
+ strategy: "keep",
897
+ conflicts: [],
898
+ };
899
+ }
900
+
901
+ const plan = planConflictResolution(scopedConflicts, {
902
+ strategy,
903
+ conflictFiles: selectedPaths,
904
+ });
905
+
906
+ if (plan.conflicts.length === 0) {
907
+ return plan;
908
+ }
909
+
910
+ const confirmed = await confirmConflictResolution(plan);
911
+
912
+ if (!confirmed) {
913
+ return {
914
+ strategy: "keep",
915
+ conflicts: [],
916
+ };
917
+ }
918
+
919
+ if (plan.strategy !== "accept-conflict-copy") {
920
+ return plan;
921
+ }
922
+
923
+ const confirmedPlan = await confirmOverwriteEntries(plan);
924
+
925
+ return confirmedPlan.conflicts.length > 0
926
+ ? confirmedPlan
927
+ : {
928
+ strategy: "keep",
929
+ conflicts: [],
930
+ };
931
+ }
932
+
933
+ async function resolveConflictFiles(context) {
934
+ const conflicts = await listConflictFiles(context);
935
+
936
+ if (conflicts.length === 0) {
937
+ return "未发现可处理的冲突文件。";
938
+ }
939
+
940
+ const options = getCommandOptions(context);
941
+ const resolutionPlan = options.strategy
942
+ ? planConflictResolution(conflicts, options)
943
+ : await promptConflictResolution(conflicts);
944
+
945
+ if (options.previewOnly || options.dryRun) {
946
+ return formatConflictResolutionPreview(resolutionPlan);
947
+ }
948
+
949
+ if (resolutionPlan.strategy === "keep") {
950
+ return "已保留当前冲突文件,未做修改。";
951
+ }
952
+
953
+ if (resolutionPlan.conflicts.length === 0) {
954
+ return "未选择任何冲突文件,未做修改。";
955
+ }
956
+
957
+ const results = [];
958
+ let successCount = 0;
959
+
960
+ for (const entry of resolutionPlan.conflicts) {
961
+ try {
962
+ if (resolutionPlan.strategy === "cleanup") {
963
+ fs.unlinkSync(entry.filePath);
964
+ results.push(`- 已删除: [${entry.scope}] ${entry.relativePath}`);
965
+ successCount += 1;
966
+ continue;
967
+ }
968
+
969
+ fs.mkdirSync(path.dirname(entry.resolutionTargetPath), { recursive: true });
970
+ fs.copyFileSync(entry.filePath, entry.resolutionTargetPath);
971
+ fs.unlinkSync(entry.filePath);
972
+ results.push(
973
+ `- 已覆盖: [${entry.scope}] ${entry.baseRelativePath} <- ${entry.relativePath}`,
974
+ );
975
+ successCount += 1;
976
+ } catch (error) {
977
+ results.push(
978
+ `- 处理失败: [${entry.scope}] ${entry.relativePath} (${error.message})`,
979
+ );
980
+ }
981
+ }
982
+
983
+ return [
984
+ `已处理 ${successCount}/${resolutionPlan.conflicts.length} 个冲突文件,策略: ${resolutionPlan.strategy}`,
985
+ ...results,
986
+ ].join("\n");
987
+ }
988
+
989
+ async function startSyncService(context) {
990
+ if (gitSyncInstance) {
991
+ return gitSyncInstance;
992
+ }
993
+
994
+ const config = await loadConfig(context);
995
+ gitSyncInstance = createSyncService(context, config);
996
+
997
+ if (!gitSyncInstance) {
998
+ return null;
999
+ }
1000
+
1001
+ await gitSyncInstance.init();
1002
+ return gitSyncInstance;
1003
+ }
1004
+
1005
+ function formatSyncStatus(status) {
1006
+ if (!status) {
1007
+ return "同步服务尚未启动。";
1008
+ }
1009
+
1010
+ const syncItems = normalizeSyncItems(status.syncItems);
1011
+ const experienceSummary = assessExperienceConsistency(syncItems);
1012
+ const localBaseline = assessExperienceBaseline(status.openclawDir, syncItems);
1013
+ const syncBaseline = assessExperienceBaseline(status.syncDir, syncItems);
1014
+ const migrationReadiness = assessMigrationReadiness({
1015
+ ...status,
1016
+ syncItems,
1017
+ });
1018
+ const lines = [
1019
+ `同步方式: ${status.transport}`,
1020
+ `同步模式: ${status.mode || "unknown"}`,
1021
+ `同步目录: ${status.syncDir}`,
1022
+ `同步内容: ${
1023
+ syncItems.length > 0
1024
+ ? syncItems.join(", ")
1025
+ : "未配置"
1026
+ }`,
1027
+ `体验一致性: ${formatExperienceConsistencySummary(experienceSummary)}`,
1028
+ `迁移准备度: ${getMigrationReadinessSummary(migrationReadiness.level)}`,
1029
+ `本地状态基线: ${formatExperienceBaselineSummary(localBaseline)}`,
1030
+ `同步副本基线: ${formatExperienceBaselineSummary(syncBaseline)}`,
1031
+ `同步中: ${status.isSyncing ? "是" : "否"}`,
1032
+ `最近同步: ${status.lastSyncAt || "无"}`,
1033
+ ];
1034
+
1035
+ if (experienceSummary.missingItems.length > 0) {
1036
+ lines.push(`建议补齐: ${experienceSummary.missingItems.join(", ")}`);
1037
+ }
1038
+
1039
+ if (localBaseline.missingSelectedItems.length > 0) {
1040
+ lines.push(`本地缺失: ${localBaseline.missingSelectedItems.join(", ")}`);
1041
+ }
1042
+
1043
+ if (syncBaseline.missingSelectedItems.length > 0) {
1044
+ lines.push(`同步副本缺失: ${syncBaseline.missingSelectedItems.join(", ")}`);
1045
+ }
1046
+
1047
+ if (status.transport === "github") {
1048
+ lines.push(`仓库: ${status.repo || "未配置"}`);
1049
+ }
1050
+
1051
+ if (status.transport === "p2p") {
1052
+ lines.push(`发现主题: ${status.discoveryKey || "未就绪"}`);
1053
+ lines.push(`连接节点: ${status.peerCount ?? 0}`);
1054
+ lines.push(`Drive 版本: ${status.driveVersion ?? 0}`);
1055
+ lines.push(`最近方向: ${status.lastSyncDirection || "无"}`);
1056
+ }
1057
+
1058
+ lines.push(`最近冲突: ${status.lastConflictAt || "无"}`);
1059
+ lines.push(
1060
+ `冲突文件: ${
1061
+ Array.isArray(status.lastConflictFiles) && status.lastConflictFiles.length > 0
1062
+ ? status.lastConflictFiles.join(", ")
1063
+ : "无"
1064
+ }`,
1065
+ );
1066
+ lines.push(`最近错误: ${status.lastError || "无"}`);
1067
+
1068
+ return lines.join("\n");
1069
+ }
1070
+
17
1071
  module.exports = {
18
1072
  /**
19
1073
  * 插件激活时的入口函数
20
1074
  * @param {object} context - OpenClaw 提供的上下文对象,包含 api 等
21
1075
  */
22
- async activate(context) {
1076
+ activate(context) {
23
1077
  if (process.env.DEBUG === "openclaw:sync") {
24
1078
  console.log("✅ openclaw-sync-assistant 插件已激活!");
25
1079
  }
26
1080
 
27
- // 防止在 OpenClaw 的某些生命周期中 activate 被并发/多次调用导致向导重复弹出
28
- if (isWizardRunning) return;
1081
+ void (async () => {
1082
+ // 防止在 OpenClaw 的某些生命周期中 activate 被并发/多次调用导致向导重复弹出
1083
+ if (isWizardRunning) return;
29
1084
 
30
- // 检查是否已经配置过
31
- let hasConfigured = false;
32
- let syncMethod = null;
33
- let githubRepo = null;
34
- let syncMode = null;
1085
+ // 检查是否已经配置过
1086
+ let config = await loadConfig(context);
35
1087
 
36
- if (context.api && context.api.config && context.api.config.get) {
37
- syncMethod = await context.api.config.get(
38
- "openclaw-sync-assistant.syncMethod",
39
- );
40
- githubRepo = await context.api.config.get(
41
- "openclaw-sync-assistant.githubRepo",
42
- );
43
- syncMode = await context.api.config.get(
44
- "openclaw-sync-assistant.syncMode",
45
- );
46
- if (syncMethod) {
47
- hasConfigured = true;
48
- }
49
- }
50
-
51
- // 判断当前进程是否为 OpenClaw 的长期后台进程 (daemon),而不是执行一次性命令 (如 plugins list/update)
52
- // 之前使用 process.argv 判断不够准确,有些环境下(如全局安装的 openclaw 命令)真正的参数可能被隐藏或包装
53
- // 更稳妥的做法是检查环境变量,通常 daemon 启动时会带有特定的环境或特征
54
- // 但如果环境变量不可靠,我们可以检查 process.argv 里面是否*不包含* 'plugins'
55
- // 因为执行 'openclaw plugins xxx' 时,'plugins' 一定在参数列表中
56
- const isPluginCommand = process.argv.some(
57
- (arg) => arg.includes("plugins") || arg.includes("plugin"),
58
- );
1088
+ const serviceMarker = process.env.OPENCLAW_SERVICE_MARKER;
1089
+ const serviceKind = (process.env.OPENCLAW_SERVICE_KIND || "").toLowerCase();
1090
+ const isGatewayService =
1091
+ Boolean(serviceMarker) && serviceKind.includes("gateway");
59
1092
 
60
- // 只有当不是执行插件管理命令,且未配置时,才触发引导向导
61
- if (!hasConfigured && !isPluginCommand) {
62
- isWizardRunning = true;
63
- try {
64
- await module.exports.runSetupWizard(context);
65
- // 向导结束后重新获取最新配置
66
- if (context.api && context.api.config && context.api.config.get) {
67
- syncMethod = await context.api.config.get(
68
- "openclaw-sync-assistant.syncMethod",
69
- );
70
- githubRepo = await context.api.config.get(
71
- "openclaw-sync-assistant.githubRepo",
72
- );
73
- syncMode = await context.api.config.get(
74
- "openclaw-sync-assistant.syncMode",
75
- );
1093
+ if (!config.hasConfigured && isGatewayService) {
1094
+ isWizardRunning = true;
1095
+ try {
1096
+ await module.exports.runSetupWizard(context);
1097
+ config = await loadConfig(context);
1098
+ } finally {
1099
+ isWizardRunning = false;
76
1100
  }
77
- } finally {
78
- isWizardRunning = false;
79
1101
  }
80
- }
81
1102
 
82
- // 启动实际的同步服务
83
- if (syncMethod === "github" && githubRepo) {
84
- const openclawDir = path.join(os.homedir(), ".openclaw");
85
- // 这里我们为了安全,默认只同步 config 和 workspace 等子目录,但为了简单起见,我们在 openclawDir 下创建一个 sync 专用文件夹
86
- // 或者直接同步整个 .openclaw 目录(需要在 github-sync.js 中排除一些不必要的缓存)
87
- // 推荐做法:在 .openclaw/sync-data 目录做软链接或者单独管理
88
- const syncDir = path.join(openclawDir, "sync-data");
1103
+ if (config.hasConfigured) {
1104
+ gitSyncInstance = createSyncService(context, config);
89
1105
 
90
- gitSyncInstance = new GitSyncService(
91
- syncDir,
92
- githubRepo,
93
- syncMode,
94
- process.env.DEBUG === "openclaw:sync",
95
- );
96
- await gitSyncInstance.init();
97
- } else if (syncMethod === "p2p") {
98
- console.log("[OpenClaw Sync] P2P 模式尚未在此版本中完全实现核心逻辑。");
99
- }
1106
+ if (gitSyncInstance) {
1107
+ await gitSyncInstance.init();
1108
+ }
1109
+ }
1110
+ })().catch((error) => {
1111
+ console.error("[openclaw-sync-assistant] activate failed:", error);
1112
+ });
100
1113
  },
101
1114
 
102
1115
  /**
@@ -171,11 +1184,21 @@ module.exports = {
171
1184
  }
172
1185
 
173
1186
  const syncItems = await multiselect({
174
- message: "请选择要同步的内容 (按空格勾选,回车确认):",
1187
+ message: "请选择要同步的内容 (按空格勾选,回车确认;若希望跨 OpenClaw 保持一致体验,建议全选):",
175
1188
  options: [
176
1189
  { value: "Config", label: "Config", hint: "OpenClaw 核心配置" },
177
1190
  { value: "Auth", label: "Auth", hint: "认证信息" },
178
- { value: "Workspace", label: "Workspace", hint: "工作区状态" },
1191
+ { value: "Sessions", label: "Sessions", hint: "会话历史与 Agent 状态" },
1192
+ {
1193
+ value: "ChannelState",
1194
+ label: "ChannelState",
1195
+ hint: "渠道登录状态,如 WhatsApp / Telegram",
1196
+ },
1197
+ {
1198
+ value: "WorkspaceFiles",
1199
+ label: "WorkspaceFiles",
1200
+ hint: "MEMORY.md、USER.md、skills、prompts 等工作区文件",
1201
+ },
179
1202
  ],
180
1203
  required: false,
181
1204
  });
@@ -220,11 +1243,19 @@ module.exports = {
220
1243
  }
221
1244
 
222
1245
  s.stop("配置已保存!");
1246
+ const experienceSummary = assessExperienceConsistency(syncItems);
223
1247
  outro(
224
1248
  pc.green(
225
1249
  `✔ 配置向导完成!后台服务将按 [${syncMethod.toUpperCase()}] 模式运行。`,
226
1250
  ),
227
1251
  );
1252
+ if (experienceSummary.level !== "full") {
1253
+ console.log(
1254
+ pc.yellow(
1255
+ `当前体验一致性保障为 ${formatExperienceConsistencySummary(experienceSummary)},建议补齐: ${experienceSummary.missingItems.join(", ") || "Config, Auth, Sessions, ChannelState, WorkspaceFiles"}`,
1256
+ ),
1257
+ );
1258
+ }
228
1259
  },
229
1260
 
230
1261
  /**
@@ -237,4 +1268,101 @@ module.exports = {
237
1268
  gitSyncInstance = null;
238
1269
  }
239
1270
  },
1271
+
1272
+ getSyncStatus() {
1273
+ if (!gitSyncInstance || typeof gitSyncInstance.getStatus !== "function") {
1274
+ return null;
1275
+ }
1276
+
1277
+ return gitSyncInstance.getStatus();
1278
+ },
1279
+
1280
+ formatSyncStatus,
1281
+ assessExperienceConsistency,
1282
+ formatExperienceConsistencySummary,
1283
+ assessExperienceBaseline,
1284
+ formatExperienceBaselineSummary,
1285
+ assessMigrationReadiness,
1286
+ formatMigrationVerificationReport,
1287
+ isConflictFile,
1288
+ getFileMetadata,
1289
+ formatFileMetadata,
1290
+ parseConflictFileDetails,
1291
+ collectConflictFiles,
1292
+ listConflictFiles,
1293
+ formatConflictStatus,
1294
+ listConflictScopes,
1295
+ filterConflictsByScopes,
1296
+ planConflictResolution,
1297
+ formatConflictResolutionPreview,
1298
+ resolveConflictFiles,
1299
+
1300
+ async executeCommand(commandId, context) {
1301
+ if (commandId === "sync.setup") {
1302
+ await module.exports.runSetupWizard(context);
1303
+ return "同步配置向导已完成。";
1304
+ }
1305
+
1306
+ if (commandId === "sync.status") {
1307
+ const service = await startSyncService(context);
1308
+ const status =
1309
+ service && typeof service.getStatus === "function"
1310
+ ? service.getStatus()
1311
+ : module.exports.getSyncStatus();
1312
+ const output = formatSyncStatus(status);
1313
+ console.log(output);
1314
+ return output;
1315
+ }
1316
+
1317
+ if (commandId === "sync.sync-now") {
1318
+ const service = await startSyncService(context);
1319
+
1320
+ if (!service || typeof service.performSync !== "function") {
1321
+ const output = "同步服务尚未配置,无法立即同步。";
1322
+ console.log(output);
1323
+ return output;
1324
+ }
1325
+
1326
+ if (service instanceof P2PSyncService) {
1327
+ await service.performSync("push");
1328
+ } else {
1329
+ await service.performSync();
1330
+ }
1331
+
1332
+ const output = formatSyncStatus(service.getStatus());
1333
+ console.log(output);
1334
+ return output;
1335
+ }
1336
+
1337
+ if (commandId === "sync.conflicts") {
1338
+ const output = formatConflictStatus(await listConflictFiles(context));
1339
+ console.log(output);
1340
+ return output;
1341
+ }
1342
+
1343
+ if (commandId === "sync.verify-migration") {
1344
+ const service = await startSyncService(context);
1345
+ const status =
1346
+ service && typeof service.getStatus === "function"
1347
+ ? service.getStatus()
1348
+ : module.exports.getSyncStatus();
1349
+ const output = formatMigrationVerificationReport(
1350
+ assessMigrationReadiness(status),
1351
+ );
1352
+ console.log(output);
1353
+ return output;
1354
+ }
1355
+
1356
+ if (commandId === "sync.resolve-conflicts") {
1357
+ const output = await resolveConflictFiles(context);
1358
+ console.log(output);
1359
+ return output;
1360
+ }
1361
+
1362
+ throw new Error(`未知命令: ${commandId}`);
1363
+ },
1364
+
1365
+ async runCommand(commandId, context) {
1366
+ return module.exports.executeCommand(commandId, context);
1367
+ },
240
1368
  };