wangchuan 1.0.2 → 2.2.0
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/.wangchuan/config.example.json +44 -19
- package/README.md +145 -91
- package/dist/bin/wangchuan.js +9 -0
- package/dist/bin/wangchuan.js.map +1 -1
- package/dist/src/commands/diff.d.ts.map +1 -1
- package/dist/src/commands/diff.js +3 -1
- package/dist/src/commands/diff.js.map +1 -1
- package/dist/src/commands/dump.d.ts +9 -0
- package/dist/src/commands/dump.d.ts.map +1 -0
- package/dist/src/commands/dump.js +68 -0
- package/dist/src/commands/dump.js.map +1 -0
- package/dist/src/commands/list.d.ts +1 -1
- package/dist/src/commands/list.d.ts.map +1 -1
- package/dist/src/commands/list.js +26 -12
- package/dist/src/commands/list.js.map +1 -1
- package/dist/src/commands/pull.d.ts.map +1 -1
- package/dist/src/commands/pull.js +11 -1
- package/dist/src/commands/pull.js.map +1 -1
- package/dist/src/commands/push.d.ts.map +1 -1
- package/dist/src/commands/push.js +13 -5
- package/dist/src/commands/push.js.map +1 -1
- package/dist/src/commands/status.d.ts.map +1 -1
- package/dist/src/commands/status.js +5 -2
- package/dist/src/commands/status.js.map +1 -1
- package/dist/src/core/config.d.ts +8 -1
- package/dist/src/core/config.d.ts.map +1 -1
- package/dist/src/core/config.js +54 -15
- package/dist/src/core/config.js.map +1 -1
- package/dist/src/core/json-field.d.ts +17 -0
- package/dist/src/core/json-field.d.ts.map +1 -0
- package/dist/src/core/json-field.js +21 -0
- package/dist/src/core/json-field.js.map +1 -0
- package/dist/src/core/migrate.d.ts +22 -0
- package/dist/src/core/migrate.d.ts.map +1 -0
- package/dist/src/core/migrate.js +183 -0
- package/dist/src/core/migrate.js.map +1 -0
- package/dist/src/core/sync.d.ts +7 -0
- package/dist/src/core/sync.d.ts.map +1 -1
- package/dist/src/core/sync.js +423 -68
- package/dist/src/core/sync.js.map +1 -1
- package/dist/src/types.d.ts +62 -17
- package/dist/src/types.d.ts.map +1 -1
- package/dist/test/json-field.test.d.ts +5 -0
- package/dist/test/json-field.test.d.ts.map +1 -0
- package/dist/test/json-field.test.js +71 -0
- package/dist/test/json-field.test.js.map +1 -0
- package/dist/test/sync.test.d.ts +13 -0
- package/dist/test/sync.test.d.ts.map +1 -0
- package/dist/test/sync.test.js +477 -0
- package/dist/test/sync.test.js.map +1 -0
- package/package.json +2 -2
- package/skill/SKILL.md +52 -39
package/dist/src/core/sync.js
CHANGED
|
@@ -6,12 +6,17 @@
|
|
|
6
6
|
* restoreFromRepo 本地仓库目录 → 工作区(拉取后还原)
|
|
7
7
|
* diff 对比两侧,返回差异摘要
|
|
8
8
|
*
|
|
9
|
+
* 支持三层同步:
|
|
10
|
+
* shared — 跨 agent 共享(skills、MCP 模板、共享记忆)
|
|
11
|
+
* agents/* — per-agent 跨环境同步
|
|
12
|
+
*
|
|
9
13
|
* 所有方法支持可选的 agent 过滤参数,只操作指定智能体的文件。
|
|
10
14
|
*/
|
|
11
15
|
import fs from 'fs';
|
|
12
16
|
import path from 'path';
|
|
13
17
|
import os from 'os';
|
|
14
18
|
import { cryptoEngine } from './crypto.js';
|
|
19
|
+
import { jsonField } from './json-field.js';
|
|
15
20
|
import { validator } from '../utils/validator.js';
|
|
16
21
|
import { logger } from '../utils/logger.js';
|
|
17
22
|
import { askConflict } from '../utils/prompt.js';
|
|
@@ -36,6 +41,138 @@ function walkDir(dirAbs) {
|
|
|
36
41
|
fs.readdirSync(dirAbs).forEach(f => walk(f));
|
|
37
42
|
return results;
|
|
38
43
|
}
|
|
44
|
+
/** 按 repoRel 去重,保留第一个出现的条目 */
|
|
45
|
+
function deduplicateEntries(entries) {
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
return entries.filter(e => {
|
|
48
|
+
if (seen.has(e.repoRel))
|
|
49
|
+
return false;
|
|
50
|
+
seen.add(e.repoRel);
|
|
51
|
+
return true;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const AGENT_NAMES = ['openclaw', 'claude', 'gemini'];
|
|
55
|
+
/**
|
|
56
|
+
* 为指定 agent profile 生成 syncFiles + syncDirs + jsonFields 条目
|
|
57
|
+
*/
|
|
58
|
+
function buildAgentEntries(name, profile, repoDirBase) {
|
|
59
|
+
const entries = [];
|
|
60
|
+
const wsPath = expandHome(profile.workspacePath);
|
|
61
|
+
const repoPrefix = `agents/${name}`;
|
|
62
|
+
// syncFiles
|
|
63
|
+
for (const item of profile.syncFiles) {
|
|
64
|
+
const suffix = item.encrypt ? '.enc' : '';
|
|
65
|
+
entries.push({
|
|
66
|
+
srcAbs: path.join(wsPath, item.src),
|
|
67
|
+
repoRel: path.join(repoPrefix, item.src + suffix),
|
|
68
|
+
plainRel: path.join(repoPrefix, item.src),
|
|
69
|
+
encrypt: item.encrypt,
|
|
70
|
+
agentName: name,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// syncDirs
|
|
74
|
+
for (const dir of (profile.syncDirs ?? [])) {
|
|
75
|
+
const scanBase = repoDirBase
|
|
76
|
+
? path.join(repoDirBase, repoPrefix, dir.src)
|
|
77
|
+
: path.join(wsPath, dir.src);
|
|
78
|
+
if (!fs.existsSync(scanBase))
|
|
79
|
+
continue;
|
|
80
|
+
for (const relFile of walkDir(scanBase)) {
|
|
81
|
+
const suffix = dir.encrypt ? '.enc' : '';
|
|
82
|
+
const plainFile = relFile.endsWith('.enc') ? relFile.slice(0, -4) : relFile;
|
|
83
|
+
entries.push({
|
|
84
|
+
srcAbs: path.join(wsPath, dir.src, plainFile),
|
|
85
|
+
repoRel: path.join(repoPrefix, dir.src, plainFile + suffix),
|
|
86
|
+
plainRel: path.join(repoPrefix, dir.src, plainFile),
|
|
87
|
+
encrypt: dir.encrypt,
|
|
88
|
+
agentName: name,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// jsonFields — 字段级 JSON 提取
|
|
93
|
+
for (const jf of (profile.jsonFields ?? [])) {
|
|
94
|
+
const suffix = jf.encrypt ? '.enc' : '';
|
|
95
|
+
entries.push({
|
|
96
|
+
srcAbs: path.join(wsPath, jf.src),
|
|
97
|
+
repoRel: path.join(repoPrefix, jf.repoName + suffix),
|
|
98
|
+
plainRel: path.join(repoPrefix, jf.repoName),
|
|
99
|
+
encrypt: jf.encrypt,
|
|
100
|
+
agentName: name,
|
|
101
|
+
jsonExtract: {
|
|
102
|
+
fields: jf.fields,
|
|
103
|
+
originalPath: path.join(wsPath, jf.src),
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return entries;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 构建 shared tier 条目(skills、MCP 模板、共享文件)
|
|
111
|
+
*/
|
|
112
|
+
function buildSharedEntries(cfg, repoDirBase) {
|
|
113
|
+
const entries = [];
|
|
114
|
+
const shared = cfg.shared;
|
|
115
|
+
if (!shared)
|
|
116
|
+
return entries;
|
|
117
|
+
const profiles = cfg.profiles.default;
|
|
118
|
+
// ── shared skills:多源汇聚 ────────────────────────────────
|
|
119
|
+
for (const source of shared.skills.sources) {
|
|
120
|
+
const p = profiles[source.agent];
|
|
121
|
+
if (!p.enabled)
|
|
122
|
+
continue;
|
|
123
|
+
const wsPath = expandHome(p.workspacePath);
|
|
124
|
+
const scanBase = repoDirBase
|
|
125
|
+
? path.join(repoDirBase, 'shared', 'skills')
|
|
126
|
+
: path.join(wsPath, source.dir);
|
|
127
|
+
if (!fs.existsSync(scanBase))
|
|
128
|
+
continue;
|
|
129
|
+
for (const relFile of walkDir(scanBase)) {
|
|
130
|
+
// 跳过 .DS_Store 等系统文件
|
|
131
|
+
if (path.basename(relFile).startsWith('.'))
|
|
132
|
+
continue;
|
|
133
|
+
entries.push({
|
|
134
|
+
srcAbs: path.join(wsPath, source.dir, relFile),
|
|
135
|
+
repoRel: path.join('shared', 'skills', relFile),
|
|
136
|
+
plainRel: path.join('shared', 'skills', relFile),
|
|
137
|
+
encrypt: false,
|
|
138
|
+
agentName: 'shared',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// ── shared MCP:从各 agent 的 JSON 中提取 mcpServers ──────
|
|
143
|
+
for (const source of shared.mcp.sources) {
|
|
144
|
+
const p = profiles[source.agent];
|
|
145
|
+
if (!p.enabled)
|
|
146
|
+
continue;
|
|
147
|
+
const wsPath = expandHome(p.workspacePath);
|
|
148
|
+
const srcPath = path.join(wsPath, source.src);
|
|
149
|
+
const repoName = `mcp/${source.agent}-${source.field}.json`;
|
|
150
|
+
entries.push({
|
|
151
|
+
srcAbs: srcPath,
|
|
152
|
+
repoRel: path.join('shared', repoName + '.enc'),
|
|
153
|
+
plainRel: path.join('shared', repoName),
|
|
154
|
+
encrypt: true,
|
|
155
|
+
agentName: 'shared',
|
|
156
|
+
jsonExtract: {
|
|
157
|
+
fields: [source.field],
|
|
158
|
+
originalPath: srcPath,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// ── shared syncFiles ───────────────────────────────────────
|
|
163
|
+
for (const item of shared.syncFiles) {
|
|
164
|
+
const wsPath = expandHome(item.workspacePath);
|
|
165
|
+
const suffix = item.encrypt ? '.enc' : '';
|
|
166
|
+
entries.push({
|
|
167
|
+
srcAbs: path.join(wsPath, item.src),
|
|
168
|
+
repoRel: path.join('shared', item.src + suffix),
|
|
169
|
+
plainRel: path.join('shared', item.src),
|
|
170
|
+
encrypt: item.encrypt,
|
|
171
|
+
agentName: 'shared',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return entries;
|
|
175
|
+
}
|
|
39
176
|
/**
|
|
40
177
|
* 构建需要同步的文件条目列表(所有同步方向的单一事实来源)。
|
|
41
178
|
*
|
|
@@ -44,83 +181,204 @@ function walkDir(dirAbs) {
|
|
|
44
181
|
*/
|
|
45
182
|
export function buildFileEntries(cfg, repoDirBase, agent) {
|
|
46
183
|
const entries = [];
|
|
47
|
-
const
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
184
|
+
const profiles = cfg.profiles.default;
|
|
185
|
+
// per-agent 条目
|
|
186
|
+
for (const name of AGENT_NAMES) {
|
|
187
|
+
const p = profiles[name];
|
|
188
|
+
if (!p.enabled || (agent && agent !== name))
|
|
189
|
+
continue;
|
|
190
|
+
entries.push(...buildAgentEntries(name, p, repoDirBase));
|
|
191
|
+
}
|
|
192
|
+
// shared 条目(--agent 过滤时不包含 shared,因为 shared 不属于任何单一 agent)
|
|
193
|
+
if (!agent) {
|
|
194
|
+
entries.push(...buildSharedEntries(cfg, repoDirBase));
|
|
195
|
+
}
|
|
196
|
+
return deduplicateEntries(entries);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* 将 shared 内容(skills、MCP 配置)分发到各 agent 的本地目录。
|
|
200
|
+
* push 前调用,确保各 agent 在推送前已获得最新共享资源。
|
|
201
|
+
*/
|
|
202
|
+
function distributeShared(cfg) {
|
|
203
|
+
const shared = cfg.shared;
|
|
204
|
+
if (!shared)
|
|
205
|
+
return;
|
|
206
|
+
const profiles = cfg.profiles.default;
|
|
207
|
+
// ── 分发 skills:从每个 source 收集,只添加对方完全没有的新 skill ──
|
|
208
|
+
// 收集各 agent 当前拥有的 skill 集合
|
|
209
|
+
const agentSkills = new Map(); // agent → relPath → absPath
|
|
210
|
+
for (const source of shared.skills.sources) {
|
|
211
|
+
const p = profiles[source.agent];
|
|
212
|
+
if (!p.enabled)
|
|
213
|
+
continue;
|
|
214
|
+
const skillsDir = path.join(expandHome(p.workspacePath), source.dir);
|
|
215
|
+
const skills = new Map();
|
|
216
|
+
if (fs.existsSync(skillsDir)) {
|
|
217
|
+
for (const relFile of walkDir(skillsDir)) {
|
|
218
|
+
if (path.basename(relFile).startsWith('.'))
|
|
219
|
+
continue;
|
|
220
|
+
skills.set(relFile, path.join(skillsDir, relFile));
|
|
221
|
+
}
|
|
60
222
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
223
|
+
agentSkills.set(source.agent, skills);
|
|
224
|
+
}
|
|
225
|
+
// 合并所有 agent 的 skill(去重,先出现者优先)
|
|
226
|
+
const allSkills = new Map();
|
|
227
|
+
for (const skills of agentSkills.values()) {
|
|
228
|
+
for (const [rel, abs] of skills) {
|
|
229
|
+
if (!allSkills.has(rel))
|
|
230
|
+
allSkills.set(rel, abs);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// 分发:只把某 agent 缺少的 skill 复制过去(skill 存在于全局集合中)
|
|
234
|
+
for (const source of shared.skills.sources) {
|
|
235
|
+
const p = profiles[source.agent];
|
|
236
|
+
if (!p.enabled)
|
|
237
|
+
continue;
|
|
238
|
+
const mySkills = agentSkills.get(source.agent);
|
|
239
|
+
const skillsDir = path.join(expandHome(p.workspacePath), source.dir);
|
|
240
|
+
for (const [relFile, srcAbs] of allSkills) {
|
|
241
|
+
if (mySkills.has(relFile))
|
|
242
|
+
continue; // 已有,不覆盖
|
|
243
|
+
const dest = path.join(skillsDir, relFile);
|
|
244
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
245
|
+
fs.copyFileSync(srcAbs, dest);
|
|
246
|
+
logger.debug(` 分发 skill: ${relFile} → ${source.agent}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// ── 分发 MCP 配置:从每个 source 提取,merge 到其他 agent ──
|
|
250
|
+
const mergedMcp = {};
|
|
251
|
+
for (const source of shared.mcp.sources) {
|
|
252
|
+
const p = profiles[source.agent];
|
|
253
|
+
if (!p.enabled)
|
|
254
|
+
continue;
|
|
255
|
+
const srcPath = path.join(expandHome(p.workspacePath), source.src);
|
|
256
|
+
if (!fs.existsSync(srcPath))
|
|
257
|
+
continue;
|
|
258
|
+
try {
|
|
259
|
+
const json = JSON.parse(fs.readFileSync(srcPath, 'utf-8'));
|
|
260
|
+
const mcpField = json[source.field];
|
|
261
|
+
if (mcpField && typeof mcpField === 'object') {
|
|
262
|
+
Object.assign(mergedMcp, mcpField);
|
|
77
263
|
}
|
|
78
264
|
}
|
|
265
|
+
catch { /* 忽略解析失败 */ }
|
|
79
266
|
}
|
|
80
|
-
//
|
|
81
|
-
if (
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
267
|
+
// 写回到每个 source agent 的 MCP 配置(文件不存在时创建)
|
|
268
|
+
if (Object.keys(mergedMcp).length > 0) {
|
|
269
|
+
for (const source of shared.mcp.sources) {
|
|
270
|
+
const p = profiles[source.agent];
|
|
271
|
+
if (!p.enabled)
|
|
272
|
+
continue;
|
|
273
|
+
const srcPath = path.join(expandHome(p.workspacePath), source.src);
|
|
274
|
+
try {
|
|
275
|
+
let json = {};
|
|
276
|
+
if (fs.existsSync(srcPath)) {
|
|
277
|
+
json = JSON.parse(fs.readFileSync(srcPath, 'utf-8'));
|
|
278
|
+
}
|
|
279
|
+
const currentMcp = (json[source.field] ?? {});
|
|
280
|
+
// 只添加本地没有的 MCP server,不覆盖已有配置
|
|
281
|
+
let changed = false;
|
|
282
|
+
for (const [key, val] of Object.entries(mergedMcp)) {
|
|
283
|
+
if (!(key in currentMcp)) {
|
|
284
|
+
currentMcp[key] = val;
|
|
285
|
+
changed = true;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (changed) {
|
|
289
|
+
json[source.field] = currentMcp;
|
|
290
|
+
fs.mkdirSync(path.dirname(srcPath), { recursive: true });
|
|
291
|
+
fs.writeFileSync(srcPath, JSON.stringify(json, null, 2), 'utf-8');
|
|
292
|
+
logger.debug(` 分发 MCP servers → ${source.agent}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch { /* 忽略 */ }
|
|
92
296
|
}
|
|
93
297
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* 清理 repo 中的过期文件 — repo 有但当前 entries 中不包含的条目删除。
|
|
301
|
+
* 仅清理 agents/ 和 shared/ 目录下的文件(不删 .git 等)。
|
|
302
|
+
*/
|
|
303
|
+
function pruneRepoStaleFiles(repoPath, entries) {
|
|
304
|
+
const activeRepoRels = new Set(entries.map(e => e.repoRel));
|
|
305
|
+
const deleted = [];
|
|
306
|
+
for (const topDir of ['agents', 'shared']) {
|
|
307
|
+
const scanRoot = path.join(repoPath, topDir);
|
|
308
|
+
if (!fs.existsSync(scanRoot))
|
|
309
|
+
continue;
|
|
310
|
+
for (const relFile of walkDir(scanRoot)) {
|
|
311
|
+
if (path.basename(relFile).startsWith('.'))
|
|
312
|
+
continue;
|
|
313
|
+
const repoRel = path.join(topDir, relFile);
|
|
314
|
+
if (!activeRepoRels.has(repoRel)) {
|
|
315
|
+
const abs = path.join(repoPath, repoRel);
|
|
316
|
+
fs.unlinkSync(abs);
|
|
317
|
+
deleted.push(repoRel);
|
|
318
|
+
logger.debug(` repo 清理过期文件: ${repoRel}`);
|
|
319
|
+
// 清理空目录
|
|
320
|
+
let dir = path.dirname(abs);
|
|
321
|
+
while (dir !== scanRoot && dir.startsWith(scanRoot)) {
|
|
322
|
+
const remaining = fs.readdirSync(dir);
|
|
323
|
+
if (remaining.length === 0) {
|
|
324
|
+
fs.rmdirSync(dir);
|
|
325
|
+
dir = path.dirname(dir);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
106
332
|
}
|
|
107
333
|
}
|
|
108
|
-
return
|
|
334
|
+
return deleted;
|
|
109
335
|
}
|
|
110
336
|
export const syncEngine = {
|
|
111
337
|
expandHome,
|
|
112
338
|
buildFileEntries,
|
|
339
|
+
/**
|
|
340
|
+
* 推送前:先分发 shared 内容到各 agent,再收集文件到 repo。
|
|
341
|
+
*/
|
|
113
342
|
async stageToRepo(cfg, agent) {
|
|
343
|
+
// 推送全部时,先分发 shared 资源到各 agent
|
|
344
|
+
if (!agent) {
|
|
345
|
+
distributeShared(cfg);
|
|
346
|
+
}
|
|
114
347
|
const repoPath = expandHome(cfg.localRepoPath);
|
|
115
348
|
const keyPath = expandHome(cfg.keyPath);
|
|
116
349
|
const entries = buildFileEntries(cfg, undefined, agent);
|
|
117
|
-
const result = { synced: [], skipped: [], encrypted: [] };
|
|
350
|
+
const result = { synced: [], skipped: [], encrypted: [], deleted: [] };
|
|
118
351
|
for (const entry of entries) {
|
|
119
352
|
if (!fs.existsSync(entry.srcAbs)) {
|
|
120
353
|
logger.debug(`跳过(不存在): ${entry.srcAbs}`);
|
|
121
354
|
result.skipped.push(entry.srcAbs);
|
|
122
355
|
continue;
|
|
123
356
|
}
|
|
357
|
+
const destAbs = path.join(repoPath, entry.repoRel);
|
|
358
|
+
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
|
|
359
|
+
// ── JSON 字段级提取 ─────────────────────────────────────
|
|
360
|
+
if (entry.jsonExtract) {
|
|
361
|
+
try {
|
|
362
|
+
const fullJson = JSON.parse(fs.readFileSync(entry.srcAbs, 'utf-8'));
|
|
363
|
+
const partial = jsonField.extractFields(fullJson, entry.jsonExtract.fields);
|
|
364
|
+
const content = JSON.stringify(partial, null, 2);
|
|
365
|
+
if (entry.encrypt) {
|
|
366
|
+
const encrypted = cryptoEngine.encryptString(content, keyPath);
|
|
367
|
+
fs.writeFileSync(destAbs, encrypted, 'utf-8');
|
|
368
|
+
result.encrypted.push(entry.repoRel);
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
fs.writeFileSync(destAbs, content, 'utf-8');
|
|
372
|
+
}
|
|
373
|
+
result.synced.push(entry.repoRel);
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
logger.warn(`跳过 JSON 字段提取(解析失败): ${entry.srcAbs} — ${err.message}`);
|
|
377
|
+
result.skipped.push(entry.repoRel);
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
// ── 整文件同步(原有逻辑) ────────────────────────────────
|
|
124
382
|
if (!entry.encrypt) {
|
|
125
383
|
const content = fs.readFileSync(entry.srcAbs, 'utf-8');
|
|
126
384
|
if (validator.containsSensitiveData(content)) {
|
|
@@ -128,8 +386,6 @@ export const syncEngine = {
|
|
|
128
386
|
logger.warn(` 建议在配置中将该文件标记为 encrypt:true`);
|
|
129
387
|
}
|
|
130
388
|
}
|
|
131
|
-
const destAbs = path.join(repoPath, entry.repoRel);
|
|
132
|
-
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
|
|
133
389
|
if (entry.encrypt) {
|
|
134
390
|
cryptoEngine.encryptFile(entry.srcAbs, destAbs, keyPath);
|
|
135
391
|
result.encrypted.push(entry.repoRel);
|
|
@@ -139,23 +395,106 @@ export const syncEngine = {
|
|
|
139
395
|
}
|
|
140
396
|
result.synced.push(entry.repoRel);
|
|
141
397
|
}
|
|
398
|
+
// ── 清理 repo 中的过期文件(全量 push 时) ──────────────────
|
|
399
|
+
// 只用实际写入 repo 的条目做 prune,排除本地不存在的 skipped 条目
|
|
400
|
+
if (!agent) {
|
|
401
|
+
const syncedEntries = entries.filter(e => fs.existsSync(e.srcAbs));
|
|
402
|
+
const pruned = pruneRepoStaleFiles(repoPath, syncedEntries);
|
|
403
|
+
result.deleted.push(...pruned);
|
|
404
|
+
}
|
|
142
405
|
return result;
|
|
143
406
|
},
|
|
144
407
|
async restoreFromRepo(cfg, agent) {
|
|
145
408
|
const repoPath = expandHome(cfg.localRepoPath);
|
|
146
409
|
const keyPath = expandHome(cfg.keyPath);
|
|
147
410
|
const entries = buildFileEntries(cfg, repoPath, agent);
|
|
148
|
-
const result = { synced: [], skipped: [], decrypted: [], conflicts: [] };
|
|
149
|
-
// 批量决策:overwrite_all / skip_all
|
|
411
|
+
const result = { synced: [], skipped: [], decrypted: [], conflicts: [], localOnly: [] };
|
|
150
412
|
let batchDecision;
|
|
151
413
|
for (const entry of entries) {
|
|
152
414
|
const srcRepo = path.join(repoPath, entry.repoRel);
|
|
153
415
|
if (!fs.existsSync(srcRepo)) {
|
|
416
|
+
// repo 没有但本地有 → 记录为 localOnly
|
|
417
|
+
// jsonFields 条目:srcAbs 是完整 JSON(总是存在),需检查提取字段是否非空
|
|
418
|
+
if (entry.jsonExtract) {
|
|
419
|
+
try {
|
|
420
|
+
const fullJson = JSON.parse(fs.readFileSync(entry.srcAbs, 'utf-8'));
|
|
421
|
+
const extracted = jsonField.extractFields(fullJson, entry.jsonExtract.fields);
|
|
422
|
+
if (Object.keys(extracted).length > 0) {
|
|
423
|
+
result.localOnly.push(entry.repoRel);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
catch { /* JSON 解析失败则忽略 */ }
|
|
427
|
+
}
|
|
428
|
+
else if (fs.existsSync(entry.srcAbs)) {
|
|
429
|
+
result.localOnly.push(entry.repoRel);
|
|
430
|
+
}
|
|
154
431
|
logger.debug(`跳过(仓库中不存在): ${entry.repoRel}`);
|
|
155
432
|
result.skipped.push(entry.repoRel);
|
|
156
433
|
continue;
|
|
157
434
|
}
|
|
158
|
-
// ──
|
|
435
|
+
// ── JSON 字段级 merge-back ────────────────────────────────
|
|
436
|
+
if (entry.jsonExtract) {
|
|
437
|
+
let partialContent;
|
|
438
|
+
if (entry.encrypt) {
|
|
439
|
+
partialContent = cryptoEngine.decryptString(fs.readFileSync(srcRepo, 'utf-8').trim(), keyPath);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
partialContent = fs.readFileSync(srcRepo, 'utf-8');
|
|
443
|
+
}
|
|
444
|
+
const partial = JSON.parse(partialContent);
|
|
445
|
+
// 读取本地完整 JSON,merge 进去(不破坏其他字段)
|
|
446
|
+
const targetPath = entry.jsonExtract.originalPath;
|
|
447
|
+
let fullJson = {};
|
|
448
|
+
if (fs.existsSync(targetPath)) {
|
|
449
|
+
fullJson = JSON.parse(fs.readFileSync(targetPath, 'utf-8'));
|
|
450
|
+
}
|
|
451
|
+
const merged = jsonField.mergeFields(fullJson, partial);
|
|
452
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
453
|
+
fs.writeFileSync(targetPath, JSON.stringify(merged, null, 2), 'utf-8');
|
|
454
|
+
// shared MCP 条目:同时分发到所有其他 agent
|
|
455
|
+
if (entry.agentName === 'shared' && cfg.shared) {
|
|
456
|
+
for (const source of cfg.shared.mcp.sources) {
|
|
457
|
+
const p = cfg.profiles.default[source.agent];
|
|
458
|
+
if (!p.enabled)
|
|
459
|
+
continue;
|
|
460
|
+
const otherPath = path.join(expandHome(p.workspacePath), source.src);
|
|
461
|
+
if (otherPath === targetPath)
|
|
462
|
+
continue; // 已处理
|
|
463
|
+
let otherJson = {};
|
|
464
|
+
if (fs.existsSync(otherPath)) {
|
|
465
|
+
try {
|
|
466
|
+
otherJson = JSON.parse(fs.readFileSync(otherPath, 'utf-8'));
|
|
467
|
+
}
|
|
468
|
+
catch { /* */ }
|
|
469
|
+
}
|
|
470
|
+
const otherMerged = jsonField.mergeFields(otherJson, partial);
|
|
471
|
+
fs.mkdirSync(path.dirname(otherPath), { recursive: true });
|
|
472
|
+
fs.writeFileSync(otherPath, JSON.stringify(otherMerged, null, 2), 'utf-8');
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
result.synced.push(entry.repoRel);
|
|
476
|
+
if (entry.encrypt)
|
|
477
|
+
result.decrypted.push(entry.repoRel);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
// ── shared skills 分发到各 agent ──────────────────────────
|
|
481
|
+
if (entry.agentName === 'shared' && entry.repoRel.startsWith('shared/skills/')) {
|
|
482
|
+
const relInSkills = entry.repoRel.slice('shared/skills/'.length);
|
|
483
|
+
const shared = cfg.shared;
|
|
484
|
+
if (shared) {
|
|
485
|
+
for (const source of shared.skills.sources) {
|
|
486
|
+
const p = cfg.profiles.default[source.agent];
|
|
487
|
+
if (!p.enabled)
|
|
488
|
+
continue;
|
|
489
|
+
const dest = path.join(expandHome(p.workspacePath), source.dir, relInSkills);
|
|
490
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
491
|
+
fs.copyFileSync(srcRepo, dest);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
result.synced.push(entry.repoRel);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
// ── 冲突检测(整文件同步) ────────────────────────────────
|
|
159
498
|
if (fs.existsSync(entry.srcAbs)) {
|
|
160
499
|
let isDiff = false;
|
|
161
500
|
const localBuf = fs.readFileSync(entry.srcAbs);
|
|
@@ -173,17 +512,12 @@ export const syncEngine = {
|
|
|
173
512
|
}
|
|
174
513
|
if (isDiff) {
|
|
175
514
|
result.conflicts.push(entry.repoRel);
|
|
176
|
-
// 已有批量决策则直接应用
|
|
177
515
|
if (batchDecision === 'skip_all') {
|
|
178
516
|
logger.info(` ↷ 跳过(保留本地): ${entry.repoRel}`);
|
|
179
517
|
result.skipped.push(entry.repoRel);
|
|
180
518
|
continue;
|
|
181
519
|
}
|
|
182
|
-
if (batchDecision
|
|
183
|
-
// 继续走下方写入逻辑
|
|
184
|
-
}
|
|
185
|
-
else {
|
|
186
|
-
// 无批量决策,询问用户
|
|
520
|
+
if (batchDecision !== 'overwrite_all') {
|
|
187
521
|
const ans = await askConflict(entry.repoRel);
|
|
188
522
|
if (ans === 'skip' || ans === 'skip_all') {
|
|
189
523
|
if (ans === 'skip_all')
|
|
@@ -194,7 +528,6 @@ export const syncEngine = {
|
|
|
194
528
|
}
|
|
195
529
|
if (ans === 'overwrite_all')
|
|
196
530
|
batchDecision = 'overwrite_all';
|
|
197
|
-
// ans === 'overwrite' 或 'overwrite_all':继续走下方写入逻辑
|
|
198
531
|
}
|
|
199
532
|
}
|
|
200
533
|
}
|
|
@@ -207,7 +540,7 @@ export const syncEngine = {
|
|
|
207
540
|
else {
|
|
208
541
|
fs.copyFileSync(srcRepo, entry.srcAbs);
|
|
209
542
|
}
|
|
210
|
-
result.synced.push(entry.
|
|
543
|
+
result.synced.push(entry.repoRel);
|
|
211
544
|
}
|
|
212
545
|
return result;
|
|
213
546
|
},
|
|
@@ -229,6 +562,28 @@ export const syncEngine = {
|
|
|
229
562
|
diff.missing.push(entry.repoRel);
|
|
230
563
|
continue;
|
|
231
564
|
}
|
|
565
|
+
// JSON 字段提取时,比较提取后的内容
|
|
566
|
+
if (entry.jsonExtract) {
|
|
567
|
+
try {
|
|
568
|
+
const fullJson = JSON.parse(fs.readFileSync(entry.srcAbs, 'utf-8'));
|
|
569
|
+
const localPartial = JSON.stringify(jsonField.extractFields(fullJson, entry.jsonExtract.fields), null, 2);
|
|
570
|
+
let repoContent;
|
|
571
|
+
if (entry.encrypt) {
|
|
572
|
+
repoContent = cryptoEngine.decryptString(fs.readFileSync(path.join(repoPath, entry.repoRel), 'utf-8').trim(), keyPath);
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
repoContent = fs.readFileSync(path.join(repoPath, entry.repoRel), 'utf-8');
|
|
576
|
+
}
|
|
577
|
+
if (localPartial !== repoContent) {
|
|
578
|
+
diff.modified.push(entry.repoRel);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
diff.modified.push(entry.repoRel);
|
|
583
|
+
}
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
// 整文件比较
|
|
232
587
|
const srcBuf = fs.readFileSync(entry.srcAbs);
|
|
233
588
|
const repoBuf = fs.readFileSync(path.join(repoPath, entry.repoRel));
|
|
234
589
|
if (entry.encrypt) {
|