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.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/bin/wangchuan.js +1 -1
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +67 -22
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/core/config.js +1 -1
- package/dist/src/core/config.js.map +1 -1
- package/dist/src/core/crypto.d.ts.map +1 -1
- package/dist/src/core/crypto.js +3 -2
- package/dist/src/core/crypto.js.map +1 -1
- package/dist/src/core/migrate.d.ts.map +1 -1
- package/dist/src/core/migrate.js +1 -0
- package/dist/src/core/migrate.js.map +1 -1
- package/dist/src/core/sync-restore.d.ts +19 -0
- package/dist/src/core/sync-restore.d.ts.map +1 -0
- package/dist/src/core/sync-restore.js +339 -0
- package/dist/src/core/sync-restore.js.map +1 -0
- package/dist/src/core/sync-shared.d.ts +30 -0
- package/dist/src/core/sync-shared.d.ts.map +1 -0
- package/dist/src/core/sync-shared.js +397 -0
- package/dist/src/core/sync-shared.js.map +1 -0
- package/dist/src/core/sync-stage.d.ts +57 -0
- package/dist/src/core/sync-stage.d.ts.map +1 -0
- package/dist/src/core/sync-stage.js +429 -0
- package/dist/src/core/sync-stage.js.map +1 -0
- package/dist/src/core/sync.d.ts +22 -46
- package/dist/src/core/sync.d.ts.map +1 -1
- package/dist/src/core/sync.js +64 -1267
- package/dist/src/core/sync.js.map +1 -1
- package/dist/src/i18n.d.ts.map +1 -1
- package/dist/src/i18n.js +27 -2
- package/dist/src/i18n.js.map +1 -1
- package/dist/test/crypto.test.js +2 -2
- package/dist/test/crypto.test.js.map +1 -1
- package/dist/test/git.test.d.ts +5 -0
- package/dist/test/git.test.d.ts.map +1 -0
- package/dist/test/git.test.js +90 -0
- package/dist/test/git.test.js.map +1 -0
- package/dist/test/migrate.test.d.ts +9 -0
- package/dist/test/migrate.test.d.ts.map +1 -0
- package/dist/test/migrate.test.js +133 -0
- package/dist/test/migrate.test.js.map +1 -0
- package/dist/test/sync-lock.test.d.ts +5 -0
- package/dist/test/sync-lock.test.d.ts.map +1 -0
- package/dist/test/sync-lock.test.js +94 -0
- package/dist/test/sync-lock.test.js.map +1 -0
- package/package.json +2 -2
package/dist/src/core/sync.js
CHANGED
|
@@ -1,33 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* sync.ts — Core sync engine
|
|
2
|
+
* sync.ts — Core sync engine barrel module
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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
|
-
|
|
540
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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
|
-
|
|
978
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|