agent-syncer 0.1.0 → 0.1.2
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/CONTENT-REPO.md +526 -0
- package/README.md +540 -47
- package/bin/agent-sync.js +190 -18
- package/lib/commands/doctor.js +302 -23
- package/lib/commands/init.js +332 -0
- package/lib/commands/link.js +379 -103
- package/lib/commands/list.js +214 -0
- package/lib/commands/status.js +236 -46
- package/lib/commands/sync.js +555 -0
- package/lib/config.js +303 -96
- package/lib/gitignore.js +50 -22
- package/lib/install.js +260 -0
- package/lib/log.js +11 -0
- package/lib/manifest.js +442 -0
- package/lib/merge.js +1255 -0
- package/lib/prompt.js +364 -1
- package/lib/prune.js +80 -0
- package/lib/record.js +460 -0
- package/lib/source.js +312 -0
- package/lib/stale.js +130 -0
- package/lib/target.js +152 -13
- package/package.json +3 -2
package/lib/merge.js
ADDED
|
@@ -0,0 +1,1255 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { isSymlink } from './link.js';
|
|
6
|
+
import { ITEM_KINDS, parseItem } from './manifest.js';
|
|
7
|
+
import {
|
|
8
|
+
CONTENT_ROOT,
|
|
9
|
+
MERGE_KINDS,
|
|
10
|
+
PROJECT_DIR_ALIASES,
|
|
11
|
+
TOOLS,
|
|
12
|
+
isTool,
|
|
13
|
+
mergeKindsOf,
|
|
14
|
+
mergeTarget,
|
|
15
|
+
projectDirOf,
|
|
16
|
+
} from './target.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 合并器:把 `.agents/hooks/*.json` 和 `.agents/mcp/*.json` 写进各工具自己的配置文件。
|
|
20
|
+
*
|
|
21
|
+
* ## 为什么不能像 skills 那样建链接
|
|
22
|
+
*
|
|
23
|
+
* `.claude/skills` 是指向 `.agents/skills/` 的 junction,内容只存一份。但
|
|
24
|
+
* `.mcp.json` 和 `.claude/settings.json` 里**还有用户自己的东西**,整份覆盖等于
|
|
25
|
+
* 把它们删了。所以这两类只能**读—改—写**:保留用户的一切,只动我们负责的那部分。
|
|
26
|
+
*
|
|
27
|
+
* ## 归属问题:整个模块的核心
|
|
28
|
+
*
|
|
29
|
+
* 「我们负责的那部分」不能靠推断。有条看起来能走的捷径——「`.agent-sync.json`
|
|
30
|
+
* 的 `installed` 里有 `mcp:foo`,所以 `.mcp.json` 里的 `foo` 就是我们写的」。
|
|
31
|
+
* **它是错的**:`installed` 说的是「`.agents/mcp/foo.json` 这个文件是本工具放的」,
|
|
32
|
+
* 而用户完全可能在自己的 `.mcp.json` 里写一个同名的 server。两者会分叉,
|
|
33
|
+
* 于是**第二轮 sync 就会把用户那份静默吃掉**。
|
|
34
|
+
*
|
|
35
|
+
* 所以归属来自 `merged` 记录(见 `record.js`),而且**是被验证的**:
|
|
36
|
+
* 「记录说是我写的」还不够,还要「**现场确实还是我写的那份**」才动。
|
|
37
|
+
* 现场对不上 → 判为用户改过 → 保留 + 报告。这个安全阀是刻意做的:
|
|
38
|
+
* 宁可少清理,不可多删除。
|
|
39
|
+
*
|
|
40
|
+
* ## 一条铁律:绝不静默降级
|
|
41
|
+
*
|
|
42
|
+
* 转不过去的条目(`url` 没有 `type`、套了 `mcpServers` 包装、事件名拼错……)
|
|
43
|
+
* 一律**报出来**。一个「看起来配好了但不生效」的 MCP server 比没有更糟——
|
|
44
|
+
* 这是 `CONTENT-REPO.md` 已经写在 rules 那节的口径。
|
|
45
|
+
*
|
|
46
|
+
* ## 分层
|
|
47
|
+
*
|
|
48
|
+
* 上半部分是**纯函数**(不碰磁盘、不打印),下半部分是 fs 层。
|
|
49
|
+
* 命令层只跟 `applyMerge()` 和 `checkMerge()` 打交道。
|
|
50
|
+
*
|
|
51
|
+
* ## 三个入参的意思(整个模块都按这套说法)
|
|
52
|
+
*
|
|
53
|
+
* - `desired`:这一轮**要写进去**的,`id → 值`。调用方已经把选中和 `protect` 过滤过了
|
|
54
|
+
* - `keep`:**整个不碰**的 id。两种来源:`protect` 锁住的,以及**这一轮不想摘的**
|
|
55
|
+
* (没加 `--prune` 时,掉出选择的那些)。两种都不改目标文件,而且**归属一直留着**
|
|
56
|
+
* - `prev`:上一轮我们写了什么(`merged[tool][kind]`)
|
|
57
|
+
*
|
|
58
|
+
* **要摘掉的 = `prev` 里有、但 `desired` 和 `keep` 里都没有的**。由这条推出来,
|
|
59
|
+
* 不需要调用方再算一遍——少一个能算错的地方。
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/** JSON 意义上的「一个对象」——null 和数组都不算 */
|
|
63
|
+
function isPlain(value) {
|
|
64
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 有 BOM 就剥掉。
|
|
69
|
+
*
|
|
70
|
+
* Windows 上记事本、PowerShell 的 `>` 重定向默认都写带 BOM 的 UTF-8,而
|
|
71
|
+
* `JSON.parse` 见 BOM 直接抛。**这份知识以前散在好几个地方**:merge.js 在
|
|
72
|
+
* `readJsonFile` 里内联做了一次,config.js 抄了一份(还注明「merge.js 早就这么
|
|
73
|
+
* 处理了,这里跟上」),而 record.js 压根没做——于是记事本改一下
|
|
74
|
+
* `.agents/.agent-sync.json`,记录当场判为「不可用」,合并产物的归属跟着全丢。
|
|
75
|
+
* 收在这里一处,谁读 JSON 谁拿去用。
|
|
76
|
+
*
|
|
77
|
+
* @param {string} text
|
|
78
|
+
*/
|
|
79
|
+
export function stripBom(text) {
|
|
80
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 这个错误是不是「路径上真的没有东西」。
|
|
85
|
+
*
|
|
86
|
+
* **只有 `ENOENT` / `ENOTDIR` 算不存在,其余(`EPERM` / `EACCES` / `EBUSY` /
|
|
87
|
+
* `ELOOP`…)一律是「有东西,但读不到」。** 别逐个列举权限类错误码:Windows 上
|
|
88
|
+
* 同一件事可能是 EPERM 也可能是 EACCES,漏一个就退化成「不存在」。
|
|
89
|
+
*
|
|
90
|
+
* 这个区分不是洁癖,两个方向都出过事:
|
|
91
|
+
*
|
|
92
|
+
* - **降级成「不存在」→ 接着写。** `readJsonFile` 的 blocker 就是这么来的:
|
|
93
|
+
* 读不到被当成「这文件没有」,调用方走「没坏、可以写」那条分支,把用户已有的
|
|
94
|
+
* `.mcp.json` 整份覆盖掉。
|
|
95
|
+
* - **降级成「不存在」→ 报给用户看。** `status` 的内容计数原先 `catch` 一律返回
|
|
96
|
+
* null,于是权限错、IO 错都打成「无此目录」——用户看到的是「这儿没东西」,
|
|
97
|
+
* 而真相是「这儿的东西我看不到」。
|
|
98
|
+
*
|
|
99
|
+
* 全项目四处要做这个判断(`readJsonFile`、`presentIn`、`config.js` 读配置、
|
|
100
|
+
* `status.js` 数内容),所以收在这里一份。文件在 merge.js 而不是某个新模块:
|
|
101
|
+
* 那三个调用方本来就都从 merge.js 拿东西,不用为此新增一条依赖边。
|
|
102
|
+
*
|
|
103
|
+
* @param {unknown} err
|
|
104
|
+
*/
|
|
105
|
+
export function isMissingPath(err) {
|
|
106
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err)?.code;
|
|
107
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 路径是否存在,**断链也算存在**。
|
|
112
|
+
*
|
|
113
|
+
* 不能用 `fs.existsSync`:它跟的是链接目标,断链因此返回 false,而我们两道
|
|
114
|
+
* 「不碰用户的链接」的守卫都以「存在」为前提。守卫被整条跳过之后,`rename`
|
|
115
|
+
* 会**直接把那个断链换成我们的文件**——POSIX 上静默生效,Windows 上则报
|
|
116
|
+
* 一句莫名其妙的 `写入失败:EPERM … rename`。`lstat` 不跟目标,所以断链
|
|
117
|
+
* 在这里照样算「有东西」。
|
|
118
|
+
*
|
|
119
|
+
* `link.js` 里有一个同名的内部工具,但它没导出,也不该为了这一处把它的
|
|
120
|
+
* 可见性放大——这里只需要「在不在」这一个问题。
|
|
121
|
+
*
|
|
122
|
+
* @param {string} p
|
|
123
|
+
*/
|
|
124
|
+
function lexists(p) {
|
|
125
|
+
try {
|
|
126
|
+
fs.lstatSync(p);
|
|
127
|
+
return true;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 结构相等,**对象键序无关**。
|
|
135
|
+
*
|
|
136
|
+
* 必须无序:工具自己重写配置文件时可能重排键序。按序比会把它误判成
|
|
137
|
+
* 「用户改了我们的东西」,于是走保护分支、那份记录**永远不再参与清理**——
|
|
138
|
+
* 一个很难查的卡死。
|
|
139
|
+
*/
|
|
140
|
+
export function deepEqualJSON(a, b) {
|
|
141
|
+
if (a === b) return true;
|
|
142
|
+
if (typeof a !== typeof b) return false;
|
|
143
|
+
|
|
144
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
145
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
146
|
+
return a.every((x, i) => deepEqualJSON(x, b[i]));
|
|
147
|
+
}
|
|
148
|
+
if (!isPlain(a) || !isPlain(b)) return false;
|
|
149
|
+
|
|
150
|
+
const ka = Object.keys(a);
|
|
151
|
+
if (ka.length !== Object.keys(b).length) return false;
|
|
152
|
+
return ka.every((k) => Object.hasOwn(b, k) && deepEqualJSON(a[k], b[k]));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** `prev` 里有、`desired` 和 `keep` 里都没有的 —— 这一轮该摘掉的 */
|
|
156
|
+
function removalIds(prev, desired, keep) {
|
|
157
|
+
const protectedIds = new Set(keep);
|
|
158
|
+
return Object.keys(prev).filter((id) => !protectedIds.has(id) && !Object.hasOwn(desired, id));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 把内容里写的项目根变量换成 `tool` 自己的写法。
|
|
163
|
+
*
|
|
164
|
+
* 内容仓库的铁律是「只描述这是什么,不描述写到哪」,所以里面不该出现某个具体
|
|
165
|
+
* 工具的变量名。但引用项目内脚本时又确实需要一个「项目根」的写法,各工具叫法
|
|
166
|
+
* 还不同。解法是一张**别名表**(`PROJECT_DIR_ALIASES`):写哪个都认,到这里
|
|
167
|
+
* 统一翻译。于是已经写下的 `${CLAUDE_PROJECT_DIR}` 不会作废,写
|
|
168
|
+
* `${workspaceFolder}` 的内容也能在 Claude 上跑。
|
|
169
|
+
*
|
|
170
|
+
* **只认表里那几个**。`${DEPT_WIKI_TOKEN}` 这类环境变量原样留着——它们由工具
|
|
171
|
+
* 自己的进程环境在运行时展开,不归我们管。
|
|
172
|
+
*
|
|
173
|
+
* @param {string} s @param {string} tool
|
|
174
|
+
*/
|
|
175
|
+
function translateString(s, tool) {
|
|
176
|
+
const target = projectDirOf(tool)?.var;
|
|
177
|
+
if (!target) return s;
|
|
178
|
+
|
|
179
|
+
let out = s;
|
|
180
|
+
for (const alias of PROJECT_DIR_ALIASES) {
|
|
181
|
+
if (alias === target) continue;
|
|
182
|
+
// 用 split/join 而不是正则:变量名里没有需要转义的字符,少一类边界
|
|
183
|
+
out = out.split(`\${${alias}}`).join(`\${${target}}`);
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 深度遍历,翻译所有字符串值 */
|
|
189
|
+
function translateValue(value, tool) {
|
|
190
|
+
if (typeof value === 'string') return translateString(value, tool);
|
|
191
|
+
if (Array.isArray(value)) return value.map((v) => translateValue(v, tool));
|
|
192
|
+
if (isPlain(value)) {
|
|
193
|
+
/** @type {Record<string, any>} */
|
|
194
|
+
const out = {};
|
|
195
|
+
for (const [k, v] of Object.entries(value)) out[k] = translateValue(v, tool);
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// 内容文件 → 该写进目标的值
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 校验并翻译一条 MCP 定义。
|
|
207
|
+
*
|
|
208
|
+
* 三条硬规则,漏了就是「静默不生效」:
|
|
209
|
+
*
|
|
210
|
+
* 1. **不能套 `mcpServers` 包装**。那是配置文件里的层级,不是内容文件里的。
|
|
211
|
+
* 套了的话我们会写出一个名叫 `mcpServers` 的 server——`CONTENT-REPO.md`
|
|
212
|
+
* 的 FAQ 把这列为头号作者错误。
|
|
213
|
+
* 2. **有 `url` 就必须有 `type`**。实测过:只有 `url` 没有 `type` 的配置会被
|
|
214
|
+
* Claude 当成 stdio server 读,然后跳过。**不替它补**——Trae 的 schema 我们
|
|
215
|
+
* 没实证,多写一个 `type` 有可能直接让它拒收。顺手补一个 `type: "stdio"`
|
|
216
|
+
* 正是把「我们以为对」当成「它一定对」。
|
|
217
|
+
* 3. 顶层必须是对象。
|
|
218
|
+
*
|
|
219
|
+
* 其余一律原样透传,只做变量翻译。**不注入 `type: "stdio"`**:Claude 缺
|
|
220
|
+
* `type` 本来就按 stdio 读,多写一个键纯属多余。
|
|
221
|
+
*
|
|
222
|
+
* @param {any} raw @param {string} tool
|
|
223
|
+
* @returns {{value: Record<string, any>}|{error: string}}
|
|
224
|
+
*/
|
|
225
|
+
export function buildMcpEntry(raw, tool) {
|
|
226
|
+
if (!isPlain(raw)) {
|
|
227
|
+
return { error: '顶层必须是一个对象(单个 server 的定义)' };
|
|
228
|
+
}
|
|
229
|
+
if (Object.hasOwn(raw, 'mcpServers')) {
|
|
230
|
+
return {
|
|
231
|
+
error:
|
|
232
|
+
'内容文件里不要套 mcpServers 包装——那是配置文件里的层级。' +
|
|
233
|
+
'直接写 server 自己的字段(command / args / env,或 type / url / headers)',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
if (typeof raw.url === 'string' && typeof raw.type !== 'string') {
|
|
237
|
+
return {
|
|
238
|
+
error:
|
|
239
|
+
'有 url 却没有 type——这样写会被当成 stdio server 读然后**静默跳过**。' +
|
|
240
|
+
'请补上 "type"(远端传输用 "http"),本工具不替你猜',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return { value: translateValue(raw, tool) };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 已知的 hook 事件名。**尽力而为的清单,不是权威**——CLI 版本更新会加新事件。
|
|
248
|
+
* 列出来只为了抓「Sessionstart 拼错了」这类静默失效:事件名写错不会报错,
|
|
249
|
+
* 那个 hook 就是永远不跑。
|
|
250
|
+
*/
|
|
251
|
+
const KNOWN_HOOK_EVENTS = new Set([
|
|
252
|
+
'PreToolUse',
|
|
253
|
+
'PostToolUse',
|
|
254
|
+
'Notification',
|
|
255
|
+
'UserPromptSubmit',
|
|
256
|
+
'Stop',
|
|
257
|
+
'SubagentStop',
|
|
258
|
+
'PreCompact',
|
|
259
|
+
'SessionStart',
|
|
260
|
+
'SessionEnd',
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
/** 明显是文件级元数据的键。它们绝不该出现在 hook 内容文件里——合并过去就是脏数据 */
|
|
264
|
+
const HOOK_META_KEYS = new Set(['description', '$schema', 'title']);
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 校验并翻译一个 hook 片段。
|
|
268
|
+
*
|
|
269
|
+
* @param {any} raw @param {string} tool
|
|
270
|
+
* @returns {{value: Record<string, any[]>, warnings: string[]}|{error: string}}
|
|
271
|
+
*/
|
|
272
|
+
export function buildHooksEntry(raw, tool) {
|
|
273
|
+
if (!isPlain(raw)) {
|
|
274
|
+
return { error: '顶层必须是一个对象,键是事件名(如 SessionStart / PostToolUse)' };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** @type {Record<string, any[]>} */
|
|
278
|
+
const events = {};
|
|
279
|
+
/** @type {string[]} */
|
|
280
|
+
const warnings = [];
|
|
281
|
+
|
|
282
|
+
for (const [name, entries] of Object.entries(raw)) {
|
|
283
|
+
if (HOOK_META_KEYS.has(name) || name.startsWith('_')) {
|
|
284
|
+
return {
|
|
285
|
+
error:
|
|
286
|
+
`顶层键 "${name}" 不是事件名。文件级的说明写文档,别写在这里——` +
|
|
287
|
+
'它会被原样合并进 settings.json 变成脏数据',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (!Array.isArray(entries)) {
|
|
291
|
+
return { error: `事件 "${name}" 的值必须是数组` };
|
|
292
|
+
}
|
|
293
|
+
if (!KNOWN_HOOK_EVENTS.has(name)) {
|
|
294
|
+
warnings.push(`事件名 "${name}" 不在已知列表里——如果这是拼错的,那个 hook 永远不会跑`);
|
|
295
|
+
}
|
|
296
|
+
events[name] = translateValue(entries, tool);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return { value: events, warnings };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// 纯函数核:算下一轮该写成什么
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* `.mcp.json` / `.trae/mcp.json` 的合并。
|
|
308
|
+
*
|
|
309
|
+
* @param {{
|
|
310
|
+
* current: Record<string, any>, desired: Record<string, any>,
|
|
311
|
+
* keep: string[], prev: Record<string, any>,
|
|
312
|
+
* }} input
|
|
313
|
+
* @returns {{next: Record<string, any>, recorded: Record<string, any>, conflicts: string[], problems: string[]}}
|
|
314
|
+
*/
|
|
315
|
+
function mergeMcp({ current, desired, keep, prev }) {
|
|
316
|
+
/** @type {string[]} */
|
|
317
|
+
const conflicts = [];
|
|
318
|
+
/** @type {string[]} */
|
|
319
|
+
const problems = [];
|
|
320
|
+
|
|
321
|
+
// `mcpServers` 在,但根本不是普通对象。**整段不碰,也不去「修好」它。**
|
|
322
|
+
//
|
|
323
|
+
// 为什么是「不动」而不是「修好」:那个值是用户写下的形态,我们猜不出他的本意
|
|
324
|
+
// (也许是将来某个版本支持的新写法),按自己的理解重写「成对象」就等于把他写的
|
|
325
|
+
// 东西直接抹掉。实测过的老行为更难堪——`{"mcpServers":"x"}` 会被当成空的,
|
|
326
|
+
// 然后写成 `{}`,值凭空消失,`problems` 还一声不吭。
|
|
327
|
+
//
|
|
328
|
+
// 记录里的归属照旧留着(同下面「锁住的不碰」那条口径):读不懂现场就无从验证,
|
|
329
|
+
// 抹掉才是真的失联。
|
|
330
|
+
if (Object.hasOwn(current, 'mcpServers') && !isPlain(current.mcpServers)) {
|
|
331
|
+
problems.push('mcpServers 不是一个对象——结构不认识,没动它');
|
|
332
|
+
return { next: { ...current }, recorded: { ...prev }, conflicts, problems };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 这一轮压根没有要合的东西。什么都不动,于是 `next` 和读到的完全相同,调用方
|
|
336
|
+
// 自然不会去写盘——用户的 `{"mcpServers":{}}` 也就不会被「顺手」删成 `{}`。
|
|
337
|
+
//
|
|
338
|
+
// 只在**真的动过东西之后**才收掉空壳(见下面 `delete next.mcpServers`):
|
|
339
|
+
// 那是「我们的条目摘干净了,别留个空架子」,和「本轮无事可做却重写一遍文件」
|
|
340
|
+
// 是两件事。
|
|
341
|
+
if (Object.keys(desired).length === 0 && Object.keys(prev).length === 0) {
|
|
342
|
+
return { next: { ...current }, recorded: {}, conflicts, problems };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** @type {Record<string, any>} */
|
|
346
|
+
const next = { ...current };
|
|
347
|
+
/** @type {Record<string, any>} */
|
|
348
|
+
const servers = { ...(isPlain(current.mcpServers) ? current.mcpServers : {}) };
|
|
349
|
+
|
|
350
|
+
/** @type {Record<string, any>} */
|
|
351
|
+
const recorded = {};
|
|
352
|
+
|
|
353
|
+
// ---- 1. 该摘的摘 ----
|
|
354
|
+
for (const id of removalIds(prev, desired, keep)) {
|
|
355
|
+
const was = prev[id];
|
|
356
|
+
if (!Object.hasOwn(servers, id)) continue; // 现场本来就没了,不用管
|
|
357
|
+
|
|
358
|
+
if (deepEqualJSON(servers[id], was)) {
|
|
359
|
+
delete servers[id];
|
|
360
|
+
} else {
|
|
361
|
+
// 现场和记录对不上 —— 有人改过。判为用户的,保留,但**继续记着**:
|
|
362
|
+
// 沿用 P2 那条口径,认得出的残留要一直留在记录里,抹掉就等于失联。
|
|
363
|
+
recorded[id] = was;
|
|
364
|
+
conflicts.push(`mcp:${id} 被改过,没有删除`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---- 2. 锁住的 / 这一轮暂不摘的:整个不碰,但**归属一直留着** ----
|
|
369
|
+
//
|
|
370
|
+
// 必须**无条件**保留 `prev` 里的值,不能写成「对得上才记」:
|
|
371
|
+
// 对不上说明用户改过它,而改过的**更要**说得出它本来是我们的——否则这一条
|
|
372
|
+
// 从此失联,将来加 `--prune` 也找不到它,更不会告诉你它为什么还在。
|
|
373
|
+
// 「没删掉的残留要一直留在记录里」是 P2 就定下的口径。
|
|
374
|
+
//
|
|
375
|
+
// 自我清理:下一轮真去摘它的时候,现场已经没有这个键的话它不会进 recorded,
|
|
376
|
+
// 记录里不会越积越多。
|
|
377
|
+
for (const id of keep) {
|
|
378
|
+
if (prev[id] !== undefined) recorded[id] = prev[id];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---- 3. 该写的写 ----
|
|
382
|
+
for (const [id, want] of Object.entries(desired)) {
|
|
383
|
+
const was = prev[id];
|
|
384
|
+
const has = Object.hasOwn(servers, id);
|
|
385
|
+
|
|
386
|
+
if (has && was === undefined) {
|
|
387
|
+
// 目标里已经有了,但记录里没有。**绝不覆盖**,而且不记进 recorded,
|
|
388
|
+
// 所以下一轮它还是「不是我们的」。
|
|
389
|
+
//
|
|
390
|
+
// 两种来路都要说清楚,别只说「你自己写的」:除了用户手写的那份,
|
|
391
|
+
// **升级前本工具写过的**也会落到这里——老记录只有 `installed`,没有
|
|
392
|
+
// `merged` 那一段(见 record.js 开头)。把后者说成「你自己写的」,
|
|
393
|
+
// 用户会一头雾水地去找一个他从没写过的 server。
|
|
394
|
+
conflicts.push(
|
|
395
|
+
`mcp:${id} 与记录之外的 server 重名——没有覆盖。` +
|
|
396
|
+
'它可能是你自己写进配置文件的,也可能是更早版本的记录里还没有 merged 那一段、' +
|
|
397
|
+
'而本工具当时写下的。要本工具接管就先删掉那一份,要保住它就写进 protect',
|
|
398
|
+
);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (has && !deepEqualJSON(servers[id], was)) {
|
|
402
|
+
// 是我们写的,但被人改过。同样保留 + 报告,不覆盖回去。
|
|
403
|
+
recorded[id] = was;
|
|
404
|
+
conflicts.push(`mcp:${id} 被改过——保留你的改动没覆盖。想彻底由你管就写进 protect`);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
servers[id] = want;
|
|
409
|
+
recorded[id] = want;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (Object.keys(servers).length > 0) next.mcpServers = servers;
|
|
413
|
+
else delete next.mcpServers;
|
|
414
|
+
|
|
415
|
+
return { next, recorded, conflicts, problems };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* `.claude/settings.json` 的 `hooks` 段合并。
|
|
420
|
+
*
|
|
421
|
+
* 难点在于事件数组里**我们和用户的条目混在一起**,而 JSON 没有注释可用、
|
|
422
|
+
* 也不能加 `_source` 之类的标记键(会变成脏数据)。所以只能靠记录:
|
|
423
|
+
*
|
|
424
|
+
* 1. 先把 `prev` 里记着的(即上一轮我们自己追加的)**按深度相等全部摘掉**,
|
|
425
|
+
* 剩下的就都是用户的
|
|
426
|
+
* 2. 再把这一轮 `desired` 的追加进去
|
|
427
|
+
*
|
|
428
|
+
* 「按深度相等」意味着:用户改过的那一条匹配不上,会留在「用户的」那一堆里
|
|
429
|
+
* 被保留下来——这正是我们要的。**改过的就归用户。**
|
|
430
|
+
*
|
|
431
|
+
* ⚠️ 第 1 步必须摘**全部** `prev`,不能只摘「要被删掉的」那几个:还在选择里的
|
|
432
|
+
* 条目也要先摘再重新追加,否则第 2 步会因为「列表里已经有了」而跳过,那一条
|
|
433
|
+
* 就不会进 `recorded`——归属当场丢失,下一轮再也清不掉。
|
|
434
|
+
*
|
|
435
|
+
* @param {{
|
|
436
|
+
* current: Record<string, any>, desired: Record<string, any>,
|
|
437
|
+
* keep: string[], prev: Record<string, any>,
|
|
438
|
+
* }} input
|
|
439
|
+
* @returns {{next: Record<string, any>, recorded: Record<string, any>, conflicts: string[], problems: string[]}}
|
|
440
|
+
*/
|
|
441
|
+
function mergeHooks({ current, desired, keep, prev }) {
|
|
442
|
+
/** @type {Record<string, any>} */
|
|
443
|
+
const next = { ...current };
|
|
444
|
+
/** @type {string[]} */
|
|
445
|
+
const conflicts = [];
|
|
446
|
+
/** @type {string[]} */
|
|
447
|
+
const problems = [];
|
|
448
|
+
|
|
449
|
+
// `hooks` 在,但不是普通对象 —— 和 mcp 那一侧同一个道理:结构不认识就整段不碰,
|
|
450
|
+
// 报出来让用户自己决定,而不是按我们的理解把它重写成一个对象。
|
|
451
|
+
// 老行为会把它替换成 `{}` / `{SessionStart:[]}`,用户写的东西直接消失。
|
|
452
|
+
if (Object.hasOwn(current, 'hooks') && !isPlain(current.hooks)) {
|
|
453
|
+
problems.push('hooks 不是一个对象——结构不认识,没动它');
|
|
454
|
+
return { next, recorded: { ...prev }, conflicts, problems };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// 这一轮没有要合的东西(内容一条没选、记录里也一条没写过):什么都不动。
|
|
458
|
+
// `{"hooks":{"SessionStart":"字符串"}}` 这种不认识的值才不会被改写成
|
|
459
|
+
// `{"SessionStart":[]}`,`{"hooks":{}}` 也不会被顺手删成 `{}`。
|
|
460
|
+
if (Object.keys(desired).length === 0 && Object.keys(prev).length === 0) {
|
|
461
|
+
return { next, recorded: {}, conflicts, problems };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const hookEntries = isPlain(current.hooks) ? current.hooks : {};
|
|
465
|
+
/** @type {Record<string, any>} */
|
|
466
|
+
const hooks = { ...hookEntries };
|
|
467
|
+
|
|
468
|
+
const keepSet = new Set(keep);
|
|
469
|
+
|
|
470
|
+
/** 要碰的事件名:上一轮写过的 + 这一轮要写的 */
|
|
471
|
+
const touched = new Set();
|
|
472
|
+
for (const src of [prev, desired]) {
|
|
473
|
+
for (const events of Object.values(src)) {
|
|
474
|
+
if (isPlain(events)) for (const e of Object.keys(events)) touched.add(e);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// 值不是数组的事件名:结构不认识。**这一轮不碰它**,只报一声。
|
|
479
|
+
// `[...(hooks[e] ?? [])]` 展开一个字符串会得到一堆单字,然后 splice 掉——
|
|
480
|
+
// 那就不是「没动它」了,是把它拆了。
|
|
481
|
+
/** @type {string[]} */
|
|
482
|
+
const mergeable = [];
|
|
483
|
+
for (const e of touched) {
|
|
484
|
+
if (Array.isArray(hookEntries[e]) || hookEntries[e] === undefined) mergeable.push(e);
|
|
485
|
+
else problems.push(`hook 事件 "${e}" 不是一个数组——结构不认识,没动它`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---- 1. 按记录把自己上一轮追加的摘掉,剩下的归用户 ----
|
|
489
|
+
/** @type {Record<string, any[]>} */
|
|
490
|
+
const userOwned = {};
|
|
491
|
+
/** 记录说我们写过、现场却找不到的 id。稍后要**留在记录里**,见下面。 */
|
|
492
|
+
const lost = new Set();
|
|
493
|
+
for (const e of mergeable) {
|
|
494
|
+
const list = [...(hookEntries[e] ?? [])];
|
|
495
|
+
|
|
496
|
+
for (const [id, events] of Object.entries(prev)) {
|
|
497
|
+
if (keepSet.has(id)) continue; // 锁住的条目不碰,它们的条目留在原处
|
|
498
|
+
for (const entry of events[e] ?? []) {
|
|
499
|
+
const at = list.findIndex((x) => deepEqualJSON(x, entry));
|
|
500
|
+
if (at === -1) {
|
|
501
|
+
// 记录说是我们写的,现场却找不到:被删了,或者被改过。现场留下的那份
|
|
502
|
+
// 当用户的——但**归属继续留在记录里**。
|
|
503
|
+
//
|
|
504
|
+
// 这条以前只报一次就彻底失联,而 mcp 那一侧同样情形是「一直留着、每轮都
|
|
505
|
+
// 报」。两边必须一致:抹掉归属就等于「用户漏看一次就永远找不回来」,
|
|
506
|
+
// 将来 `--prune` 也找不到它、更不会解释它为什么还在。代价是这声警告
|
|
507
|
+
// 每轮重复——可以接受,`status` / `doctor` 里本来就该一直看得见。
|
|
508
|
+
lost.add(id);
|
|
509
|
+
conflicts.push(
|
|
510
|
+
`hook:${id} 在 ${e} 里有一条找不到了(可能被改过或删过)——` +
|
|
511
|
+
'现场留下的那份按你的保留,记录里继续记着它,不会被悄悄忘掉',
|
|
512
|
+
);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
list.splice(at, 1);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
userOwned[e] = list;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---- 2. 把这一轮的追加进去(用户已有的不重复加)----
|
|
522
|
+
/** @type {Record<string, any>} */
|
|
523
|
+
const appended = {};
|
|
524
|
+
for (const e of mergeable) {
|
|
525
|
+
const list = userOwned[e];
|
|
526
|
+
for (const [id, events] of Object.entries(desired)) {
|
|
527
|
+
for (const entry of events[e] ?? []) {
|
|
528
|
+
if (list.some((x) => deepEqualJSON(x, entry))) continue;
|
|
529
|
+
list.push(entry);
|
|
530
|
+
((appended[id] ??= {})[e] ??= []).push(entry);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (list.length > 0) hooks[e] = list;
|
|
534
|
+
else delete hooks[e];
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---- 3. 丢失的、锁住的、这一轮暂不摘的:整个不碰,但**归属一直留着** ----
|
|
538
|
+
//
|
|
539
|
+
// 同 mcp 那一侧:无条件保留,不能「对得上才记」。对不上说明用户改过,
|
|
540
|
+
// 而改过的更要说得出来它本来是我们的,否则将来 `--prune` 找不到它,
|
|
541
|
+
// 也不会告诉你它为什么还在。
|
|
542
|
+
//
|
|
543
|
+
// 起点是**只记我们实际追加的**:用户原本就有的(哪怕长得一模一样)不认领——
|
|
544
|
+
// 认领了的话,将来清理会把他的东西删掉。
|
|
545
|
+
/** @type {Record<string, any>} */
|
|
546
|
+
const recorded = { ...appended };
|
|
547
|
+
for (const id of lost) {
|
|
548
|
+
if (prev[id] !== undefined) recorded[id] = prev[id];
|
|
549
|
+
}
|
|
550
|
+
for (const id of keep) {
|
|
551
|
+
if (prev[id] !== undefined) recorded[id] = prev[id];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (Object.keys(hooks).length > 0) next.hooks = hooks;
|
|
555
|
+
else delete next.hooks;
|
|
556
|
+
|
|
557
|
+
return { next, recorded, conflicts, problems };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** `MERGE_KINDS` 里的类型 → 合并实现 */
|
|
561
|
+
const MERGERS = { mcp: mergeMcp, hooks: mergeHooks };
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* 算一个工具的一类内容合并之后该是什么样。**纯函数,不碰磁盘。**
|
|
565
|
+
*
|
|
566
|
+
* `problems` 里是「现场的结构我们不认识,所以一个字都没动」这类事——它和
|
|
567
|
+
* `conflicts`(现场像你的东西,保留)不是一回事,别混:`conflicts` 是正常的
|
|
568
|
+
* 保护行为,`problems` 是「本该写进去的没写进去」,调用方要当成故障报出来。
|
|
569
|
+
*
|
|
570
|
+
* @param {{
|
|
571
|
+
* kind: string, current: Record<string, any>, desired: Record<string, any>,
|
|
572
|
+
* keep?: string[], prev: Record<string, any>,
|
|
573
|
+
* }} input
|
|
574
|
+
* @returns {{next: Record<string, any>, recorded: Record<string, any>, conflicts: string[], problems: string[]}}
|
|
575
|
+
*/
|
|
576
|
+
export function planMerge({ kind, current, desired, keep = [], prev }) {
|
|
577
|
+
const merge = MERGERS[kind];
|
|
578
|
+
if (!merge) {
|
|
579
|
+
throw new Error(`未知的合并类型 "${kind}"(可用:${Object.keys(MERGERS).join('、')})`);
|
|
580
|
+
}
|
|
581
|
+
return merge({ current, desired, keep, prev });
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ---------------------------------------------------------------------------
|
|
585
|
+
// fs 层
|
|
586
|
+
// ---------------------------------------------------------------------------
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* 读一个 JSON 配置文件。**「不存在」「空文件」「坏文件」「读不到」是四件事。**
|
|
590
|
+
*
|
|
591
|
+
* 坏文件绝不能被当成空的然后覆盖写——那是在用户配置已经坏了的时候再补一刀。
|
|
592
|
+
* 「读不到」同理,而且更隐蔽:`EPERM` / `EACCES` / `EBUSY` / `ELOOP` 这些错误
|
|
593
|
+
* 一概当成「文件不存在」的话,`applyMerge` 就会以为「没坏、可以写」,
|
|
594
|
+
* 把用户那份配置**整份覆盖掉**(实测过:给 `.mcp.json` 加一条拒绝读取的 ACL,
|
|
595
|
+
* 回来的是 `problems: []`、`changed: true`,用户自己的内容没了)。
|
|
596
|
+
*
|
|
597
|
+
* @param {string} abs
|
|
598
|
+
* @returns {{exists: boolean, empty: boolean, bom: boolean, value: any, error: string|null}}
|
|
599
|
+
*/
|
|
600
|
+
export function readJsonFile(abs) {
|
|
601
|
+
/** @type {string} */
|
|
602
|
+
let raw;
|
|
603
|
+
try {
|
|
604
|
+
raw = fs.readFileSync(abs, 'utf8');
|
|
605
|
+
} catch (e) {
|
|
606
|
+
const code = /** @type {NodeJS.ErrnoException} */ (e).code;
|
|
607
|
+
// ENOENT:真的没有。ENOTDIR:路径中间被一个文件挡住了(`.mcp.json/x`)——
|
|
608
|
+
// 对我们的用途来说同样等于「这个路径上没有文件」(判据见 isMissingPath)。
|
|
609
|
+
if (isMissingPath(e)) {
|
|
610
|
+
return { exists: false, empty: false, bom: false, value: {}, error: null };
|
|
611
|
+
}
|
|
612
|
+
// 其余一律是「有东西,但读不到」。**绝不能降级成「不存在」**:那会让调用方
|
|
613
|
+
// 走「没坏、可以写」那条分支。Windows 上权限问题可能是 EPERM 也可能是 EACCES,
|
|
614
|
+
// 所以这里不逐个列举,凡不是上面两种的都算。
|
|
615
|
+
return {
|
|
616
|
+
exists: true,
|
|
617
|
+
empty: false,
|
|
618
|
+
bom: false,
|
|
619
|
+
value: null,
|
|
620
|
+
error: `读不到(${code ?? '未知错误'}):${/** @type {Error} */ (e).message}`,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Windows 上的编辑器加 UTF-8 BOM 很常见,JSON.parse 会直接抛错。
|
|
625
|
+
// 写回时保持原样,别去动用户的编码习惯。
|
|
626
|
+
const bom = raw.charCodeAt(0) === 0xfeff;
|
|
627
|
+
const body = bom ? raw.slice(1) : raw;
|
|
628
|
+
|
|
629
|
+
if (body.trim() === '') {
|
|
630
|
+
return { exists: true, empty: true, bom, value: {}, error: null };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** @type {any} */
|
|
634
|
+
let value;
|
|
635
|
+
try {
|
|
636
|
+
value = JSON.parse(body);
|
|
637
|
+
} catch (e) {
|
|
638
|
+
return {
|
|
639
|
+
exists: true,
|
|
640
|
+
empty: false,
|
|
641
|
+
bom,
|
|
642
|
+
value: null,
|
|
643
|
+
error: `不是合法的 JSON:${/** @type {Error} */ (e).message}`,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
if (!isPlain(value)) {
|
|
647
|
+
return { exists: true, empty: false, bom, value: null, error: '顶层必须是一个 JSON 对象' };
|
|
648
|
+
}
|
|
649
|
+
return { exists: true, empty: false, bom, value, error: null };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* 原子写:先写同目录的临时文件,再 rename 过去。
|
|
654
|
+
*
|
|
655
|
+
* 和 `link.js` 建链接同一个标准——写坏 `.claude/settings.json` 等于用户的
|
|
656
|
+
* Claude 起不来,这里不该比建链接更草率。失败时原文件一个字节都没动过。
|
|
657
|
+
*
|
|
658
|
+
* 临时文件名里带 `process.pid`。**这就够了,不需要引入锁**:进程内不会并发
|
|
659
|
+
* 写同一个文件(sync 从头到尾是顺序执行的),而跨进程 pid 必不相同,于是各写
|
|
660
|
+
* 各的临时文件。用固定名字时,两个进程会互相踩:A 的收尾清理(写失败那条路径
|
|
661
|
+
* 上的 `rmSync`)会把 B 正在写的那个删掉,然后 B 的 rename 报一个跟真实原因
|
|
662
|
+
* 毫无关系的错。带 pid 之后最坏情况只剩「两个进程都 rename 成功、后一个赢」——
|
|
663
|
+
* 那是原子写本来就接受的结果,而且两份内容本来就是同一份。
|
|
664
|
+
*
|
|
665
|
+
* @param {string} abs @param {any} value @param {{bom?: boolean}} [opts]
|
|
666
|
+
*/
|
|
667
|
+
export function writeJsonFile(abs, value, opts = {}) {
|
|
668
|
+
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
669
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
670
|
+
|
|
671
|
+
const tmp = `${abs}.agent-sync.${process.pid}.tmp`;
|
|
672
|
+
try {
|
|
673
|
+
fs.writeFileSync(tmp, opts.bom ? `\ufeff${text}` : text);
|
|
674
|
+
fs.renameSync(tmp, abs);
|
|
675
|
+
} catch (e) {
|
|
676
|
+
try {
|
|
677
|
+
fs.rmSync(tmp, { force: true });
|
|
678
|
+
} catch {
|
|
679
|
+
// 临时文件都清不掉的话,报原来那个错更有用
|
|
680
|
+
}
|
|
681
|
+
throw e;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** 只读地读一批内容文件,逐个校验。返回 {desired, problems, warnings} */
|
|
686
|
+
function readSources({ tool, kind, sources }) {
|
|
687
|
+
/** @type {Record<string, any>} */
|
|
688
|
+
const desired = {};
|
|
689
|
+
/** @type {string[]} */
|
|
690
|
+
const problems = [];
|
|
691
|
+
/** @type {string[]} */
|
|
692
|
+
const warnings = [];
|
|
693
|
+
|
|
694
|
+
for (const [id, src] of Object.entries(sources)) {
|
|
695
|
+
const read = readJsonFile(src);
|
|
696
|
+
if (!read.exists) {
|
|
697
|
+
problems.push(`${kind}:${id} 的内容文件读不到(${src})`);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (read.error) {
|
|
701
|
+
problems.push(`${kind}:${id}:${read.error}`);
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const built =
|
|
706
|
+
kind === 'mcp' ? buildMcpEntry(read.value, tool) : buildHooksEntry(read.value, tool);
|
|
707
|
+
if ('error' in built) {
|
|
708
|
+
problems.push(`${kind}:${id}:${built.error}`);
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
for (const w of built.warnings ?? []) warnings.push(`${kind}:${id}:${w}`);
|
|
712
|
+
desired[id] = built.value;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return { desired, problems, warnings };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* 一个工具的一类内容:读内容文件 → 合并 → (必要时)写。
|
|
720
|
+
*
|
|
721
|
+
* @param {{
|
|
722
|
+
* projectRoot: string, tool: string, kind: string,
|
|
723
|
+
* sources: Record<string, string>, // id → 内容文件的绝对路径
|
|
724
|
+
* prev: Record<string, any>, // merged[tool][kind],上一轮我们写了什么
|
|
725
|
+
* keep?: string[], // protect 锁住的 id,整个不碰
|
|
726
|
+
* dryRun?: boolean,
|
|
727
|
+
* }} input
|
|
728
|
+
* @returns {{
|
|
729
|
+
* rel: string, verified: boolean, changed: boolean, created: boolean,
|
|
730
|
+
* recorded: Record<string, any>, problems: string[], conflicts: string[], warnings: string[],
|
|
731
|
+
* }}
|
|
732
|
+
*/
|
|
733
|
+
export function applyMerge({ projectRoot, tool, kind, sources, prev, keep = [], dryRun = false }) {
|
|
734
|
+
const target = mergeTarget(tool, kind);
|
|
735
|
+
if (!target) {
|
|
736
|
+
throw new Error(`${tool} 没有 ${kind} 的合并目标——调用方该先用 mergeTarget() 判断`);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const label = `${TOOLS[tool].label} · ${target.rel}`;
|
|
740
|
+
const abs = path.resolve(projectRoot, target.rel);
|
|
741
|
+
|
|
742
|
+
// 诊断一律在这里加「哪个工具、哪个文件」的前缀,**加一次**。
|
|
743
|
+
// 以前两处各加一遍,同一句话里会出现 `Claude Code(.mcp.json):Claude Code · .mcp.json …`。
|
|
744
|
+
/** @param {string} m */
|
|
745
|
+
const tag = (m) => `${label}:${m}`;
|
|
746
|
+
|
|
747
|
+
const { desired, problems: sourceProblems, warnings: sourceWarnings } = readSources({
|
|
748
|
+
tool,
|
|
749
|
+
kind,
|
|
750
|
+
sources,
|
|
751
|
+
});
|
|
752
|
+
const problems = sourceProblems.map(tag);
|
|
753
|
+
const warnings = sourceWarnings.map(tag);
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* 收工:把已经攒下的诊断带上,别让调用方以为一切正常。
|
|
757
|
+
*
|
|
758
|
+
* **`recorded` 的默认值是「上一轮那份」,不是空。** 走到这几条早退路径
|
|
759
|
+
* (目标是链接 / 是目录 / 读不了)时文件一个字节都没动过,而
|
|
760
|
+
* **「没动过」不等于「不是我们写的」**——抹掉归属是不可逆的:下一轮 `prev`
|
|
761
|
+
* 成了 `{}`,那几个 id 立刻落进「与记录之外的 server 重名」那一支,sync 会
|
|
762
|
+
* 劝用户「删掉那一份,或写进 protect」,而那份本来就是它自己写的。
|
|
763
|
+
*
|
|
764
|
+
* 触发方式简单到离谱(实测过):`.mcp.json` 里多一个尾逗号 → 读不了 →
|
|
765
|
+
* 归属当场消失,把文件修好也回不来。
|
|
766
|
+
*
|
|
767
|
+
* 同一条口径在 mergeMcp / mergeHooks 里也写着(读不懂现场时 `recorded: {...prev}`),
|
|
768
|
+
* 只有这里漏了。写失败那条路仍然显式传 `recorded`(那时确实是我们算出来的新归属)。
|
|
769
|
+
*/
|
|
770
|
+
const done = (extra = {}) => ({
|
|
771
|
+
rel: target.rel,
|
|
772
|
+
verified: target.verified,
|
|
773
|
+
changed: false,
|
|
774
|
+
created: false,
|
|
775
|
+
recorded: { ...prev },
|
|
776
|
+
problems,
|
|
777
|
+
conflicts: [],
|
|
778
|
+
warnings,
|
|
779
|
+
...extra,
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
// ---- 目标文件能写吗 ----
|
|
783
|
+
//
|
|
784
|
+
// 判「在不在」用 lstat(见 lexists),**不能用 `fs.existsSync`**:它跟链接目标,
|
|
785
|
+
// 断链返回 false,于是下面这道守卫被整条跳过——POSIX 上紧接着的 rename 会
|
|
786
|
+
// 直接把那个断链替换掉,用户指向 dotfiles 仓库的链接就没了;Windows 上则
|
|
787
|
+
// 报一句看不出所以然的 `写入失败:EPERM … rename`。
|
|
788
|
+
if (lexists(abs)) {
|
|
789
|
+
if (isSymlink(abs)) {
|
|
790
|
+
// `.claude/settings.json` 指向 dotfiles 仓库是合法且常见的配置。
|
|
791
|
+
// 我们只能改工作区里这一份,改不了别人仓库里的,所以拒绝。
|
|
792
|
+
problems.push(tag('是一个链接(指向别处),本工具不碰——请自行维护'));
|
|
793
|
+
return done();
|
|
794
|
+
}
|
|
795
|
+
if (fs.lstatSync(abs).isDirectory()) {
|
|
796
|
+
problems.push(tag('是个目录,不是文件'));
|
|
797
|
+
return done();
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const read = readJsonFile(abs);
|
|
802
|
+
if (read.error) {
|
|
803
|
+
// **一个字都不写。** 绝不能「解析失败 / 读不到就当空的然后覆盖写」。
|
|
804
|
+
problems.push(tag(`${read.error}——没有改动它`));
|
|
805
|
+
return done();
|
|
806
|
+
}
|
|
807
|
+
if (read.empty) warnings.push(tag('是空文件,按空对象处理'));
|
|
808
|
+
|
|
809
|
+
// ---- 合并 ----
|
|
810
|
+
const { next, recorded, conflicts, problems: planProblems } = planMerge({
|
|
811
|
+
kind,
|
|
812
|
+
current: read.value,
|
|
813
|
+
desired,
|
|
814
|
+
keep,
|
|
815
|
+
prev,
|
|
816
|
+
});
|
|
817
|
+
// 「结构不认识,没动它」是故障不是保护:本该写进去的没写进去,必须说出来
|
|
818
|
+
for (const p of planProblems) problems.push(tag(p));
|
|
819
|
+
|
|
820
|
+
// ---- 该不该写 ----
|
|
821
|
+
//
|
|
822
|
+
// 按**解析后的结构**比,不按文本比。否则用户的 4 空格缩进、工具重排键序
|
|
823
|
+
// 都会触发一次重写,那个文件在 git 里反复出现。第一次写完之后就再也不抖。
|
|
824
|
+
//
|
|
825
|
+
// 「没有东西要写就一个字都不写」也由这条兜住:`planMerge` 在没有内容可合时
|
|
826
|
+
// 原样返回,next 与读到的逐字段相同,changed 为假——用户的 `{"mcpServers":{}}`
|
|
827
|
+
// 不会被改成 `{}`,也不会平白多出一个空壳。
|
|
828
|
+
const changed = !deepEqualJSON(next, read.value);
|
|
829
|
+
|
|
830
|
+
if (changed && !dryRun) {
|
|
831
|
+
try {
|
|
832
|
+
writeJsonFile(abs, next, { bom: read.bom });
|
|
833
|
+
} catch (e) {
|
|
834
|
+
problems.push(tag(`写入失败:${/** @type {Error} */ (e).message}`));
|
|
835
|
+
return done({ recorded });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
return {
|
|
840
|
+
rel: target.rel,
|
|
841
|
+
verified: target.verified,
|
|
842
|
+
changed,
|
|
843
|
+
created: changed && !read.exists,
|
|
844
|
+
recorded,
|
|
845
|
+
problems,
|
|
846
|
+
conflicts,
|
|
847
|
+
warnings,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* 只读检查:现在盘上的结果和我们期望的是不是一致。
|
|
853
|
+
*
|
|
854
|
+
* 给 `status` / `doctor` 用,**不写盘**。和写入侧共用 `deepEqualJSON`,
|
|
855
|
+
* 保证「报漂移」和「会不会真写」是同一个判断——两处各写一套迟早对不上。
|
|
856
|
+
*
|
|
857
|
+
* @param {{
|
|
858
|
+
* projectRoot: string, tool: string, kind: string,
|
|
859
|
+
* sources: Record<string, string>, prev: Record<string, any>, keep?: string[],
|
|
860
|
+
* }} input
|
|
861
|
+
* @returns {{state: 'ok'|'drift'|'missing'|'unreadable'|'conflict', detail: string}}
|
|
862
|
+
*/
|
|
863
|
+
export function checkMerge({ projectRoot, tool, kind, sources, prev, keep = [] }) {
|
|
864
|
+
const target = mergeTarget(tool, kind);
|
|
865
|
+
if (!target) return { state: 'conflict', detail: '这个工具没有对应的合并目标' };
|
|
866
|
+
|
|
867
|
+
const abs = path.resolve(projectRoot, target.rel);
|
|
868
|
+
const hasWork = Object.keys(sources).length > 0 || Object.keys(prev).length > 0;
|
|
869
|
+
|
|
870
|
+
// 和写入侧同一道守卫:existsSync 对断链返回 false,会把「这是个链接」整个漏掉,
|
|
871
|
+
// 于是 status / doctor 打出「还没生成——跑一次 sync」,而 sync 去了会被拒绝。
|
|
872
|
+
if (!lexists(abs)) {
|
|
873
|
+
return hasWork
|
|
874
|
+
? { state: 'missing', detail: `${target.rel} 还不存在——跑一次 sync 生成` }
|
|
875
|
+
: { state: 'ok', detail: '' };
|
|
876
|
+
}
|
|
877
|
+
if (isSymlink(abs)) {
|
|
878
|
+
return { state: 'conflict', detail: `${target.rel} 是一个链接,本工具不碰` };
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const read = readJsonFile(abs);
|
|
882
|
+
if (read.error) return { state: 'unreadable', detail: `${target.rel} ${read.error}` };
|
|
883
|
+
|
|
884
|
+
const { desired, problems: sourceProblems } = readSources({ tool, kind, sources });
|
|
885
|
+
|
|
886
|
+
// **内容文件本身坏了**:它不在 `desired` 里,于是下一步会算出「漂移」,
|
|
887
|
+
// 报出去是「跑一次 sync 重新合并」——而 sync 去了只会再报一次同样的错,
|
|
888
|
+
// 用户被指去跑一条注定失败的命令。直接把真正的原因说出来。
|
|
889
|
+
if (sourceProblems.length > 0) {
|
|
890
|
+
return { state: 'unreadable', detail: sourceProblems.join(';') };
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const { next, conflicts, problems } = planMerge({ kind, current: read.value, desired, keep, prev });
|
|
894
|
+
|
|
895
|
+
// 结构不认识的报成「有本工具没动的地方」,而**不是 ok**:后者会在 status 里打出
|
|
896
|
+
// 一句「✅ 一致」,而实际上一个字都没合过——正是这个模块开头想避免的那句话。
|
|
897
|
+
if (problems.length > 0) return { state: 'conflict', detail: problems.join(';') };
|
|
898
|
+
if (conflicts.length > 0) return { state: 'conflict', detail: conflicts.join(';') };
|
|
899
|
+
if (!deepEqualJSON(next, read.value)) {
|
|
900
|
+
return { state: 'drift', detail: `${target.rel} 和 .agents/ 里的内容对不上` };
|
|
901
|
+
}
|
|
902
|
+
return { state: 'ok', detail: '' };
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// ---------------------------------------------------------------------------
|
|
906
|
+
// 按 [工具 × 类型] 展开
|
|
907
|
+
// ---------------------------------------------------------------------------
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* 把 `agents.json` 的 `protect` 折算成**合并层**的「整个不碰」名单。
|
|
911
|
+
*
|
|
912
|
+
* `protect` 是条目层的写法(`"hook:dept-hooks"`),合并层按目录分组(`hooks`),
|
|
913
|
+
* 中间要过一次 `ITEM_KINDS` 的映射。
|
|
914
|
+
*
|
|
915
|
+
* **这段原先在 sync / status / doctor 里各写了一遍**——三份一模一样的语义判断。
|
|
916
|
+
* 加一类合并内容时漏改任何一处,那一处的 `protect` 就会静默失效(用户以为锁上了,
|
|
917
|
+
* 实际照删),而这正是 `ITEM_KINDS` 那张表存在的理由。
|
|
918
|
+
*
|
|
919
|
+
* @param {string[]} [protect]
|
|
920
|
+
* @returns {Record<string, string[]>} 目录名 → 锁住的 id
|
|
921
|
+
*/
|
|
922
|
+
export function keepByProtect(protect = []) {
|
|
923
|
+
/** @type {Record<string, string[]>} */
|
|
924
|
+
const out = Object.fromEntries(MERGE_KINDS.map((d) => [d, /** @type {string[]} */ ([])]));
|
|
925
|
+
for (const key of protect) {
|
|
926
|
+
const colon = key.indexOf(':');
|
|
927
|
+
if (colon === -1) continue;
|
|
928
|
+
const dir = ITEM_KINDS[key.slice(0, colon)]?.dir;
|
|
929
|
+
if (dir && MERGE_KINDS.includes(dir)) out[dir].push(key.slice(colon + 1));
|
|
930
|
+
}
|
|
931
|
+
return out;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* 一次完整的合并:声明了的每个工具 × 每一类内容,各走一遍 `applyMerge`。
|
|
936
|
+
*
|
|
937
|
+
* `sourcesByKind` 由调用方给,因为「这一轮该装什么」只有 sync 知道:
|
|
938
|
+
* `--dry-run` 时 `applyInstall` 没执行,`.agents/` 里可能还是上一轮的旧内容,
|
|
939
|
+
* 从那儿读会让预演报「无变化」而真跑大改。
|
|
940
|
+
*
|
|
941
|
+
* ## 不传 `--prune` 时不摘
|
|
942
|
+
*
|
|
943
|
+
* 和内容层一个口径:本工具**默认只报告不删**。掉出选择的条目会被塞进 `keep`,
|
|
944
|
+
* 于是「整个不碰」——它们继续留在 `recorded` 里,下一轮还是我们说得出归属的。
|
|
945
|
+
* 加 `--prune` 才真摘。
|
|
946
|
+
*
|
|
947
|
+
* @param {{
|
|
948
|
+
* projectRoot: string, tools: string[],
|
|
949
|
+
* sourcesByKind: Record<string, Record<string, string>>, // 类型 → {id → 内容文件绝对路径}
|
|
950
|
+
* keepByKind?: Record<string, string[]>, // 类型 → protect 锁住的 id
|
|
951
|
+
* prevMerged?: Record<string, any>,
|
|
952
|
+
* dryRun?: boolean, prune?: boolean,
|
|
953
|
+
* }} input
|
|
954
|
+
*/
|
|
955
|
+
export function applyMergeAll({
|
|
956
|
+
projectRoot,
|
|
957
|
+
tools,
|
|
958
|
+
sourcesByKind,
|
|
959
|
+
keepByKind = {},
|
|
960
|
+
prevMerged = {},
|
|
961
|
+
dryRun = false,
|
|
962
|
+
prune = false,
|
|
963
|
+
}) {
|
|
964
|
+
/** @type {Record<string, any>} */
|
|
965
|
+
const merged = {};
|
|
966
|
+
/** @type {{tool: string, kind: string, rel: string, verified: boolean, changed: boolean, created: boolean, conflicts: string[]}[]} */
|
|
967
|
+
const applied = [];
|
|
968
|
+
/** @type {string[]} */
|
|
969
|
+
const problems = [];
|
|
970
|
+
/** @type {string[]} */
|
|
971
|
+
const warnings = [];
|
|
972
|
+
/** @type {{tool: string, kind: string, rel: string, messages: string[]}[]} */
|
|
973
|
+
const conflicts = [];
|
|
974
|
+
/** @type {{tool: string, kind: string, ids: string[]}[]} */
|
|
975
|
+
const stale = [];
|
|
976
|
+
/** @type {{tool: string, kind: string, count: number}[]} */
|
|
977
|
+
const unsupported = [];
|
|
978
|
+
|
|
979
|
+
// **也要遍历「已经不声明、但记录里还有」的工具。**
|
|
980
|
+
//
|
|
981
|
+
// 只遍历 `tools` 的话,某个工具从 `links` 里去掉之后,我们以前写进它配置
|
|
982
|
+
// 文件里的条目**没有任何代码路径会去清**——而那个文件是提交进版本库的。
|
|
983
|
+
// 这和 `findStaleLinks` 要解决的是同一类问题。
|
|
984
|
+
const allTools = [...new Set([...tools, ...Object.keys(prevMerged ?? {})])];
|
|
985
|
+
|
|
986
|
+
// 记录里可能有**本版本不认识**的工具名:同事用了更新的版本、手改过记录、
|
|
987
|
+
// 或者将来加了新工具。它们绝不能进下面那个循环——`TOOLS[tool].label` 会当场
|
|
988
|
+
// 抛 TypeError,而 sync 崩的位置正好是「内容已写、配置文件已建、记录还没写下去」,
|
|
989
|
+
// 下一轮我们自己的条目就会被判成「与记录之外的 server 重名」,永久不再更新。
|
|
990
|
+
//
|
|
991
|
+
// 所以过滤掉,并且**明确报出来**:跳过不等于没有这回事,那几条以后没人清理。
|
|
992
|
+
// 报成 warning 而不是 problem:内容本身没问题,本轮该做的都做完了,拿一个
|
|
993
|
+
// 退出码逼用户去改一个可能是同事写下的记录,不划算(sync 会把它打出来)。
|
|
994
|
+
const knownTools = allTools.filter(isTool);
|
|
995
|
+
const unknownTools = allTools.filter((t) => !isTool(t));
|
|
996
|
+
if (unknownTools.length > 0) {
|
|
997
|
+
warnings.push(
|
|
998
|
+
`记录里有 ${unknownTools.length} 个本版本不认识的名字:${unknownTools.join('、')}——已跳过,` +
|
|
999
|
+
'用其它版本的本工具写下的记录会这样;本轮不会去动它们的配置',
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
for (const tool of knownTools) {
|
|
1004
|
+
const label = TOOLS[tool].label;
|
|
1005
|
+
// 没被声明的工具:什么都不该再写进去,于是 desired 为空,我们以前写的全进待摘名单
|
|
1006
|
+
const declared = tools.includes(tool);
|
|
1007
|
+
|
|
1008
|
+
for (const kind of MERGE_KINDS) {
|
|
1009
|
+
const sources = declared ? (sourcesByKind[kind] ?? {}) : {};
|
|
1010
|
+
const keep = declared ? (keepByKind[kind] ?? []) : [];
|
|
1011
|
+
|
|
1012
|
+
// 该工具根本没有这一类内容的合并目标(codex 的 mcp)。
|
|
1013
|
+
// **必须报警**——删掉 status 里那句「合并器未实现」之后,这是唯一会说话的地方。
|
|
1014
|
+
if (!mergeTarget(tool, kind)) {
|
|
1015
|
+
const n = Object.keys(sources).length;
|
|
1016
|
+
if (n > 0) unsupported.push({ tool, kind, count: n });
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const prev = prevMerged?.[tool]?.[kind] ?? {};
|
|
1021
|
+
const notWanted = Object.keys(prev).filter((id) => !Object.hasOwn(sources, id) && !keep.includes(id));
|
|
1022
|
+
if (notWanted.length > 0) stale.push({ tool, kind, ids: notWanted });
|
|
1023
|
+
|
|
1024
|
+
const r = applyMerge({
|
|
1025
|
+
projectRoot,
|
|
1026
|
+
tool,
|
|
1027
|
+
kind,
|
|
1028
|
+
sources,
|
|
1029
|
+
prev,
|
|
1030
|
+
// 不加 --prune 时把它们也「锁住」,等于整个不碰,但报告里会列出来
|
|
1031
|
+
keep: prune ? keep : [...keep, ...notWanted],
|
|
1032
|
+
dryRun,
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
// 诊断在 applyMerge 里已经统一带了「工具 · 文件」前缀,这里**不再拼一遍**:
|
|
1036
|
+
// 两层前缀拼出来的 `Claude Code(.mcp.json):Claude Code · .mcp.json …`
|
|
1037
|
+
// 看着像两条不同的消息,实际是一条。
|
|
1038
|
+
for (const p of r.problems) problems.push(p);
|
|
1039
|
+
for (const w of r.warnings) warnings.push(w);
|
|
1040
|
+
|
|
1041
|
+
const recorded = Object.keys(r.recorded).length > 0 ? r.recorded : null;
|
|
1042
|
+
if (recorded) merged[tool] = { ...(merged[tool] ?? {}), [kind]: recorded };
|
|
1043
|
+
|
|
1044
|
+
if (r.conflicts.length > 0) conflicts.push({ tool, kind, rel: r.rel, messages: r.conflicts });
|
|
1045
|
+
if (r.changed || r.created) {
|
|
1046
|
+
applied.push({
|
|
1047
|
+
tool,
|
|
1048
|
+
kind,
|
|
1049
|
+
rel: r.rel,
|
|
1050
|
+
verified: r.verified,
|
|
1051
|
+
changed: r.changed,
|
|
1052
|
+
created: r.created,
|
|
1053
|
+
conflicts: r.conflicts,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
return { merged, applied, problems, warnings, conflicts, stale, unsupported };
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* `.agents/<dir>/` 下有哪些 `*.json`(**只看目录,不看记录**)。
|
|
1064
|
+
*
|
|
1065
|
+
* 用它回答「有没有东西需要合并」;用 `sourcesFromDisk` 回答「该合并哪些」。
|
|
1066
|
+
* 两件事分开:混成一个的话,还没写过记录的新项目就不会被告知
|
|
1067
|
+
* 「声明的工具里没有一个能承接这类内容」。
|
|
1068
|
+
*
|
|
1069
|
+
* **「没有这个目录」和「读不了这个目录」是两件事**,和 `readJsonFile` 一个口径:
|
|
1070
|
+
* 前者返回 `[]`,后者返回 `null`。把权限 / IO 错误吞成 `[]` 的话,`doctor` 会
|
|
1071
|
+
* 打出一句「没有 hooks / mcp 内容,不需要合并」、`status` 整节跳过——目录读不
|
|
1072
|
+
* 出来的事就一声不吭了,而那正是「看起来没事、实际上没人在管」的形状。
|
|
1073
|
+
*
|
|
1074
|
+
* @param {string} projectRoot @param {string} dir
|
|
1075
|
+
* @returns {string[]|null} null 表示「有东西但读不了」
|
|
1076
|
+
*/
|
|
1077
|
+
export function presentIn(projectRoot, dir) {
|
|
1078
|
+
/** @type {string[]} */
|
|
1079
|
+
let names;
|
|
1080
|
+
try {
|
|
1081
|
+
names = fs.readdirSync(path.resolve(projectRoot, CONTENT_ROOT, dir));
|
|
1082
|
+
} catch (e) {
|
|
1083
|
+
if (isMissingPath(e)) return [];
|
|
1084
|
+
return null;
|
|
1085
|
+
}
|
|
1086
|
+
return names.filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -'.json'.length));
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* 从 `.agents/<dir>/` 里挑出**记录说装过的**内容文件,给只读检查当「期望内容」。
|
|
1091
|
+
*
|
|
1092
|
+
* 为什么只挑记录里的:`sync` 也只合并本次选中的那些。把用户自己丢进
|
|
1093
|
+
* `.agents/mcp/` 的 server 也算进来的话,`status` 会报一堆「漂移」——
|
|
1094
|
+
* 而 `sync` 根本不会去动它们,那是虚惊。
|
|
1095
|
+
*
|
|
1096
|
+
* 只读命令(status / doctor)读不到内容仓库,所以「期望内容」只能取
|
|
1097
|
+
* `.agents/` 里现成的——和链接那一节同一个边界。
|
|
1098
|
+
*
|
|
1099
|
+
* @param {string} projectRoot @param {{usable: boolean, installed: string[]}} record @param {string} dir
|
|
1100
|
+
* @returns {Record<string, string>}
|
|
1101
|
+
*/
|
|
1102
|
+
export function sourcesFromDisk(projectRoot, record, dir) {
|
|
1103
|
+
/** @type {Record<string, string>} */
|
|
1104
|
+
const out = {};
|
|
1105
|
+
if (!record.usable) return out;
|
|
1106
|
+
|
|
1107
|
+
for (const key of record.installed) {
|
|
1108
|
+
let parsed;
|
|
1109
|
+
try {
|
|
1110
|
+
parsed = parseItem(key);
|
|
1111
|
+
} catch {
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
if (ITEM_KINDS[parsed.kind]?.dir !== dir) continue;
|
|
1115
|
+
const abs = path.resolve(projectRoot, CONTENT_ROOT, dir, `${parsed.id}.json`);
|
|
1116
|
+
if (fs.existsSync(abs)) out[parsed.id] = abs;
|
|
1117
|
+
}
|
|
1118
|
+
return out;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* `applyMergeAll` 的只读版,供 `status` / `doctor` 用。
|
|
1123
|
+
*
|
|
1124
|
+
* 和写入侧共用 `checkMerge`,所以「报漂移」和「会不会真写」是同一个判断——
|
|
1125
|
+
* 两处各写一套迟早会对不上。
|
|
1126
|
+
*
|
|
1127
|
+
* ## `keep` 必须和写入侧算得一模一样
|
|
1128
|
+
*
|
|
1129
|
+
* 写入侧不加 `--prune` 时会把「这一轮不想摘的」也塞进 `keep`(等于整个不碰)。
|
|
1130
|
+
* 只读侧少了这一步的话,两边对同一份现场会给出相反的结论:`status` 报 `drift`、
|
|
1131
|
+
* 建议「跑一次 sync」,而 `sync` 故意不写——用户卡在退出码 1,**没有任何命令
|
|
1132
|
+
* 能修好**(实测过:掉出选择 + 手删 `.agents/mcp/srv.json` 之后,`status` 三轮
|
|
1133
|
+
* 都报问题,`sync` 三轮都说无事可做)。所以这里照着 `applyMergeAll` 再算一遍,
|
|
1134
|
+
* 包括 `prune` 这个开关。
|
|
1135
|
+
*
|
|
1136
|
+
* @param {{
|
|
1137
|
+
* projectRoot: string, tools: string[],
|
|
1138
|
+
* sourcesByKind: Record<string, Record<string, string>>,
|
|
1139
|
+
* keepByKind?: Record<string, string[]>, prevMerged?: Record<string, any>,
|
|
1140
|
+
* prune?: boolean,
|
|
1141
|
+
* }} input
|
|
1142
|
+
* @returns {{tool: string, kind: string, rel: string, state: string, detail: string, ids: string[]}[]}
|
|
1143
|
+
*/
|
|
1144
|
+
export function checkMergeAll({
|
|
1145
|
+
projectRoot,
|
|
1146
|
+
tools,
|
|
1147
|
+
sourcesByKind,
|
|
1148
|
+
keepByKind = {},
|
|
1149
|
+
prevMerged = {},
|
|
1150
|
+
prune = false,
|
|
1151
|
+
}) {
|
|
1152
|
+
/** @type {{tool: string, kind: string, rel: string, state: string, detail: string}[]} */
|
|
1153
|
+
const out = [];
|
|
1154
|
+
|
|
1155
|
+
for (const tool of tools) {
|
|
1156
|
+
for (const kind of MERGE_KINDS) {
|
|
1157
|
+
const target = mergeTarget(tool, kind);
|
|
1158
|
+
if (!target) continue; // 不支持由调用方单独报,这里只报「支持但状态不对」
|
|
1159
|
+
|
|
1160
|
+
const sources = sourcesByKind[kind] ?? {};
|
|
1161
|
+
const keep = keepByKind[kind] ?? [];
|
|
1162
|
+
const prev = prevMerged?.[tool]?.[kind] ?? {};
|
|
1163
|
+
|
|
1164
|
+
// **没有东西该合就跳过,判据和目标文件在不在无关。**
|
|
1165
|
+
// 用「文件在不在」当判据的话,用户自己往 `.agents/mcp/` 丢一个不在记录里的
|
|
1166
|
+
// 文件、自己写一份 `.mcp.json`,就会得到一句「✅ Claude Code · .mcp.json 一致」
|
|
1167
|
+
// ——而压根没有任何东西被合过。那正是这个模块开头想避免的话。
|
|
1168
|
+
const hasWork = Object.keys(sources).length > 0 || Object.keys(prev).length > 0;
|
|
1169
|
+
if (!hasWork) continue;
|
|
1170
|
+
|
|
1171
|
+
const notWanted = Object.keys(prev).filter((id) => !Object.hasOwn(sources, id) && !keep.includes(id));
|
|
1172
|
+
|
|
1173
|
+
const r = checkMerge({
|
|
1174
|
+
projectRoot,
|
|
1175
|
+
tool,
|
|
1176
|
+
kind,
|
|
1177
|
+
sources,
|
|
1178
|
+
prev,
|
|
1179
|
+
// 和 applyMergeAll 同一行算法,别在这里另写一套
|
|
1180
|
+
keep: prune ? keep : [...keep, ...notWanted],
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
// 「有不再需要的、但默认不会摘」要单独说,**不能混进 `drift`**:
|
|
1184
|
+
// `drift` 在 status 里会把人指去「跑一次 sync」,而 sync 默认压根不会动它
|
|
1185
|
+
// ——那正是上面那段注释要躲的死循环。用 `stale`,让人看到该加 `--prune`。
|
|
1186
|
+
const state = r.state === 'ok' && !prune && notWanted.length > 0 ? 'stale' : r.state;
|
|
1187
|
+
out.push({ tool, kind, rel: target.rel, state, detail: r.detail, ids: notWanted });
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
return out;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* `status` / `doctor` 共用的「合并产物」只读检查。
|
|
1196
|
+
*
|
|
1197
|
+
* 两个命令原先各写一遍(各三十多行),只有文案不同:读记录 → `presentIn` →
|
|
1198
|
+
* 按 `protect` 折算 keep → `checkMergeAll` → 没有合并目标的工具 → 未实证的路径。
|
|
1199
|
+
* 文案各写各的没问题,**判断必须是同一份**——两处各写一套的下场,就是
|
|
1200
|
+
* `checkMergeAll` 注释里记的那种「status 报 drift、sync 故意不写,用户卡在
|
|
1201
|
+
* 退出码 1 没有任何命令能修好」。
|
|
1202
|
+
*
|
|
1203
|
+
* @param {{
|
|
1204
|
+
* projectRoot: string,
|
|
1205
|
+
* config: {tools: string[], protect: string[]},
|
|
1206
|
+
* record: {usable: boolean, installed: string[], merged?: Record<string, any>},
|
|
1207
|
+
* }} input
|
|
1208
|
+
*/
|
|
1209
|
+
export function checkMergeView({ projectRoot, config, record }) {
|
|
1210
|
+
const merged = record?.merged ?? {};
|
|
1211
|
+
const present = Object.fromEntries(MERGE_KINDS.map((d) => [d, presentIn(projectRoot, d)]));
|
|
1212
|
+
const presentCount = MERGE_KINDS.reduce((n, d) => n + (present[d]?.length ?? 0), 0);
|
|
1213
|
+
// 「读不了」和「是空的」是两件事:前者我们根本不知道里面有什么,
|
|
1214
|
+
// 也就答不了「该合的合了没有」——调用方要各自说一句,别当成空
|
|
1215
|
+
const unreadable = MERGE_KINDS.filter((d) => present[d] === null);
|
|
1216
|
+
|
|
1217
|
+
/** 该工具根本没有这一类内容的合并目标(codex 的 mcp)——删掉 status 里那句
|
|
1218
|
+
* 「合并器未实现」之后,这是唯一还会为「装了不生效」说话的地方 */
|
|
1219
|
+
const unsupported = [];
|
|
1220
|
+
for (const tool of config.tools) {
|
|
1221
|
+
for (const dir of MERGE_KINDS) {
|
|
1222
|
+
if (mergeTarget(tool, dir)) continue;
|
|
1223
|
+
const count = present[dir]?.length ?? 0;
|
|
1224
|
+
if (count > 0) unsupported.push({ tool, dir, count });
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/** 没实证的路径(目前只有 Trae)——不能报得跟 Claude 那条一样肯定 */
|
|
1229
|
+
const unverified = [];
|
|
1230
|
+
for (const tool of config.tools) {
|
|
1231
|
+
for (const dir of mergeKindsOf(tool)) {
|
|
1232
|
+
const t = mergeTarget(tool, dir);
|
|
1233
|
+
if (t && !t.verified) unverified.push({ tool, rel: t.rel });
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
return {
|
|
1238
|
+
present,
|
|
1239
|
+
presentCount,
|
|
1240
|
+
unreadable,
|
|
1241
|
+
/** 这一节有没有值得显示的东西——空项目不该因为「文件不存在」吃一笔警告 */
|
|
1242
|
+
hasWork: presentCount > 0 || unreadable.length > 0 || Object.keys(merged).length > 0,
|
|
1243
|
+
rows: checkMergeAll({
|
|
1244
|
+
projectRoot,
|
|
1245
|
+
tools: config.tools,
|
|
1246
|
+
sourcesByKind: Object.fromEntries(
|
|
1247
|
+
MERGE_KINDS.map((d) => [d, sourcesFromDisk(projectRoot, record, d)]),
|
|
1248
|
+
),
|
|
1249
|
+
keepByKind: keepByProtect(config.protect),
|
|
1250
|
+
prevMerged: merged,
|
|
1251
|
+
}),
|
|
1252
|
+
unsupported,
|
|
1253
|
+
unverified,
|
|
1254
|
+
};
|
|
1255
|
+
}
|