wangchuan 5.5.0 → 5.6.1

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 (48) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/bin/wangchuan.js +1 -1
  4. package/dist/src/commands/init.d.ts.map +1 -1
  5. package/dist/src/commands/init.js +67 -22
  6. package/dist/src/commands/init.js.map +1 -1
  7. package/dist/src/core/config.js +1 -1
  8. package/dist/src/core/config.js.map +1 -1
  9. package/dist/src/core/crypto.d.ts.map +1 -1
  10. package/dist/src/core/crypto.js +3 -2
  11. package/dist/src/core/crypto.js.map +1 -1
  12. package/dist/src/core/migrate.d.ts.map +1 -1
  13. package/dist/src/core/migrate.js +1 -0
  14. package/dist/src/core/migrate.js.map +1 -1
  15. package/dist/src/core/sync-restore.d.ts +19 -0
  16. package/dist/src/core/sync-restore.d.ts.map +1 -0
  17. package/dist/src/core/sync-restore.js +339 -0
  18. package/dist/src/core/sync-restore.js.map +1 -0
  19. package/dist/src/core/sync-shared.d.ts +30 -0
  20. package/dist/src/core/sync-shared.d.ts.map +1 -0
  21. package/dist/src/core/sync-shared.js +397 -0
  22. package/dist/src/core/sync-shared.js.map +1 -0
  23. package/dist/src/core/sync-stage.d.ts +57 -0
  24. package/dist/src/core/sync-stage.d.ts.map +1 -0
  25. package/dist/src/core/sync-stage.js +429 -0
  26. package/dist/src/core/sync-stage.js.map +1 -0
  27. package/dist/src/core/sync.d.ts +22 -46
  28. package/dist/src/core/sync.d.ts.map +1 -1
  29. package/dist/src/core/sync.js +64 -1267
  30. package/dist/src/core/sync.js.map +1 -1
  31. package/dist/src/i18n.d.ts.map +1 -1
  32. package/dist/src/i18n.js +27 -2
  33. package/dist/src/i18n.js.map +1 -1
  34. package/dist/test/crypto.test.js +2 -2
  35. package/dist/test/crypto.test.js.map +1 -1
  36. package/dist/test/git.test.d.ts +5 -0
  37. package/dist/test/git.test.d.ts.map +1 -0
  38. package/dist/test/git.test.js +90 -0
  39. package/dist/test/git.test.js.map +1 -0
  40. package/dist/test/migrate.test.d.ts +9 -0
  41. package/dist/test/migrate.test.d.ts.map +1 -0
  42. package/dist/test/migrate.test.js +133 -0
  43. package/dist/test/migrate.test.js.map +1 -0
  44. package/dist/test/sync-lock.test.d.ts +5 -0
  45. package/dist/test/sync-lock.test.d.ts.map +1 -0
  46. package/dist/test/sync-lock.test.js +94 -0
  47. package/dist/test/sync-lock.test.js.map +1 -0
  48. package/package.json +2 -2
@@ -1,33 +1,25 @@
1
1
  /**
2
- * sync.ts — Core sync engine
2
+ * sync.ts — Core sync engine barrel module
3
3
  *
4
- * Three directions:
5
- * stageToRepo workspace local repo directory (pre-push staging)
6
- * restoreFromRepo local repo directory workspace (post-pull restore)
7
- * diff compare both sides, return diff summary
4
+ * Re-exports from sub-modules:
5
+ * sync-shared.ts — cross-agent sharing distribution
6
+ * sync-stage.ts — push direction (workspace repo)
7
+ * sync-restore.ts — pull direction (repo workspace)
8
8
  *
9
- * Supports two-tier sync:
10
- * shared — cross-agent sharing (skills, MCP templates, shared memory)
11
- * agents/* — per-agent cross-environment sync
12
- *
13
- * All methods accept an optional agent filter parameter to operate on a specific agent's files only.
9
+ * Keeps shared utilities: expandHome, ignore patterns, file entry building, diff.
14
10
  */
15
11
  import fs from 'fs';
16
12
  import path from 'path';
17
13
  import os from 'os';
18
- import crypto from 'crypto';
19
14
  import { cryptoEngine } from './crypto.js';
20
- import { keyFingerprint } from './crypto.js';
21
15
  import { jsonField } from './json-field.js';
22
- import { validator } from '../utils/validator.js';
23
- import { logger } from '../utils/logger.js';
24
16
  import { walkDir as walkDirBase } from '../utils/fs.js';
25
- import { askConflict } from '../utils/prompt.js';
26
- import { threeWayMerge } from './merge.js';
27
- import { gitEngine } from './git.js';
28
- import { t } from '../i18n.js';
29
- import chalk from 'chalk';
30
17
  import { AGENT_NAMES } from '../types.js';
18
+ // ── Re-exports from sub-modules ────────────────────────────────────
19
+ export { distributeShared, loadPendingDeletions, clearPendingDeletions, loadPendingDistributions, clearPendingDistributions, processPendingDistributions, } from './sync-shared.js';
20
+ export { stageToRepo, writeSyncMeta, readSyncMeta, writeIntegrity, verifyIntegrity, writeKeyFingerprint, verifyKeyFingerprint, detectStaleFiles, deleteStaleFiles, logProgress, contentUnchanged, encryptedPlaintextUnchanged, readPlaintextHashes, loadStageProgress, clearStageProgress, } from './sync-stage.js';
21
+ export { restoreFromRepo, backupBeforeRestore, rotateBackups, } from './sync-restore.js';
22
+ // ── Shared utilities ───────────────────────────────────────────────
31
23
  export function expandHome(p) {
32
24
  if (p.startsWith('~'))
33
25
  return path.join(os.homedir(), p.slice(1));
@@ -66,16 +58,13 @@ export function resetIgnoreCache() {
66
58
  */
67
59
  export function matchesIgnore(relPath, patterns) {
68
60
  const basename = path.basename(relPath);
69
- // Normalize to forward slashes for matching
70
61
  const normalized = relPath.split(path.sep).join('/');
71
62
  for (const pattern of patterns) {
72
63
  if (pattern.includes('/') || pattern.includes('**')) {
73
- // Path pattern — match against the full relative path
74
64
  if (globMatch(normalized, pattern))
75
65
  return true;
76
66
  }
77
67
  else {
78
- // Basename-only pattern — match against filename
79
68
  if (globMatch(basename, pattern))
80
69
  return true;
81
70
  }
@@ -84,15 +73,12 @@ export function matchesIgnore(relPath, patterns) {
84
73
  }
85
74
  /**
86
75
  * Minimal glob matcher supporting `*` (any chars except `/`) and `**` (any path segments).
87
- * Converts the glob to a regex for matching.
88
76
  */
89
77
  function globMatch(str, pattern) {
90
- // Build regex from glob pattern
91
78
  let regex = '^';
92
79
  let i = 0;
93
80
  while (i < pattern.length) {
94
81
  if (pattern[i] === '*' && pattern[i + 1] === '*') {
95
- // ** matches any path segments
96
82
  if (pattern[i + 2] === '/') {
97
83
  regex += '(?:.+/)?';
98
84
  i += 3;
@@ -111,7 +97,6 @@ function globMatch(str, pattern) {
111
97
  i++;
112
98
  }
113
99
  else {
114
- // Escape regex special chars
115
100
  regex += pattern[i].replace(/[.+^${}()|[\]\\]/g, '\\$&');
116
101
  i++;
117
102
  }
@@ -120,7 +105,7 @@ function globMatch(str, pattern) {
120
105
  return new RegExp(regex).test(str);
121
106
  }
122
107
  /** Walk directory with .wangchuanignore filtering */
123
- function walkDir(dirAbs) {
108
+ export function walkDir(dirAbs) {
124
109
  const ignorePatterns = loadIgnorePatterns();
125
110
  const filter = ignorePatterns.length > 0
126
111
  ? (relPath) => !matchesIgnore(relPath, ignorePatterns)
@@ -137,18 +122,7 @@ function deduplicateEntries(entries) {
137
122
  return true;
138
123
  });
139
124
  }
140
- /** Log a colorized progress line for stage/restore operations */
141
- function logProgress(index, total, tag, filePath) {
142
- const counter = chalk.gray(`[${index}/${total}]`);
143
- const tagColors = {
144
- enc: chalk.magenta(t('sync.progress.enc')),
145
- field: chalk.yellow(t('sync.progress.field')),
146
- decrypted: chalk.cyan(t('sync.progress.decrypted')),
147
- copy: chalk.white(t('sync.progress.copy')),
148
- };
149
- const coloredTag = tagColors[tag] ?? tag;
150
- logger.info(` ${counter} ${coloredTag} ${chalk.white(filePath)}`);
151
- }
125
+ // ── File entry building ────────────────────────────────────────────
152
126
  /**
153
127
  * Build syncFiles + syncDirs + jsonFields entries for a given agent profile.
154
128
  */
@@ -156,7 +130,6 @@ function buildAgentEntries(name, profile, repoDirBase) {
156
130
  const entries = [];
157
131
  const wsPath = expandHome(profile.workspacePath);
158
132
  const repoPrefix = `agents/${name}`;
159
- // syncFiles
160
133
  for (const item of profile.syncFiles) {
161
134
  const suffix = item.encrypt ? '.enc' : '';
162
135
  entries.push({
@@ -167,7 +140,6 @@ function buildAgentEntries(name, profile, repoDirBase) {
167
140
  agentName: name,
168
141
  });
169
142
  }
170
- // syncDirs
171
143
  for (const dir of (profile.syncDirs ?? [])) {
172
144
  const scanBase = repoDirBase
173
145
  ? path.join(repoDirBase, repoPrefix, dir.src)
@@ -186,7 +158,6 @@ function buildAgentEntries(name, profile, repoDirBase) {
186
158
  });
187
159
  }
188
160
  }
189
- // jsonFields — field-level JSON extraction
190
161
  for (const jf of (profile.jsonFields ?? [])) {
191
162
  const suffix = jf.encrypt ? '.enc' : '';
192
163
  entries.push({
@@ -212,7 +183,6 @@ function buildSharedEntries(cfg, repoDirBase) {
212
183
  if (!shared)
213
184
  return entries;
214
185
  const profiles = cfg.profiles.default;
215
- // ── shared skills: multi-source aggregation ────────────────
216
186
  for (const source of shared.skills.sources) {
217
187
  const p = profiles[source.agent];
218
188
  if (!p.enabled)
@@ -224,7 +194,6 @@ function buildSharedEntries(cfg, repoDirBase) {
224
194
  if (!fs.existsSync(scanBase))
225
195
  continue;
226
196
  for (const relFile of walkDir(scanBase)) {
227
- // Skip system files like .DS_Store
228
197
  if (path.basename(relFile).startsWith('.'))
229
198
  continue;
230
199
  entries.push({
@@ -236,7 +205,6 @@ function buildSharedEntries(cfg, repoDirBase) {
236
205
  });
237
206
  }
238
207
  }
239
- // ── shared MCP: extract mcpServers from each agent's JSON ──
240
208
  for (const source of shared.mcp.sources) {
241
209
  const p = profiles[source.agent];
242
210
  if (!p.enabled)
@@ -256,7 +224,6 @@ function buildSharedEntries(cfg, repoDirBase) {
256
224
  },
257
225
  });
258
226
  }
259
- // ── shared syncFiles ───────────────────────────────────────
260
227
  for (const item of shared.syncFiles) {
261
228
  const wsPath = expandHome(item.workspacePath);
262
229
  const suffix = item.encrypt ? '.enc' : '';
@@ -268,7 +235,6 @@ function buildSharedEntries(cfg, repoDirBase) {
268
235
  agentName: 'shared',
269
236
  });
270
237
  }
271
- // ── shared agents: multi-source aggregation (same pattern as skills) ──
272
238
  if (shared.agents) {
273
239
  for (const source of shared.agents.sources) {
274
240
  const p = profiles[source.agent];
@@ -297,8 +263,6 @@ function buildSharedEntries(cfg, repoDirBase) {
297
263
  }
298
264
  /**
299
265
  * Apply --only / --exclude filtering to file entries.
300
- * --only: keep entries whose repoRel contains any of the patterns (substring match)
301
- * --exclude: drop entries whose repoRel contains any of the patterns
302
266
  */
303
267
  function applyFilter(entries, filter) {
304
268
  if (!filter)
@@ -316,22 +280,16 @@ function applyFilter(entries, filter) {
316
280
  }
317
281
  /**
318
282
  * Build the list of file entries to sync (single source of truth for all sync directions).
319
- *
320
- * @param repoDirBase Pass local repo root to scan syncDirs from repo side (pull direction)
321
- * @param agent Only return entries for specified agent, undefined = all
322
- * @param filter Optional --only / --exclude filtering
323
283
  */
324
284
  export function buildFileEntries(cfg, repoDirBase, agent, filter) {
325
285
  const entries = [];
326
286
  const profiles = cfg.profiles.default;
327
- // per-agent entries (built-in agents)
328
287
  for (const name of AGENT_NAMES) {
329
288
  const p = profiles[name];
330
289
  if (!p.enabled || (agent && agent !== name))
331
290
  continue;
332
291
  entries.push(...buildAgentEntries(name, p, repoDirBase));
333
292
  }
334
- // custom agents (config-driven, basic file sync only)
335
293
  if (cfg.customAgents) {
336
294
  for (const [name, profile] of Object.entries(cfg.customAgents)) {
337
295
  if (agent && agent !== name)
@@ -339,781 +297,77 @@ export function buildFileEntries(cfg, repoDirBase, agent, filter) {
339
297
  entries.push(...buildAgentEntries(name, profile, repoDirBase));
340
298
  }
341
299
  }
342
- // shared entries (excluded when --agent filter is active, since shared belongs to no single agent)
343
300
  if (!agent) {
344
301
  entries.push(...buildSharedEntries(cfg, repoDirBase));
345
302
  }
346
303
  return applyFilter(deduplicateEntries(entries), filter);
347
304
  }
348
- /**
349
- * Distribute shared content (skills, MCP configs, custom agents) to each agent's local directory.
350
- * Skills and custom agents: collect pending distributions for user confirmation (no files written).
351
- * MCP configs: distributed automatically (low-risk config merges).
352
- * Called before push to prepare cross-agent sharing.
353
- */
354
- function distributeShared(cfg) {
355
- const shared = cfg.shared;
356
- if (!shared)
357
- return;
358
- const profiles = cfg.profiles.default;
359
- const pendingItems = [];
360
- // ── Skills: collect pending distributions (no file writes) ──────
361
- {
362
- // Collect each agent's current skill set
363
- const agentSkills = new Map(); // agent → relPath → absPath
364
- for (const source of shared.skills.sources) {
365
- const p = profiles[source.agent];
366
- if (!p.enabled)
367
- continue;
368
- const skillsDir = path.join(expandHome(p.workspacePath), source.dir);
369
- const skills = new Map();
370
- if (fs.existsSync(skillsDir)) {
371
- for (const relFile of walkDir(skillsDir)) {
372
- if (path.basename(relFile).startsWith('.'))
373
- continue;
374
- skills.set(relFile, path.join(skillsDir, relFile));
375
- }
376
- }
377
- agentSkills.set(source.agent, skills);
378
- }
379
- // Merge all agents' skills — for each relPath, pick the NEWEST version (latest mtime)
380
- const allSkills = new Map(); // relPath → absPath (newest)
381
- const allSkillMtimes = new Map();
382
- const allSkillOwner = new Map(); // relPath → agent name that owns newest
383
- for (const [agentName, skills] of agentSkills) {
384
- for (const [rel, abs] of skills) {
385
- try {
386
- const mtime = fs.statSync(abs).mtimeMs;
387
- if (!allSkills.has(rel) || mtime > allSkillMtimes.get(rel)) {
388
- allSkills.set(rel, abs);
389
- allSkillMtimes.set(rel, mtime);
390
- allSkillOwner.set(rel, agentName);
391
- }
392
- }
393
- catch {
394
- if (!allSkills.has(rel)) {
395
- allSkills.set(rel, abs);
396
- allSkillOwner.set(rel, agentName);
397
- }
398
- }
399
- }
400
- }
401
- // Build ownership map: relPath → set of agent names that have it
402
- const agentHasSkill = new Map();
403
- for (const [agentName, skills] of agentSkills) {
404
- for (const rel of skills.keys()) {
405
- if (!agentHasSkill.has(rel))
406
- agentHasSkill.set(rel, new Set());
407
- agentHasSkill.get(rel).add(agentName);
408
- }
409
- }
410
- const allSourceAgents = shared.skills.sources.map(s => s.agent).filter(a => profiles[a].enabled);
411
- // Detect pending distributions for each skill
412
- for (const [relFile, srcAbs] of allSkills) {
413
- const owners = agentHasSkill.get(relFile) ?? new Set();
414
- const sourceAgent = allSkillOwner.get(relFile) ?? '';
415
- for (const targetAgent of allSourceAgents) {
416
- if (targetAgent === sourceAgent)
417
- continue;
418
- const targetHasIt = owners.has(targetAgent);
419
- const targetSkillsDir = path.join(expandHome(profiles[targetAgent].workspacePath), shared.skills.sources.find(s => s.agent === targetAgent).dir);
420
- const targetPath = path.join(targetSkillsDir, relFile);
421
- if (!targetHasIt) {
422
- // Target doesn't have this skill.
423
- // If only one agent has it → genuinely new skill → "add" pending
424
- // If multiple agents have it but this one doesn't → likely deleted → "delete" pending
425
- if (owners.size === 1) {
426
- pendingItems.push({
427
- kind: 'skill',
428
- action: 'add',
429
- relFile,
430
- sourceAgent,
431
- targetAgents: [targetAgent],
432
- sourceAbs: srcAbs,
433
- });
434
- }
435
- // Multi-owner missing case handled in the delete detection loop below
436
- }
437
- else {
438
- // Target has it — check if content differs (needs update)
439
- if (path.resolve(targetPath) === path.resolve(srcAbs))
440
- continue;
441
- try {
442
- if (fs.readFileSync(targetPath).equals(fs.readFileSync(srcAbs)))
443
- continue;
444
- }
445
- catch { /* fall through */ }
446
- pendingItems.push({
447
- kind: 'skill',
448
- action: 'update',
449
- relFile,
450
- sourceAgent,
451
- targetAgents: [targetAgent],
452
- sourceAbs: srcAbs,
453
- });
454
- }
455
- }
456
- }
457
- // Detect delete cases: skill missing from some agents but present in multiple others
458
- for (const [relFile, owners] of agentHasSkill) {
459
- const missingFrom = allSourceAgents.filter(a => !owners.has(a));
460
- if (missingFrom.length > 0 && owners.size > 1) {
461
- const srcAgent = [...owners][0];
462
- const srcAbs = agentSkills.get(srcAgent)?.get(relFile) ?? '';
463
- for (const target of missingFrom) {
464
- pendingItems.push({
465
- kind: 'skill',
466
- action: 'delete',
467
- relFile,
468
- sourceAgent: srcAgent,
469
- targetAgents: [target],
470
- sourceAbs: srcAbs,
471
- });
472
- }
473
- }
474
- }
475
- }
476
- // ── Distribute MCP configs: automatic (unchanged) ──────────────
477
- const mergedMcp = {};
478
- const mcpMtimes = {}; // server key → mtime of source file
479
- for (const source of shared.mcp.sources) {
480
- const p = profiles[source.agent];
481
- if (!p.enabled)
305
+ // ── Diff (stays in barrel — small, uses both stage and restore helpers) ──
306
+ async function diff(cfg, agent, filter) {
307
+ const repoPath = expandHome(cfg.localRepoPath);
308
+ const keyPath = expandHome(cfg.keyPath);
309
+ const entries = buildFileEntries(cfg, undefined, agent, filter);
310
+ const diffResult = { added: [], modified: [], missing: [] };
311
+ for (const entry of entries) {
312
+ const srcExists = fs.existsSync(entry.srcAbs);
313
+ const repoExists = fs.existsSync(path.join(repoPath, entry.repoRel));
314
+ if (!srcExists && !repoExists)
482
315
  continue;
483
- const srcPath = path.join(expandHome(p.workspacePath), source.src);
484
- if (!fs.existsSync(srcPath))
316
+ if (srcExists && !repoExists) {
317
+ diffResult.added.push(entry.repoRel);
485
318
  continue;
486
- try {
487
- const mtime = fs.statSync(srcPath).mtimeMs;
488
- const json = JSON.parse(fs.readFileSync(srcPath, 'utf-8'));
489
- const mcpField = json[source.field];
490
- if (mcpField && typeof mcpField === 'object') {
491
- for (const [key, val] of Object.entries(mcpField)) {
492
- // Keep the version from the most recently modified source file
493
- if (!(key in mergedMcp) || mtime > (mcpMtimes[key] ?? 0)) {
494
- mergedMcp[key] = val;
495
- mcpMtimes[key] = mtime;
496
- }
497
- }
498
- }
499
- }
500
- catch { /* ignore parse failures */ }
501
- }
502
- // Write back to each source agent's MCP config (create file if missing)
503
- if (Object.keys(mergedMcp).length > 0) {
504
- for (const source of shared.mcp.sources) {
505
- const p = profiles[source.agent];
506
- if (!p.enabled)
507
- continue;
508
- const srcPath = path.join(expandHome(p.workspacePath), source.src);
509
- try {
510
- let json = {};
511
- if (fs.existsSync(srcPath)) {
512
- json = JSON.parse(fs.readFileSync(srcPath, 'utf-8'));
513
- }
514
- const currentMcp = (json[source.field] ?? {});
515
- // Add new MCP servers AND update existing ones if config changed
516
- let changed = false;
517
- for (const [key, val] of Object.entries(mergedMcp)) {
518
- if (!(key in currentMcp)) {
519
- // New server — add it
520
- currentMcp[key] = val;
521
- changed = true;
522
- }
523
- else if (JSON.stringify(currentMcp[key]) !== JSON.stringify(val)) {
524
- // Existing server with updated config — take the newer version
525
- currentMcp[key] = val;
526
- changed = true;
527
- }
528
- }
529
- if (changed) {
530
- json[source.field] = currentMcp;
531
- fs.mkdirSync(path.dirname(srcPath), { recursive: true });
532
- fs.writeFileSync(srcPath, JSON.stringify(json, null, 2), 'utf-8');
533
- logger.debug(` ${t('sync.distributeMcp', { agent: source.agent })}`);
534
- }
535
- }
536
- catch { /* ignore */ }
537
319
  }
538
- }
539
- // ── Custom agents: collect pending distributions (no file writes) ──
540
- if (shared.agents && shared.agents.sources.length > 0) {
541
- // Collect each agent's current custom agent files
542
- const agentAgents = new Map(); // agent → relPath → absPath
543
- for (const source of shared.agents.sources) {
544
- const p = profiles[source.agent];
545
- if (!p.enabled)
546
- continue;
547
- const agentsDir = path.join(expandHome(p.workspacePath), source.dir);
548
- const agents = new Map();
549
- if (fs.existsSync(agentsDir)) {
550
- for (const relFile of walkDir(agentsDir)) {
551
- if (path.basename(relFile).startsWith('.'))
552
- continue;
553
- agents.set(relFile, path.join(agentsDir, relFile));
554
- }
555
- }
556
- agentAgents.set(source.agent, agents);
557
- }
558
- // Merge all agents' custom agent files — pick NEWEST version by mtime
559
- const allAgentFiles = new Map();
560
- const allAgentMtimes = new Map();
561
- const allAgentOwner = new Map();
562
- for (const [agentName, agents] of agentAgents) {
563
- for (const [rel, abs] of agents) {
564
- try {
565
- const mtime = fs.statSync(abs).mtimeMs;
566
- if (!allAgentFiles.has(rel) || mtime > allAgentMtimes.get(rel)) {
567
- allAgentFiles.set(rel, abs);
568
- allAgentMtimes.set(rel, mtime);
569
- allAgentOwner.set(rel, agentName);
570
- }
571
- }
572
- catch {
573
- if (!allAgentFiles.has(rel)) {
574
- allAgentFiles.set(rel, abs);
575
- allAgentOwner.set(rel, agentName);
576
- }
577
- }
578
- }
579
- }
580
- // Build ownership map: relPath → set of agent names that have it
581
- const agentHasFile = new Map();
582
- for (const [agentName, agents] of agentAgents) {
583
- for (const rel of agents.keys()) {
584
- if (!agentHasFile.has(rel))
585
- agentHasFile.set(rel, new Set());
586
- agentHasFile.get(rel).add(agentName);
587
- }
320
+ if (!srcExists && repoExists) {
321
+ diffResult.missing.push(entry.repoRel);
322
+ continue;
588
323
  }
589
- const allSourceAgents = shared.agents.sources.map(s => s.agent).filter(a => profiles[a].enabled);
590
- // Detect pending distributions for each custom agent file
591
- for (const [relFile, srcAbs] of allAgentFiles) {
592
- const owners = agentHasFile.get(relFile) ?? new Set();
593
- const sourceAgent = allAgentOwner.get(relFile) ?? '';
594
- for (const targetAgent of allSourceAgents) {
595
- if (targetAgent === sourceAgent)
596
- continue;
597
- const targetHasIt = owners.has(targetAgent);
598
- const targetAgentsDir = path.join(expandHome(profiles[targetAgent].workspacePath), shared.agents.sources.find(s => s.agent === targetAgent).dir);
599
- const targetPath = path.join(targetAgentsDir, relFile);
600
- if (!targetHasIt) {
601
- // Only create add if genuinely new (single owner)
602
- if (owners.size === 1) {
603
- pendingItems.push({
604
- kind: 'agent',
605
- action: 'add',
606
- relFile,
607
- sourceAgent,
608
- targetAgents: [targetAgent],
609
- sourceAbs: srcAbs,
610
- });
611
- }
324
+ if (entry.jsonExtract) {
325
+ try {
326
+ const fullJson = JSON.parse(fs.readFileSync(entry.srcAbs, 'utf-8'));
327
+ const localPartial = JSON.stringify(jsonField.extractFields(fullJson, entry.jsonExtract.fields), null, 2);
328
+ let repoContent;
329
+ if (entry.encrypt) {
330
+ repoContent = cryptoEngine.decryptString(fs.readFileSync(path.join(repoPath, entry.repoRel), 'utf-8').trim(), keyPath);
612
331
  }
613
332
  else {
614
- if (path.resolve(targetPath) === path.resolve(srcAbs))
615
- continue;
616
- try {
617
- if (fs.readFileSync(targetPath).equals(fs.readFileSync(srcAbs)))
618
- continue;
619
- }
620
- catch { /* fall through */ }
621
- pendingItems.push({
622
- kind: 'agent',
623
- action: 'update',
624
- relFile,
625
- sourceAgent,
626
- targetAgents: [targetAgent],
627
- sourceAbs: srcAbs,
628
- });
333
+ repoContent = fs.readFileSync(path.join(repoPath, entry.repoRel), 'utf-8');
629
334
  }
630
- }
631
- }
632
- // Detect delete cases
633
- for (const [relFile, owners] of agentHasFile) {
634
- const missingFrom = allSourceAgents.filter(a => !owners.has(a));
635
- if (missingFrom.length > 0 && owners.size > 1) {
636
- const srcAgent = [...owners][0];
637
- const srcAbs = agentAgents.get(srcAgent)?.get(relFile) ?? '';
638
- for (const target of missingFrom) {
639
- pendingItems.push({
640
- kind: 'agent',
641
- action: 'delete',
642
- relFile,
643
- sourceAgent: srcAgent,
644
- targetAgents: [target],
645
- sourceAbs: srcAbs,
646
- });
335
+ if (localPartial !== repoContent) {
336
+ diffResult.modified.push(entry.repoRel);
647
337
  }
648
338
  }
649
- }
650
- }
651
- // ── Write pending distributions if any ──────────────────────────
652
- if (pendingItems.length > 0) {
653
- // Merge same-kind/same-action/same-relFile items by combining targetAgents
654
- const merged = mergePendingItems(pendingItems);
655
- savePendingDistributions(merged);
656
- }
657
- }
658
- /**
659
- * Detect stale files in repo (present in repo but absent from current entries).
660
- * Returns the list of stale repoRel paths WITHOUT deleting them.
661
- */
662
- function detectStaleFiles(repoPath, entries) {
663
- const activeRepoRels = new Set(entries.map(e => e.repoRel));
664
- const stale = [];
665
- for (const topDir of ['agents', 'shared']) {
666
- const scanRoot = path.join(repoPath, topDir);
667
- if (!fs.existsSync(scanRoot))
668
- continue;
669
- for (const relFile of walkDir(scanRoot)) {
670
- if (path.basename(relFile).startsWith('.'))
671
- continue;
672
- const repoRel = path.join(topDir, relFile);
673
- if (!activeRepoRels.has(repoRel)) {
674
- stale.push(repoRel);
339
+ catch {
340
+ diffResult.modified.push(entry.repoRel);
675
341
  }
676
- }
677
- }
678
- return stale;
679
- }
680
- /**
681
- * Actually delete stale files from repo (after user confirmation).
682
- */
683
- export function deleteStaleFiles(repoPath, staleFiles) {
684
- for (const repoRel of staleFiles) {
685
- const abs = path.join(repoPath, repoRel);
686
- if (!fs.existsSync(abs))
687
342
  continue;
688
- fs.unlinkSync(abs);
689
- logger.debug(` ${t('sync.pruneStale', { file: repoRel })}`);
690
- // Clean up empty directories
691
- const topDir = repoRel.split(path.sep)[0];
692
- const scanRoot = path.join(repoPath, topDir);
693
- let dir = path.dirname(abs);
694
- while (dir !== scanRoot && dir.startsWith(scanRoot)) {
695
- const remaining = fs.readdirSync(dir);
696
- if (remaining.length === 0) {
697
- fs.rmdirSync(dir);
698
- dir = path.dirname(dir);
699
- }
700
- else {
701
- break;
702
- }
703
- }
704
- }
705
- }
706
- const PENDING_DELETIONS_PATH = path.join(os.homedir(), '.wangchuan', 'pending-deletions.json');
707
- const PENDING_DISTRIBUTIONS_PATH = path.join(os.homedir(), '.wangchuan', 'pending-distributions.json');
708
- /** Save pending deletions for later user confirmation */
709
- function savePendingDeletions(files) {
710
- const existing = loadPendingDeletions();
711
- const merged = [...new Set([...existing, ...files])];
712
- fs.mkdirSync(path.dirname(PENDING_DELETIONS_PATH), { recursive: true });
713
- fs.writeFileSync(PENDING_DELETIONS_PATH, JSON.stringify(merged, null, 2), 'utf-8');
714
- }
715
- /** Load pending deletions */
716
- export function loadPendingDeletions() {
717
- try {
718
- if (!fs.existsSync(PENDING_DELETIONS_PATH))
719
- return [];
720
- return JSON.parse(fs.readFileSync(PENDING_DELETIONS_PATH, 'utf-8'));
721
- }
722
- catch {
723
- return [];
724
- }
725
- }
726
- /** Clear pending deletions after confirmation */
727
- export function clearPendingDeletions() {
728
- try {
729
- if (fs.existsSync(PENDING_DELETIONS_PATH))
730
- fs.unlinkSync(PENDING_DELETIONS_PATH);
731
- }
732
- catch { /* */ }
733
- }
734
- /** Merge pending distribution items with same kind/action/relFile by combining targetAgents */
735
- function mergePendingItems(items) {
736
- const map = new Map();
737
- for (const item of items) {
738
- const key = `${item.kind}:${item.action}:${item.relFile}:${item.sourceAgent}`;
739
- const existing = map.get(key);
740
- if (existing) {
741
- for (const t of item.targetAgents) {
742
- if (!existing.targetAgents.includes(t))
743
- existing.targetAgents.push(t);
744
- }
745
- }
746
- else {
747
- map.set(key, { ...item, targetAgents: [...item.targetAgents] });
748
343
  }
749
- }
750
- return [...map.values()];
751
- }
752
- /** Save pending distributions for user confirmation */
753
- function savePendingDistributions(items) {
754
- fs.mkdirSync(path.dirname(PENDING_DISTRIBUTIONS_PATH), { recursive: true });
755
- fs.writeFileSync(PENDING_DISTRIBUTIONS_PATH, JSON.stringify(items, null, 2), 'utf-8');
756
- }
757
- /** Load pending distributions */
758
- export function loadPendingDistributions() {
759
- try {
760
- if (!fs.existsSync(PENDING_DISTRIBUTIONS_PATH))
761
- return [];
762
- return JSON.parse(fs.readFileSync(PENDING_DISTRIBUTIONS_PATH, 'utf-8'));
763
- }
764
- catch {
765
- return [];
766
- }
767
- }
768
- /** Clear pending distributions after processing */
769
- export function clearPendingDistributions() {
770
- try {
771
- if (fs.existsSync(PENDING_DISTRIBUTIONS_PATH))
772
- fs.unlinkSync(PENDING_DISTRIBUTIONS_PATH);
773
- }
774
- catch { /* */ }
775
- }
776
- /**
777
- * Process pending distributions interactively.
778
- * Groups by relFile, prompts user for each, executes the chosen actions.
779
- */
780
- export async function processPendingDistributions(cfg) {
781
- const pending = loadPendingDistributions();
782
- if (pending.length === 0)
783
- return;
784
- const profiles = cfg.profiles.default;
785
- const shared = cfg.shared;
786
- if (!shared) {
787
- clearPendingDistributions();
788
- return;
789
- }
790
- // Group by kind + relFile
791
- const grouped = new Map();
792
- for (const item of pending) {
793
- const key = `${item.kind}:${item.relFile}`;
794
- if (!grouped.has(key))
795
- grouped.set(key, []);
796
- grouped.get(key).push(item);
797
- }
798
- logger.info(t('sync.pendingDistributions', { count: pending.length }));
799
- const rl = await import('readline');
800
- for (const [, items] of grouped) {
801
- const first = items[0];
802
- // Collect all unique target agents across all actions for this file
803
- const allTargets = [...new Set(items.flatMap(i => [...i.targetAgents]))];
804
- console.log();
805
- logger.info(t('sync.distItem', {
806
- kind: first.kind,
807
- action: first.action,
808
- file: first.relFile,
809
- source: first.sourceAgent,
810
- }));
811
- logger.info(t('sync.distPrompt'));
812
- // Build choices
813
- const choices = [];
814
- choices.push(`[0] ${t('sync.distAll')} (${allTargets.join(', ')})`);
815
- for (let i = 0; i < allTargets.length; i++) {
816
- choices.push(`[${i + 1}] ${allTargets[i]}`);
817
- }
818
- choices.push(`[${allTargets.length + 1}] ${t('sync.distNone')}`);
819
- for (const c of choices)
820
- console.log(` ${c}`);
821
- const iface = rl.createInterface({ input: process.stdin, output: process.stdout });
822
- const answer = await new Promise(resolve => {
823
- iface.question(t('sync.distInputPrompt'), (ans) => { iface.close(); resolve(ans.trim()); });
824
- });
825
- // Parse selection
826
- const indices = answer.split(/[,\s]+/).map(s => parseInt(s, 10)).filter(n => !isNaN(n));
827
- let selectedAgents = [];
828
- if (indices.includes(0)) {
829
- selectedAgents = [...allTargets];
830
- }
831
- else if (indices.includes(allTargets.length + 1)) {
832
- selectedAgents = [];
833
- }
834
- else {
835
- selectedAgents = indices
836
- .filter(i => i > 0 && i <= allTargets.length)
837
- .map(i => allTargets[i - 1])
838
- .filter((a) => a !== undefined);
839
- }
840
- // Execute the distribution for selected agents
841
- for (const targetAgent of selectedAgents) {
842
- for (const item of items) {
843
- if (!item.targetAgents.includes(targetAgent))
844
- continue;
845
- executeDistribution(item, targetAgent, cfg);
846
- }
847
- }
848
- if (selectedAgents.length === 0) {
849
- logger.info(t('sync.distSkipped'));
850
- }
851
- }
852
- clearPendingDistributions();
853
- }
854
- /** Execute a single distribution action for a target agent */
855
- function executeDistribution(item, targetAgent, cfg) {
856
- const profiles = cfg.profiles.default;
857
- const shared = cfg.shared;
858
- if (!shared)
859
- return;
860
- const p = profiles[targetAgent];
861
- if (!p)
862
- return;
863
- // Resolve target directory based on kind
864
- let targetDir;
865
- if (item.kind === 'skill') {
866
- const source = shared.skills.sources.find(s => s.agent === targetAgent);
867
- if (!source)
868
- return;
869
- targetDir = path.join(expandHome(p.workspacePath), source.dir);
870
- }
871
- else {
872
- const source = shared.agents?.sources.find(s => s.agent === targetAgent);
873
- if (!source)
874
- return;
875
- targetDir = path.join(expandHome(p.workspacePath), source.dir);
876
- }
877
- const targetPath = path.join(targetDir, item.relFile);
878
- if (item.action === 'delete') {
879
- if (fs.existsSync(targetPath)) {
880
- fs.unlinkSync(targetPath);
881
- // Clean up empty parent dirs
882
- let dir = path.dirname(targetPath);
883
- while (dir !== targetDir && dir.startsWith(targetDir)) {
884
- try {
885
- const remaining = fs.readdirSync(dir);
886
- if (remaining.length === 0) {
887
- fs.rmdirSync(dir);
888
- dir = path.dirname(dir);
889
- }
890
- else
891
- break;
892
- }
893
- catch {
894
- break;
344
+ const srcBuf = fs.readFileSync(entry.srcAbs);
345
+ const repoBuf = fs.readFileSync(path.join(repoPath, entry.repoRel));
346
+ if (entry.encrypt) {
347
+ try {
348
+ const decrypted = cryptoEngine.decryptString(repoBuf.toString('utf-8').trim(), keyPath);
349
+ if (srcBuf.toString('utf-8') !== decrypted) {
350
+ diffResult.modified.push(entry.repoRel);
895
351
  }
896
352
  }
897
- logger.ok(` ${t('sync.distApplied', { action: 'delete', file: item.relFile, agent: targetAgent })}`);
898
- }
899
- }
900
- else {
901
- // add or update — copy the source file
902
- if (!fs.existsSync(item.sourceAbs))
903
- return;
904
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
905
- fs.copyFileSync(item.sourceAbs, targetPath);
906
- logger.ok(` ${t('sync.distApplied', { action: item.action, file: item.relFile, agent: targetAgent })}`);
907
- }
908
- }
909
- const SYNC_META_FILE = 'sync-meta.json';
910
- function writeSyncMeta(repoPath, cfg) {
911
- const meta = {
912
- lastSyncAt: new Date().toISOString(),
913
- hostname: cfg.hostname || os.hostname(),
914
- environment: cfg.environment ?? 'default',
915
- };
916
- fs.writeFileSync(path.join(repoPath, SYNC_META_FILE), JSON.stringify(meta, null, 2), 'utf-8');
917
- }
918
- function readSyncMeta(repoPath) {
919
- const metaPath = path.join(repoPath, SYNC_META_FILE);
920
- if (!fs.existsSync(metaPath))
921
- return null;
922
- try {
923
- return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
924
- }
925
- catch {
926
- return null;
927
- }
928
- }
929
- // ── Integrity checksum ──────────────────────────────────────────
930
- const INTEGRITY_FILE = 'integrity.json';
931
- /** Compute SHA-256 hash of a file */
932
- function sha256File(filePath) {
933
- const content = fs.readFileSync(filePath);
934
- return crypto.createHash('sha256').update(content).digest('hex');
935
- }
936
- /** Write integrity.json to repo root after staging */
937
- function writeIntegrity(repoPath, syncedFiles) {
938
- const checksums = {};
939
- for (const repoRel of syncedFiles) {
940
- const absPath = path.join(repoPath, repoRel);
941
- if (fs.existsSync(absPath)) {
942
- checksums[repoRel] = sha256File(absPath);
943
- }
944
- }
945
- const manifest = {
946
- generatedAt: new Date().toISOString(),
947
- checksums,
948
- };
949
- fs.writeFileSync(path.join(repoPath, INTEGRITY_FILE), JSON.stringify(manifest, null, 2), 'utf-8');
950
- logger.debug(t('integrity.writing'));
951
- }
952
- /** Verify integrity.json checksums against repo files, return mismatched file list */
953
- function verifyIntegrity(repoPath) {
954
- const manifestPath = path.join(repoPath, INTEGRITY_FILE);
955
- if (!fs.existsSync(manifestPath)) {
956
- logger.debug(t('integrity.missingChecksum'));
957
- return [];
958
- }
959
- let manifest;
960
- try {
961
- manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
962
- }
963
- catch {
964
- return [];
965
- }
966
- const mismatched = [];
967
- for (const [repoRel, expectedHash] of Object.entries(manifest.checksums)) {
968
- const absPath = path.join(repoPath, repoRel);
969
- if (!fs.existsSync(absPath))
970
- continue;
971
- const actualHash = sha256File(absPath);
972
- if (actualHash !== expectedHash) {
973
- mismatched.push(repoRel);
974
- logger.warn(t('integrity.mismatch', { file: repoRel }));
353
+ catch {
354
+ diffResult.modified.push(entry.repoRel);
355
+ }
975
356
  }
976
- }
977
- if (mismatched.length === 0) {
978
- const count = Object.keys(manifest.checksums).length;
979
- logger.debug(t('integrity.verified', { count }));
980
- }
981
- else {
982
- logger.warn(t('integrity.mismatchCount', { count: mismatched.length }));
983
- }
984
- return mismatched;
985
- }
986
- // ── Key fingerprint validation ──────────────────────────────────
987
- const KEY_FINGERPRINT_FILE = 'key-fingerprint.json';
988
- /**
989
- * Write the local key's SHA-256 fingerprint to the repo.
990
- * Called after successful push so other machines can verify key match.
991
- */
992
- function writeKeyFingerprint(repoPath, keyPath) {
993
- const fp = keyFingerprint(keyPath);
994
- const manifest = {
995
- fingerprint: fp,
996
- updatedAt: new Date().toISOString(),
997
- };
998
- fs.writeFileSync(path.join(repoPath, KEY_FINGERPRINT_FILE), JSON.stringify(manifest, null, 2), 'utf-8');
999
- }
1000
- /**
1001
- * Verify the local key matches the fingerprint stored in the repo.
1002
- * Throws with a clear message on mismatch. Skips silently if no fingerprint exists
1003
- * (first-time push, or migrated from older version).
1004
- */
1005
- function verifyKeyFingerprint(repoPath, keyPath) {
1006
- const fpPath = path.join(repoPath, KEY_FINGERPRINT_FILE);
1007
- if (!fs.existsSync(fpPath)) {
1008
- logger.debug(t('keyFingerprint.notFound'));
1009
- return; // first push or migrated — no fingerprint yet
1010
- }
1011
- let manifest;
1012
- try {
1013
- manifest = JSON.parse(fs.readFileSync(fpPath, 'utf-8'));
1014
- }
1015
- catch {
1016
- return; // corrupt file — skip validation
1017
- }
1018
- const localFp = keyFingerprint(keyPath);
1019
- if (localFp !== manifest.fingerprint) {
1020
- throw new Error(t('keyFingerprint.mismatch'));
1021
- }
1022
- logger.debug(t('keyFingerprint.verified'));
1023
- }
1024
- // ── Backup before destructive pull ──────────────────────────────
1025
- const WANGCHUAN_DIR = path.join(os.homedir(), '.wangchuan');
1026
- const BACKUPS_DIR = path.join(WANGCHUAN_DIR, 'backups');
1027
- const MAX_BACKUPS = 5;
1028
- /**
1029
- * Create a timestamped backup of local files that would be overwritten by restore.
1030
- * Returns the backup directory path, or null if no files needed backup.
1031
- */
1032
- function backupBeforeRestore(entries, repoPath) {
1033
- // Collect local files that exist and have a corresponding repo file
1034
- const filesToBackup = [];
1035
- for (const entry of entries) {
1036
- const srcRepo = path.join(repoPath, entry.repoRel);
1037
- if (!fs.existsSync(srcRepo) || !fs.existsSync(entry.srcAbs))
1038
- continue;
1039
- // For jsonExtract entries, check the original path
1040
- const localPath = entry.jsonExtract ? entry.jsonExtract.originalPath : entry.srcAbs;
1041
- if (!fs.existsSync(localPath))
1042
- continue;
1043
- // Only backup if content actually differs
1044
- const localBuf = fs.readFileSync(localPath);
1045
- const repoBuf = fs.readFileSync(srcRepo);
1046
- if (!localBuf.equals(repoBuf)) {
1047
- filesToBackup.push({ srcAbs: localPath, repoRel: entry.repoRel });
357
+ else {
358
+ if (!srcBuf.equals(repoBuf))
359
+ diffResult.modified.push(entry.repoRel);
1048
360
  }
1049
361
  }
1050
- if (filesToBackup.length === 0)
1051
- return null;
1052
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1053
- const backupDir = path.join(BACKUPS_DIR, timestamp);
1054
- fs.mkdirSync(backupDir, { recursive: true });
1055
- logger.info(t('backup.creating', { count: filesToBackup.length }));
1056
- // Deduplicate by srcAbs (jsonExtract entries may share originalPath)
1057
- const seen = new Set();
1058
- for (const { srcAbs, repoRel } of filesToBackup) {
1059
- if (seen.has(srcAbs))
1060
- continue;
1061
- seen.add(srcAbs);
1062
- const dest = path.join(backupDir, repoRel);
1063
- fs.mkdirSync(path.dirname(dest), { recursive: true });
1064
- fs.copyFileSync(srcAbs, dest);
1065
- }
1066
- logger.info(t('backup.created', { path: backupDir }));
1067
- return backupDir;
1068
- }
1069
- /** Keep only the N most recent backup directories, delete the rest */
1070
- function rotateBackups() {
1071
- if (!fs.existsSync(BACKUPS_DIR))
1072
- return;
1073
- const dirs = fs.readdirSync(BACKUPS_DIR)
1074
- .filter(d => fs.statSync(path.join(BACKUPS_DIR, d)).isDirectory())
1075
- .sort()
1076
- .reverse(); // newest first
1077
- if (dirs.length <= MAX_BACKUPS)
1078
- return;
1079
- const toRemove = dirs.slice(MAX_BACKUPS);
1080
- for (const dir of toRemove) {
1081
- fs.rmSync(path.join(BACKUPS_DIR, dir), { recursive: true, force: true });
1082
- }
1083
- logger.debug(t('backup.rotated', { kept: MAX_BACKUPS, removed: toRemove.length }));
1084
- }
1085
- /** Check if a file's content matches a buffer (byte-equal for <64KB, SHA-256 for larger) */
1086
- function contentUnchanged(existingPath, newContent) {
1087
- if (!fs.existsSync(existingPath))
1088
- return false;
1089
- const existingBuf = fs.readFileSync(existingPath);
1090
- if (existingBuf.length !== newContent.length)
1091
- return false;
1092
- // For small files (<64KB), direct byte comparison; otherwise hash
1093
- if (newContent.length < 65536)
1094
- return existingBuf.equals(newContent);
1095
- const h1 = crypto.createHash('sha256').update(existingBuf).digest('hex');
1096
- const h2 = crypto.createHash('sha256').update(newContent).digest('hex');
1097
- return h1 === h2;
1098
- }
1099
- /**
1100
- * Check if an encrypted file's plaintext matches new plaintext content.
1101
- * Decrypts the existing .enc file and compares with the new plaintext,
1102
- * avoiding false-positive diffs caused by random IV in AES-256-GCM.
1103
- */
1104
- function encryptedPlaintextUnchanged(existingEncPath, newPlaintext, keyPath) {
1105
- if (!fs.existsSync(existingEncPath))
1106
- return false;
1107
- try {
1108
- const existingEnc = fs.readFileSync(existingEncPath, 'utf-8').trim();
1109
- const existingPlain = cryptoEngine.decryptString(existingEnc, keyPath);
1110
- return Buffer.from(existingPlain, 'utf-8').equals(newPlaintext);
1111
- }
1112
- catch {
1113
- // Decryption failure (key changed, corrupted file) → treat as changed
1114
- return false;
1115
- }
362
+ return diffResult;
1116
363
  }
364
+ // ── Backward-compatible syncEngine object ──────────────────────────
365
+ // Import sub-module functions for syncEngine assembly
366
+ import { stageToRepo } from './sync-stage.js';
367
+ import { readSyncMeta } from './sync-stage.js';
368
+ import { deleteStaleFiles } from './sync-stage.js';
369
+ import { restoreFromRepo } from './sync-restore.js';
370
+ import { loadPendingDeletions, clearPendingDeletions, loadPendingDistributions, clearPendingDistributions, processPendingDistributions, } from './sync-shared.js';
1117
371
  export const syncEngine = {
1118
372
  expandHome,
1119
373
  buildFileEntries,
@@ -1124,465 +378,8 @@ export const syncEngine = {
1124
378
  loadPendingDistributions,
1125
379
  clearPendingDistributions,
1126
380
  processPendingDistributions,
1127
- /**
1128
- * Push: distribute shared content to all agents, then collect files to repo.
1129
- */
1130
- async stageToRepo(cfg, agent, filter) {
1131
- // Distribute shared resources to all agents before full push
1132
- if (!agent) {
1133
- distributeShared(cfg);
1134
- }
1135
- const repoPath = expandHome(cfg.localRepoPath);
1136
- const keyPath = expandHome(cfg.keyPath);
1137
- // ── Verify key fingerprint before pushing (prevents overwriting cloud with wrong key) ──
1138
- verifyKeyFingerprint(repoPath, keyPath);
1139
- const entries = buildFileEntries(cfg, undefined, agent, filter);
1140
- const result = { synced: [], skipped: [], encrypted: [], deleted: [], unchanged: [] };
1141
- let progressIdx = 0;
1142
- const totalEntries = entries.length;
1143
- for (const entry of entries) {
1144
- if (!fs.existsSync(entry.srcAbs)) {
1145
- logger.debug(t('sync.skipNotFound', { path: entry.srcAbs }));
1146
- result.skipped.push(entry.srcAbs);
1147
- continue;
1148
- }
1149
- const destAbs = path.join(repoPath, entry.repoRel);
1150
- fs.mkdirSync(path.dirname(destAbs), { recursive: true });
1151
- // ── JSON field-level extraction ────────────────────────────
1152
- if (entry.jsonExtract) {
1153
- try {
1154
- const fullJson = JSON.parse(fs.readFileSync(entry.srcAbs, 'utf-8'));
1155
- const partial = jsonField.extractFields(fullJson, entry.jsonExtract.fields);
1156
- const content = JSON.stringify(partial, null, 2);
1157
- if (entry.encrypt) {
1158
- // Compare plaintext to avoid false diffs from random IV
1159
- if (encryptedPlaintextUnchanged(destAbs, Buffer.from(content, 'utf-8'), keyPath)) {
1160
- result.unchanged.push(entry.repoRel);
1161
- continue;
1162
- }
1163
- const encrypted = cryptoEngine.encryptString(content, keyPath);
1164
- fs.writeFileSync(destAbs, encrypted, 'utf-8');
1165
- result.encrypted.push(entry.repoRel);
1166
- }
1167
- else {
1168
- const newBuf = Buffer.from(content, 'utf-8');
1169
- if (contentUnchanged(destAbs, newBuf)) {
1170
- result.unchanged.push(entry.repoRel);
1171
- continue;
1172
- }
1173
- fs.writeFileSync(destAbs, content, 'utf-8');
1174
- }
1175
- result.synced.push(entry.repoRel);
1176
- progressIdx++;
1177
- logProgress(progressIdx, totalEntries, 'field', entry.repoRel);
1178
- }
1179
- catch (err) {
1180
- logger.warn(t('sync.skipJsonParse', { path: entry.srcAbs, error: err.message }));
1181
- result.skipped.push(entry.repoRel);
1182
- }
1183
- continue;
1184
- }
1185
- // ── Whole-file sync ────────────────────────────────────────
1186
- if (!entry.encrypt) {
1187
- // Incremental check: skip if content is identical
1188
- const srcBuf = fs.readFileSync(entry.srcAbs);
1189
- if (contentUnchanged(destAbs, srcBuf)) {
1190
- result.unchanged.push(entry.repoRel);
1191
- continue;
1192
- }
1193
- const content = srcBuf.toString('utf-8');
1194
- if (validator.containsSensitiveData(content)) {
1195
- logger.warn(`⚠ ${t('sync.sensitiveData', { path: entry.srcAbs })}`);
1196
- logger.warn(` ${t('sync.suggestEncrypt')}`);
1197
- }
1198
- }
1199
- if (entry.encrypt) {
1200
- // Compare plaintext to avoid false diffs from random IV
1201
- const srcBuf = fs.readFileSync(entry.srcAbs);
1202
- if (encryptedPlaintextUnchanged(destAbs, srcBuf, keyPath)) {
1203
- result.unchanged.push(entry.repoRel);
1204
- continue;
1205
- }
1206
- cryptoEngine.encryptFile(entry.srcAbs, destAbs, keyPath);
1207
- result.encrypted.push(entry.repoRel);
1208
- progressIdx++;
1209
- logProgress(progressIdx, totalEntries, 'enc', entry.repoRel);
1210
- }
1211
- else {
1212
- fs.copyFileSync(entry.srcAbs, destAbs);
1213
- progressIdx++;
1214
- logProgress(progressIdx, totalEntries, 'copy', entry.repoRel);
1215
- }
1216
- result.synced.push(entry.repoRel);
1217
- }
1218
- // ── Detect stale files in repo (full push only, skip when filtering) ──
1219
- // When --only/--exclude is active, the entry set is incomplete — stale detection
1220
- // would wrongly flag legitimately synced files as stale, causing data loss.
1221
- if (!agent && !filter) {
1222
- const syncedEntries = entries.filter(e => fs.existsSync(e.srcAbs));
1223
- const stale = detectStaleFiles(repoPath, syncedEntries);
1224
- if (stale.length > 0) {
1225
- const isTTY = process.stdin.isTTY === true;
1226
- if (isTTY) {
1227
- // Interactive mode: ask user for confirmation before deleting
1228
- logger.warn(t('sync.pendingDeletions', { count: stale.length }));
1229
- for (const f of stale)
1230
- logger.warn(` ${t('sync.pruneCandidate', { file: f })}`);
1231
- const rl = await import('readline');
1232
- const iface = rl.createInterface({ input: process.stdin, output: process.stdout });
1233
- const answer = await new Promise(resolve => {
1234
- iface.question(t('sync.confirmDelete'), (ans) => { iface.close(); resolve(ans.trim().toLowerCase()); });
1235
- });
1236
- if (answer === 'y' || answer === 'yes' || answer === '') {
1237
- deleteStaleFiles(repoPath, stale);
1238
- result.deleted.push(...stale);
1239
- }
1240
- else {
1241
- logger.info(t('sync.deletionSkipped'));
1242
- }
1243
- }
1244
- else {
1245
- // Non-interactive mode (watch daemon): save to pending file for later confirmation
1246
- savePendingDeletions(stale);
1247
- logger.info(t('sync.deletionDeferred', { count: stale.length }));
1248
- }
1249
- }
1250
- }
1251
- // Write sync metadata to repo root
1252
- writeSyncMeta(repoPath, cfg);
1253
- // Write integrity checksums for all synced files
1254
- if (result.synced.length > 0) {
1255
- writeIntegrity(repoPath, result.synced);
1256
- }
1257
- // Write key fingerprint so other machines can verify key match before pull
1258
- writeKeyFingerprint(repoPath, keyPath);
1259
- return result;
1260
- },
1261
- async restoreFromRepo(cfg, agent, filter) {
1262
- const repoPath = expandHome(cfg.localRepoPath);
1263
- const keyPath = expandHome(cfg.keyPath);
1264
- // ── Verify key fingerprint before pulling (prevents decrypt failures mid-restore) ──
1265
- verifyKeyFingerprint(repoPath, keyPath);
1266
- const entries = buildFileEntries(cfg, repoPath, agent, filter);
1267
- const result = { synced: [], skipped: [], decrypted: [], conflicts: [], localOnly: [], skippedAgents: [] };
1268
- let restoreIdx = 0;
1269
- const restoreTotal = entries.length;
1270
- // ── Detect skipped agents (workspace dir doesn't exist) ──────
1271
- const profiles = cfg.profiles.default;
1272
- for (const name of AGENT_NAMES) {
1273
- const p = profiles[name];
1274
- if (p.enabled || (agent && agent !== name))
1275
- continue;
1276
- const wsPath = expandHome(p.workspacePath);
1277
- if (!fs.existsSync(wsPath)) {
1278
- result.skippedAgents.push(name);
1279
- }
1280
- }
1281
- // Also check custom agents
1282
- if (cfg.customAgents) {
1283
- for (const [name, profile] of Object.entries(cfg.customAgents)) {
1284
- if (agent && agent !== name)
1285
- continue;
1286
- const wsPath = expandHome(profile.workspacePath);
1287
- if (!fs.existsSync(wsPath)) {
1288
- result.skippedAgents.push(name);
1289
- }
1290
- }
1291
- }
1292
- // ── Verify integrity checksums before restore ────────────────
1293
- verifyIntegrity(repoPath);
1294
- // ── Backup local files before overwriting ────────────────────
1295
- backupBeforeRestore(entries, repoPath);
1296
- rotateBackups();
1297
- let batchDecision;
1298
- for (const entry of entries) {
1299
- const srcRepo = path.join(repoPath, entry.repoRel);
1300
- if (!fs.existsSync(srcRepo)) {
1301
- // Not in repo but exists locally → mark as localOnly
1302
- // jsonFields entries: srcAbs is the full JSON (always exists), check if extracted fields are non-empty
1303
- if (entry.jsonExtract) {
1304
- try {
1305
- const fullJson = JSON.parse(fs.readFileSync(entry.srcAbs, 'utf-8'));
1306
- const extracted = jsonField.extractFields(fullJson, entry.jsonExtract.fields);
1307
- if (Object.keys(extracted).length > 0) {
1308
- result.localOnly.push(entry.repoRel);
1309
- }
1310
- }
1311
- catch { /* ignore JSON parse failures */ }
1312
- }
1313
- else if (fs.existsSync(entry.srcAbs)) {
1314
- result.localOnly.push(entry.repoRel);
1315
- }
1316
- logger.debug(t('sync.skipNotInRepo', { file: entry.repoRel }));
1317
- result.skipped.push(entry.repoRel);
1318
- continue;
1319
- }
1320
- // ── JSON field-level merge-back ────────────────────────────
1321
- if (entry.jsonExtract) {
1322
- let partialContent;
1323
- if (entry.encrypt) {
1324
- partialContent = cryptoEngine.decryptString(fs.readFileSync(srcRepo, 'utf-8').trim(), keyPath);
1325
- }
1326
- else {
1327
- partialContent = fs.readFileSync(srcRepo, 'utf-8');
1328
- }
1329
- const partial = JSON.parse(partialContent);
1330
- // Read local full JSON, merge into it (without destroying other fields)
1331
- const targetPath = entry.jsonExtract.originalPath;
1332
- let fullJson = {};
1333
- if (fs.existsSync(targetPath)) {
1334
- fullJson = JSON.parse(fs.readFileSync(targetPath, 'utf-8'));
1335
- }
1336
- const merged = jsonField.mergeFields(fullJson, partial);
1337
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
1338
- fs.writeFileSync(targetPath, JSON.stringify(merged, null, 2), 'utf-8');
1339
- // shared MCP entry: also distribute to all other agents
1340
- if (entry.agentName === 'shared' && cfg.shared) {
1341
- for (const source of cfg.shared.mcp.sources) {
1342
- const p = cfg.profiles.default[source.agent];
1343
- if (!p.enabled)
1344
- continue;
1345
- const otherPath = path.join(expandHome(p.workspacePath), source.src);
1346
- if (otherPath === targetPath)
1347
- continue; // already handled
1348
- let otherJson = {};
1349
- if (fs.existsSync(otherPath)) {
1350
- try {
1351
- otherJson = JSON.parse(fs.readFileSync(otherPath, 'utf-8'));
1352
- }
1353
- catch { /* */ }
1354
- }
1355
- const otherMerged = jsonField.mergeFields(otherJson, partial);
1356
- fs.mkdirSync(path.dirname(otherPath), { recursive: true });
1357
- fs.writeFileSync(otherPath, JSON.stringify(otherMerged, null, 2), 'utf-8');
1358
- }
1359
- }
1360
- result.synced.push(entry.repoRel);
1361
- if (entry.encrypt)
1362
- result.decrypted.push(entry.repoRel);
1363
- restoreIdx++;
1364
- logProgress(restoreIdx, restoreTotal, entry.encrypt ? 'decrypted' : 'field', entry.repoRel);
1365
- continue;
1366
- }
1367
- // ── Distribute shared skills to all agents ─────────────────
1368
- if (entry.agentName === 'shared' && entry.repoRel.startsWith('shared/skills/')) {
1369
- const relInSkills = entry.repoRel.slice('shared/skills/'.length);
1370
- const shared = cfg.shared;
1371
- if (shared) {
1372
- for (const source of shared.skills.sources) {
1373
- const p = cfg.profiles.default[source.agent];
1374
- if (!p.enabled)
1375
- continue;
1376
- const dest = path.join(expandHome(p.workspacePath), source.dir, relInSkills);
1377
- fs.mkdirSync(path.dirname(dest), { recursive: true });
1378
- fs.copyFileSync(srcRepo, dest);
1379
- }
1380
- }
1381
- result.synced.push(entry.repoRel);
1382
- restoreIdx++;
1383
- logProgress(restoreIdx, restoreTotal, 'copy', entry.repoRel);
1384
- continue;
1385
- }
1386
- // ── Distribute shared custom agents to all agents ──────────
1387
- if (entry.agentName === 'shared' && entry.repoRel.startsWith('shared/agents/')) {
1388
- const relInAgents = entry.repoRel.slice('shared/agents/'.length);
1389
- const shared = cfg.shared;
1390
- if (shared?.agents) {
1391
- for (const source of shared.agents.sources) {
1392
- const p = cfg.profiles.default[source.agent];
1393
- if (!p.enabled)
1394
- continue;
1395
- const dest = path.join(expandHome(p.workspacePath), source.dir, relInAgents);
1396
- fs.mkdirSync(path.dirname(dest), { recursive: true });
1397
- fs.copyFileSync(srcRepo, dest);
1398
- }
1399
- }
1400
- result.synced.push(entry.repoRel);
1401
- restoreIdx++;
1402
- logProgress(restoreIdx, restoreTotal, 'copy', entry.repoRel);
1403
- continue;
1404
- }
1405
- // ── Conflict detection (whole-file sync) ──────────────────
1406
- if (fs.existsSync(entry.srcAbs)) {
1407
- let isDiff = false;
1408
- const localBuf = fs.readFileSync(entry.srcAbs);
1409
- let remoteContent;
1410
- if (entry.encrypt) {
1411
- try {
1412
- const decrypted = cryptoEngine.decryptString(fs.readFileSync(srcRepo, 'utf-8').trim(), keyPath);
1413
- isDiff = localBuf.toString('utf-8') !== decrypted;
1414
- remoteContent = decrypted;
1415
- }
1416
- catch {
1417
- isDiff = true;
1418
- }
1419
- }
1420
- else {
1421
- const repoBuf = fs.readFileSync(srcRepo);
1422
- isDiff = !localBuf.equals(repoBuf);
1423
- remoteContent = repoBuf.toString('utf-8');
1424
- }
1425
- if (isDiff) {
1426
- // ── Three-way merge for non-encrypted plain text files ──
1427
- const ext = path.extname(entry.repoRel).toLowerCase();
1428
- const MERGEABLE_EXTS = new Set(['.md', '.txt', '.json', '.yaml', '.yml']);
1429
- const isTextMergeable = !entry.encrypt && MERGEABLE_EXTS.has(ext);
1430
- if (isTextMergeable && remoteContent !== undefined) {
1431
- // Try to get the base version from git history (pre-pull version)
1432
- const baseContent = await gitEngine.showFile(repoPath, 'HEAD~1', entry.repoRel);
1433
- if (baseContent !== null) {
1434
- const localContent = localBuf.toString('utf-8');
1435
- const mergeResult = threeWayMerge(baseContent, localContent, remoteContent);
1436
- if (!mergeResult.hasConflicts) {
1437
- // Auto-resolved — write merged content
1438
- fs.mkdirSync(path.dirname(entry.srcAbs), { recursive: true });
1439
- fs.writeFileSync(entry.srcAbs, mergeResult.merged, 'utf-8');
1440
- logger.info(` ${t('merge.autoResolved', { file: entry.repoRel })}`);
1441
- result.synced.push(entry.repoRel);
1442
- restoreIdx++;
1443
- logProgress(restoreIdx, restoreTotal, 'copy', entry.repoRel);
1444
- continue;
1445
- }
1446
- // Has conflicts — write merged content with conflict markers
1447
- fs.mkdirSync(path.dirname(entry.srcAbs), { recursive: true });
1448
- fs.writeFileSync(entry.srcAbs, mergeResult.merged, 'utf-8');
1449
- logger.warn(` ${t('merge.conflictsFound', { file: entry.repoRel })}`);
1450
- result.conflicts.push(entry.repoRel);
1451
- result.synced.push(entry.repoRel);
1452
- restoreIdx++;
1453
- logProgress(restoreIdx, restoreTotal, 'copy', entry.repoRel);
1454
- continue;
1455
- }
1456
- }
1457
- // ── Fallback: interactive overwrite/skip/merge prompt ──
1458
- result.conflicts.push(entry.repoRel);
1459
- if (batchDecision === 'skip_all') {
1460
- logger.info(` ↷ ${t('sync.skippedKeepLocal', { file: entry.repoRel })}`);
1461
- result.skipped.push(entry.repoRel);
1462
- continue;
1463
- }
1464
- if (batchDecision !== 'overwrite_all') {
1465
- const localStr = localBuf.toString('utf-8');
1466
- const canMerge = isTextMergeable && remoteContent !== undefined;
1467
- const ans = await askConflict(entry.repoRel, localStr, remoteContent, canMerge);
1468
- if (ans === 'skip' || ans === 'skip_all') {
1469
- if (ans === 'skip_all')
1470
- batchDecision = 'skip_all';
1471
- logger.info(` ↷ ${t('sync.skippedKeepLocal', { file: entry.repoRel })}`);
1472
- result.skipped.push(entry.repoRel);
1473
- continue;
1474
- }
1475
- if (ans === 'overwrite_all')
1476
- batchDecision = 'overwrite_all';
1477
- if (ans === 'merge' && canMerge) {
1478
- // Manual merge attempt via three-way merge (with conflict markers)
1479
- const baseContent = await gitEngine.showFile(repoPath, 'HEAD~1', entry.repoRel);
1480
- const base = baseContent ?? '';
1481
- const mergeResult = threeWayMerge(base, localStr, remoteContent);
1482
- fs.mkdirSync(path.dirname(entry.srcAbs), { recursive: true });
1483
- fs.writeFileSync(entry.srcAbs, mergeResult.merged, 'utf-8');
1484
- if (mergeResult.hasConflicts) {
1485
- logger.warn(` ${t('merge.conflictsFound', { file: entry.repoRel })}`);
1486
- }
1487
- else {
1488
- logger.info(` ${t('merge.autoResolved', { file: entry.repoRel })}`);
1489
- }
1490
- result.synced.push(entry.repoRel);
1491
- restoreIdx++;
1492
- logProgress(restoreIdx, restoreTotal, 'copy', entry.repoRel);
1493
- continue;
1494
- }
1495
- }
1496
- }
1497
- }
1498
- // ── Write file ───────────────────────────────────────────
1499
- fs.mkdirSync(path.dirname(entry.srcAbs), { recursive: true });
1500
- if (entry.encrypt) {
1501
- cryptoEngine.decryptFile(srcRepo, entry.srcAbs, keyPath);
1502
- result.decrypted.push(entry.repoRel);
1503
- }
1504
- else {
1505
- fs.copyFileSync(srcRepo, entry.srcAbs);
1506
- }
1507
- result.synced.push(entry.repoRel);
1508
- restoreIdx++;
1509
- logProgress(restoreIdx, restoreTotal, entry.encrypt ? 'decrypted' : 'copy', entry.repoRel);
1510
- }
1511
- // Log sync-meta freshness info
1512
- const meta = readSyncMeta(repoPath);
1513
- if (meta) {
1514
- logger.info(t('sync.meta.lastSync', {
1515
- time: meta.lastSyncAt,
1516
- hostname: meta.hostname,
1517
- env: meta.environment,
1518
- }));
1519
- const ageMs = Date.now() - new Date(meta.lastSyncAt).getTime();
1520
- const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24));
1521
- if (ageDays >= 3) {
1522
- logger.warn(t('sync.meta.staleDays', { days: ageDays }));
1523
- }
1524
- }
1525
- return result;
1526
- },
1527
- async diff(cfg, agent, filter) {
1528
- const repoPath = expandHome(cfg.localRepoPath);
1529
- const keyPath = expandHome(cfg.keyPath);
1530
- const entries = buildFileEntries(cfg, undefined, agent, filter);
1531
- const diff = { added: [], modified: [], missing: [] };
1532
- for (const entry of entries) {
1533
- const srcExists = fs.existsSync(entry.srcAbs);
1534
- const repoExists = fs.existsSync(path.join(repoPath, entry.repoRel));
1535
- if (!srcExists && !repoExists)
1536
- continue;
1537
- if (srcExists && !repoExists) {
1538
- diff.added.push(entry.repoRel);
1539
- continue;
1540
- }
1541
- if (!srcExists && repoExists) {
1542
- diff.missing.push(entry.repoRel);
1543
- continue;
1544
- }
1545
- // For JSON field extraction, compare the extracted content
1546
- if (entry.jsonExtract) {
1547
- try {
1548
- const fullJson = JSON.parse(fs.readFileSync(entry.srcAbs, 'utf-8'));
1549
- const localPartial = JSON.stringify(jsonField.extractFields(fullJson, entry.jsonExtract.fields), null, 2);
1550
- let repoContent;
1551
- if (entry.encrypt) {
1552
- repoContent = cryptoEngine.decryptString(fs.readFileSync(path.join(repoPath, entry.repoRel), 'utf-8').trim(), keyPath);
1553
- }
1554
- else {
1555
- repoContent = fs.readFileSync(path.join(repoPath, entry.repoRel), 'utf-8');
1556
- }
1557
- if (localPartial !== repoContent) {
1558
- diff.modified.push(entry.repoRel);
1559
- }
1560
- }
1561
- catch {
1562
- diff.modified.push(entry.repoRel);
1563
- }
1564
- continue;
1565
- }
1566
- // Whole-file comparison
1567
- const srcBuf = fs.readFileSync(entry.srcAbs);
1568
- const repoBuf = fs.readFileSync(path.join(repoPath, entry.repoRel));
1569
- if (entry.encrypt) {
1570
- try {
1571
- const decrypted = cryptoEngine.decryptString(repoBuf.toString('utf-8').trim(), keyPath);
1572
- if (srcBuf.toString('utf-8') !== decrypted) {
1573
- diff.modified.push(entry.repoRel);
1574
- }
1575
- }
1576
- catch {
1577
- diff.modified.push(entry.repoRel);
1578
- }
1579
- }
1580
- else {
1581
- if (!srcBuf.equals(repoBuf))
1582
- diff.modified.push(entry.repoRel);
1583
- }
1584
- }
1585
- return diff;
1586
- },
381
+ stageToRepo,
382
+ restoreFromRepo,
383
+ diff,
1587
384
  };
1588
385
  //# sourceMappingURL=sync.js.map