agent-syncer 0.1.1 → 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/README.md +30 -3
- package/bin/agent-sync.js +43 -3
- package/lib/commands/doctor.js +57 -71
- package/lib/commands/link.js +16 -1
- package/lib/commands/status.js +91 -85
- package/lib/commands/sync.js +53 -31
- package/lib/config.js +8 -14
- package/lib/gitignore.js +17 -6
- package/lib/install.js +43 -22
- package/lib/log.js +11 -0
- package/lib/manifest.js +22 -0
- package/lib/merge.js +151 -6
- package/lib/record.js +87 -7
- package/lib/source.js +93 -21
- package/package.json +1 -1
package/lib/record.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { projectItemPath } from './
|
|
5
|
-
|
|
4
|
+
import { SUPPORT_DIR, parseItem, projectItemPath } from './manifest.js';
|
|
5
|
+
// BOM 处理只有一份实现(见 merge.js 里 stripBom 的注释)。这个文件的依赖方向
|
|
6
|
+
// 又一次「逆」——记「装过什么」的模块去借处理 JSON 的工具。可以接受:真实
|
|
7
|
+
// 依赖是「JSON 文本怎么读」这件小事,而这份知识散成好几份正是踩过的坑。
|
|
8
|
+
import { stripBom } from './merge.js';
|
|
6
9
|
import { CONTENT_ROOT } from './target.js';
|
|
7
10
|
|
|
8
11
|
/**
|
|
@@ -164,6 +167,11 @@ function mergedMap(value) {
|
|
|
164
167
|
* 读记录。文件不存在、坏了、版本不认识,一律当作「没有记录」——
|
|
165
168
|
* 那是保守方向:没有记录时什么都不会删。
|
|
166
169
|
*
|
|
170
|
+
* ⚠️ 「当作没有记录」对**删除**是保守的,对**归属**却是破坏性的:sync 拿到
|
|
171
|
+
* 一份 unusable 的记录,紧接着就会用一份全新的把它覆盖掉,`merged` 里记的归属
|
|
172
|
+
* 全丢(现场还在文件里,但那之后本工具认不出它是自己写的了)。所以调用方
|
|
173
|
+
* 读到 `reason` 时应当先 `backupUnreadableRecord` 留个档再重建。
|
|
174
|
+
*
|
|
167
175
|
* @param {string} projectRoot
|
|
168
176
|
* @returns {{usable: boolean, reason: string|null, installed: string[], scripts: string[], merged: Record<string, any>}}
|
|
169
177
|
*/
|
|
@@ -177,7 +185,10 @@ export function readRecord(projectRoot) {
|
|
|
177
185
|
/** @type {any} */
|
|
178
186
|
let raw;
|
|
179
187
|
try {
|
|
180
|
-
|
|
188
|
+
// 先去 BOM 再解析:Windows 上记事本、PowerShell 的 `>` 都会写带 BOM 的 UTF-8,
|
|
189
|
+
// 而 JSON.parse 见 BOM 直接抛——一份完全正常的记录会因此被判成「不可用」,
|
|
190
|
+
// 后果是清理静默失效 + 合并归属全丢。config.js 早就防着这一手,这里原先漏了。
|
|
191
|
+
raw = JSON.parse(stripBom(fs.readFileSync(p, 'utf8')));
|
|
181
192
|
} catch (e) {
|
|
182
193
|
return {
|
|
183
194
|
...empty,
|
|
@@ -228,8 +239,17 @@ export function planRemovals(projectRoot, record, wanted) {
|
|
|
228
239
|
|
|
229
240
|
for (const key of record.installed) {
|
|
230
241
|
if (wanted.items.includes(key) || protectedKeys.has(key)) continue;
|
|
231
|
-
|
|
232
|
-
|
|
242
|
+
// 名字都解析不了的,映射不到任何路径,谈不上删——跳过。
|
|
243
|
+
// **绝不能让它把整条命令崩掉**:记录是手改得坏的东西,`strList` 那边已经
|
|
244
|
+
// 定过口径「手改坏了的记录不该让整件事崩掉」,这里原先漏了——手写一条
|
|
245
|
+
// "oops" 就能让 status 直接抛异常。跳过的方向也是保守的:不删。
|
|
246
|
+
let parsed;
|
|
247
|
+
try {
|
|
248
|
+
parsed = parseItem(key);
|
|
249
|
+
} catch {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const abs = projectItemPath(projectRoot, parsed.kind, parsed.id);
|
|
233
253
|
if (fs.existsSync(abs)) out.push({ key, abs });
|
|
234
254
|
}
|
|
235
255
|
|
|
@@ -371,10 +391,70 @@ export function checkRecord(projectRoot, record) {
|
|
|
371
391
|
const check = (key, abs) => out.push({ key, abs, exists: fs.existsSync(abs) });
|
|
372
392
|
|
|
373
393
|
for (const key of record.installed) {
|
|
374
|
-
|
|
375
|
-
|
|
394
|
+
// 同 planRemovals:名字解析不了就跳过,不能为一条手改坏的名字把 status 打崩
|
|
395
|
+
// (调用方拿 unparsableKeys 单独把这件事说出来)
|
|
396
|
+
let parsed;
|
|
397
|
+
try {
|
|
398
|
+
parsed = parseItem(key);
|
|
399
|
+
} catch {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
check(key, projectItemPath(projectRoot, parsed.kind, parsed.id));
|
|
376
403
|
}
|
|
377
404
|
for (const rel of record.scripts) check(`script:${rel}`, scriptPath(projectRoot, rel));
|
|
378
405
|
|
|
379
406
|
return out;
|
|
380
407
|
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* 记录里**名字都解析不了**的条目(`installed` 里那些)。
|
|
411
|
+
*
|
|
412
|
+
* 它们映射不到任何路径,所以处置只能是「跳过」——但跳过不等于没有这回事:
|
|
413
|
+
* 清理功能会因此少管几项,而用户只看到「跟踪 N 项」莫名其妙变少了。
|
|
414
|
+
* 让调用方说一句,比默默少几条强。
|
|
415
|
+
*
|
|
416
|
+
* `scripts` 不在这里:它们记的是相对路径,本来就没有 `类型:名字` 的格式。
|
|
417
|
+
*
|
|
418
|
+
* @param {{installed: string[]}} record
|
|
419
|
+
* @returns {string[]}
|
|
420
|
+
*/
|
|
421
|
+
export function unparsableKeys(record) {
|
|
422
|
+
/** @type {string[]} */
|
|
423
|
+
const out = [];
|
|
424
|
+
for (const key of record.installed) {
|
|
425
|
+
try {
|
|
426
|
+
parseItem(key);
|
|
427
|
+
} catch {
|
|
428
|
+
out.push(key);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return out;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* 记录读不出来时先留个档,**别让新记录把它直接盖掉**。
|
|
436
|
+
*
|
|
437
|
+
* `writeRecord` 是整份重写的。读到一份解析不了的记录(坏 JSON / BOM / 本版本不
|
|
438
|
+
* 认识的 schemaVersion)就往下写的话,盘上那份就永久没了——而它里面记的是
|
|
439
|
+
* 「哪些内容是本工具装的」,**没有第二处能推导出来**(见文件开头那段)。
|
|
440
|
+
* 判据和 config.js 里那条一样:读失败装作不存在、然后接着写,是**进攻方向**。
|
|
441
|
+
*
|
|
442
|
+
* 只在还没有 `.bak` 时做一次:反复跑 sync 不该把最初那份越冲越远。
|
|
443
|
+
* 备份放在记录旁边(`.agents/` 下),托管段只放行 `.agent-sync.json` 这一个
|
|
444
|
+
* 文件名,所以 `.bak` 不会被提交进版本库。
|
|
445
|
+
*
|
|
446
|
+
* @param {string} projectRoot
|
|
447
|
+
* @returns {string|null} 备份文件的相对路径;没做(没有 / 已存在 / 失败)返回 null
|
|
448
|
+
*/
|
|
449
|
+
export function backupUnreadableRecord(projectRoot) {
|
|
450
|
+
const p = recordPath(projectRoot);
|
|
451
|
+
const bak = `${p}.bak`;
|
|
452
|
+
if (!fs.existsSync(p) || fs.existsSync(bak)) return null;
|
|
453
|
+
try {
|
|
454
|
+
fs.copyFileSync(p, bak);
|
|
455
|
+
return `${RECORD_REL}.bak`;
|
|
456
|
+
} catch {
|
|
457
|
+
// 备份不成也不能让 sync 整个失败——下面照常写新记录,只是没留档
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
}
|
package/lib/source.js
CHANGED
|
@@ -35,6 +35,79 @@ export function hasGit() {
|
|
|
35
35
|
return res.status === 0;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* `git clone` 的超时(毫秒)。
|
|
40
|
+
*
|
|
41
|
+
* `GIT_TERMINAL_PROMPT=0` 堵的是「停下来等人输密码」,堵不住**网络黑洞**——
|
|
42
|
+
* 连得上、但不回话(代理 / VPN 不通、对面的 git server 挂着、被墙的 host)。
|
|
43
|
+
* 那种情况下 clone 会一直等下去,在 CI 或 postinstall 里就是永久挂起。
|
|
44
|
+
*
|
|
45
|
+
* 比 `ls-remote` 那个 15 秒宽松得多:那一步是「问了才用」的下拉列表,早点放弃没损失;
|
|
46
|
+
* 这一步**失败就是整条 sync 失败**,而内容仓库可能不小、浅克隆也不一定快。
|
|
47
|
+
* 60 秒远大于实测(内容仓库不到 1 秒),等于「只有真的连不上才会触发」。
|
|
48
|
+
*
|
|
49
|
+
* 超时之后**不重试、也不退回上次的内容**:后者要把缓存引回来,而缓存正是
|
|
50
|
+
* `fetchRepo` 刻意不要的东西。直接报错,让用户知道是网络而不是他的 URL 写错了。
|
|
51
|
+
*/
|
|
52
|
+
const CLONE_TIMEOUT_MS = 60_000;
|
|
53
|
+
|
|
54
|
+
/** 这个 spawnSync 结果是不是「超时被杀」。超时时 `status` 是 null、`error.code` 是 ETIMEDOUT */
|
|
55
|
+
function isTimeout(res) {
|
|
56
|
+
return /** @type {any} */ (res.error)?.code === 'ETIMEDOUT';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 第一次 clone 失败之后,要不要退回**完整克隆**再试一次。
|
|
61
|
+
*
|
|
62
|
+
* 退回的理由只有一个:`--branch` 不认裸 SHA,那一次失败得快且无辜,换完整克隆是正解。
|
|
63
|
+
* 所以**超时不在此列**——超时说明网络已经不行了,而完整克隆(没有 `--depth 1`)
|
|
64
|
+
* 比刚才那次更慢,再发起一次等于把一次黑洞拖成两次。
|
|
65
|
+
*
|
|
66
|
+
* 判据单独拿出来,是因为它值得被钉死,而端到端**造不出**这个场景:要造「连得上、
|
|
67
|
+
* 不回话」,得有一个只接受连接、永不回话的本地 server,可本机实测下来 git 对
|
|
68
|
+
* loopback 的连接根本不完成(对**正在监听**的 server 也一样),代理 / 防火墙各家
|
|
69
|
+
* 又不一样——那种测试会变成环境的函数。
|
|
70
|
+
*
|
|
71
|
+
* @param {any} res @param {string|null|undefined} ref
|
|
72
|
+
*/
|
|
73
|
+
export function shouldRetryFullClone(res, ref) {
|
|
74
|
+
if (!ref) return false; // 没有 ref 就没有「--branch 不认 SHA」这回事
|
|
75
|
+
if (res.error) return false; // 超时、被信号杀掉、git 起不来——都不是 ref 的锅
|
|
76
|
+
return res.status !== 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 按 ref 取一份克隆;必要时退回完整克隆再检出。
|
|
81
|
+
*
|
|
82
|
+
* 把执行方式 (`run`) 从参数传进来,**只为能测**:这段的分支(`--branch` 不认裸 SHA、
|
|
83
|
+
* 超时不重试)只有「克隆真的失败了」才走得到,而真造一次失败要么靠网络、要么靠一个
|
|
84
|
+
* 挂住的假 git,两头都不稳。第一版就是因为它内联在 `fetchRepo` 里测不着,于是写了条
|
|
85
|
+
* 「耗时小于 1 秒」的假测试——**还原掉整个防重试逻辑,它照样是绿的**。
|
|
86
|
+
*
|
|
87
|
+
* 导出也是为了测。`fetchRepo` 用的是同一个函数,不是复制一份。
|
|
88
|
+
*
|
|
89
|
+
* @param {{
|
|
90
|
+
* run: (args: string[], timeoutMs?: number) => any,
|
|
91
|
+
* url: string, dest: string, ref?: string|null, timeoutMs: number,
|
|
92
|
+
* }} p
|
|
93
|
+
* @returns {any} 最后一次 `run` 的结果
|
|
94
|
+
*/
|
|
95
|
+
export function cloneWithFallback({ run, url, dest, ref, timeoutMs }) {
|
|
96
|
+
/** @type {string[]} */
|
|
97
|
+
const args = ['clone', '--quiet', '--depth', '1'];
|
|
98
|
+
if (ref) args.push('--branch', ref);
|
|
99
|
+
args.push('--', url, dest);
|
|
100
|
+
|
|
101
|
+
const first = run(args, timeoutMs);
|
|
102
|
+
if (!shouldRetryFullClone(first, ref)) return first;
|
|
103
|
+
|
|
104
|
+
// `--branch` 只认分支名和标签名,不认裸 SHA。这时退回完整克隆再检出。
|
|
105
|
+
// 失败时可能留下半个目标目录,重试前必须先清掉,否则克隆会因「目录非空」再失败一次。
|
|
106
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
107
|
+
const full = run(['clone', '--quiet', '--', url, dest], timeoutMs);
|
|
108
|
+
return full.status === 0 ? run(['-C', dest, 'checkout', '--quiet', '--detach', ref]) : full;
|
|
109
|
+
}
|
|
110
|
+
|
|
38
111
|
/**
|
|
39
112
|
* 把一个 git 仓库的某个 ref 取到临时目录。
|
|
40
113
|
*
|
|
@@ -44,10 +117,11 @@ export function hasGit() {
|
|
|
44
117
|
* **`GIT_TERMINAL_PROMPT=0` 是必须的**:否则遇到需要凭据的仓库时 git 会停下来等输入,
|
|
45
118
|
* 在 CI 或 postinstall 里就是永久挂起——和交互式提示一样危险。
|
|
46
119
|
*
|
|
47
|
-
* @param {{url: string, ref?: string|null}} opts
|
|
120
|
+
* @param {{url: string, ref?: string|null, timeoutMs?: number}} opts
|
|
121
|
+
* `timeoutMs` 只为测试而开:正常调用别传,用默认值(见 `CLONE_TIMEOUT_MS`)。
|
|
48
122
|
* @returns {{root: string, cleanup: () => void}}
|
|
49
123
|
*/
|
|
50
|
-
export function fetchRepo({ url, ref }) {
|
|
124
|
+
export function fetchRepo({ url, ref, timeoutMs = CLONE_TIMEOUT_MS }) {
|
|
51
125
|
if (!hasGit()) {
|
|
52
126
|
throw new Error('找不到 git 命令。请先安装 git,或把 content 指向一个本地已克隆的目录。');
|
|
53
127
|
}
|
|
@@ -62,35 +136,33 @@ export function fetchRepo({ url, ref }) {
|
|
|
62
136
|
GIT_ASKPASS: 'echo',
|
|
63
137
|
};
|
|
64
138
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
139
|
+
/**
|
|
140
|
+
* @param {string[]} args @param {number} [timeoutMs]
|
|
141
|
+
* `timeoutMs` 只在 clone 那条路上给。`checkout` 是纯本地操作,套上超时没有意义。
|
|
142
|
+
*/
|
|
143
|
+
const run = (args, timeoutMs) =>
|
|
67
144
|
spawnSync('git', args, {
|
|
68
145
|
encoding: 'utf8',
|
|
69
146
|
env: baseEnv,
|
|
70
147
|
// 不用 inherit:clone 的进度输出对使用者没有价值,失败时我们再完整打印 stderr
|
|
71
148
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
149
|
+
...(timeoutMs ? { timeout: timeoutMs } : {}),
|
|
72
150
|
});
|
|
73
151
|
|
|
74
|
-
|
|
75
|
-
const cloneArgs = ['clone', '--quiet', '--depth', '1'];
|
|
76
|
-
if (ref) cloneArgs.push('--branch', ref);
|
|
77
|
-
cloneArgs.push('--', url, dest);
|
|
78
|
-
|
|
79
|
-
let res = run(cloneArgs);
|
|
80
|
-
|
|
81
|
-
// `--branch` 只认分支名和标签名,不认裸 SHA。这时退回完整克隆再检出。
|
|
82
|
-
// 失败时可能留下半个目标目录,重试前必须先清掉,否则克隆会因「目录非空」再失败一次。
|
|
83
|
-
if (res.status !== 0 && ref) {
|
|
84
|
-
fs.rmSync(dest, { recursive: true, force: true });
|
|
85
|
-
const full = run(['clone', '--quiet', '--', url, dest]);
|
|
86
|
-
res =
|
|
87
|
-
full.status === 0
|
|
88
|
-
? run(['-C', dest, 'checkout', '--quiet', '--detach', ref])
|
|
89
|
-
: full;
|
|
90
|
-
}
|
|
152
|
+
const res = cloneWithFallback({ run, url, dest, ref, timeoutMs });
|
|
91
153
|
|
|
92
154
|
if (res.status !== 0) {
|
|
93
155
|
cleanup();
|
|
156
|
+
// 超时说成「拉取失败」,用户第一反应是去查自己的 URL 写错了没有。
|
|
157
|
+
// 这是两回事:地址是对的,是网络没回话。
|
|
158
|
+
if (isTimeout(res)) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`拉取超时:${url}${ref ? ` @ ${ref}` : ''}\n` +
|
|
161
|
+
` git 连上了,但 ${Math.round(timeoutMs / 1000)} 秒没有回话——` +
|
|
162
|
+
'多半是代理 / VPN 不通,或者对面的 git server 挂了。\n' +
|
|
163
|
+
' 也可以把 content 指向一个本地已克隆的目录:不联网、更快,改完内容立刻能试。',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
94
166
|
const detail = (res.stderr || res.stdout || '').trim();
|
|
95
167
|
throw new Error(
|
|
96
168
|
`拉取失败:${url}${ref ? ` @ ${ref}` : ''}\n` +
|