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/install.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { isSymlink } from './link.js';
|
|
6
|
+
import { ITEM_KINDS, SUPPORT_DIR, itemPath, parseItem, projectItemPath } from './manifest.js';
|
|
7
|
+
import { CONTENT_ROOT } from './target.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 目录树的稳定指纹:所有文件的「相对路径 + 内容哈希」,按路径排序。
|
|
11
|
+
* 单文件则直接返回内容哈希。
|
|
12
|
+
*
|
|
13
|
+
* 用它而不是 mtime 来判断「变了没有」——内容仓库重新克隆后 mtime 全变,
|
|
14
|
+
* 但内容其实一模一样,那时不该报「更新」。
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ **按原始字节算,所以换行符差异(CRLF / LF)会被算成「变了」。**
|
|
17
|
+
* 检出成 CRLF 的那份和内容仓库里的 LF 对不上,sync 会多报一次「更新」并把文件
|
|
18
|
+
* 重写回 LF,之后自愈(实测:第二遍就报「已是最新」)。
|
|
19
|
+
*
|
|
20
|
+
* 会不会污染 git,取决于**项目仓库自己的配置**,与本工具无关:
|
|
21
|
+
* - `core.autocrlf=true` → `git status` 会短暂显示 M,但 `git add` 不产生内容差异
|
|
22
|
+
* - `core.autocrlf=false` → 行尾改动作会被当成真改动提交进去
|
|
23
|
+
*
|
|
24
|
+
* 根治办法是项目里放一份 `.gitattributes`(`* text=auto`),让行尾行为不再取决于
|
|
25
|
+
* 每个人各自的 core.autocrlf。内容仓库那边已经有这个要求,项目仓库这边同样需要。
|
|
26
|
+
*
|
|
27
|
+
* @param {string} p
|
|
28
|
+
*/
|
|
29
|
+
function fingerprint(p) {
|
|
30
|
+
const st = fs.statSync(p);
|
|
31
|
+
if (!st.isDirectory()) {
|
|
32
|
+
return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @type {string[]} */
|
|
36
|
+
const parts = [];
|
|
37
|
+
const walk = (dir, base) => {
|
|
38
|
+
const entries = fs
|
|
39
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
40
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
41
|
+
for (const e of entries) {
|
|
42
|
+
const abs = path.join(dir, e.name);
|
|
43
|
+
const rel = base ? `${base}/${e.name}` : e.name;
|
|
44
|
+
if (e.isDirectory()) walk(abs, rel);
|
|
45
|
+
else parts.push(`${rel}:${crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex')}`);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
walk(p, '');
|
|
49
|
+
return parts.join('\n');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** new = 项目里还没有;same = 内容一致;update = 有差异会被覆盖 */
|
|
53
|
+
function statusOf(from, to) {
|
|
54
|
+
if (!fs.existsSync(to)) return 'new';
|
|
55
|
+
try {
|
|
56
|
+
return fingerprint(from) === fingerprint(to) ? 'same' : 'update';
|
|
57
|
+
} catch {
|
|
58
|
+
// 目标不是常规文件/目录(比如是链接),一律当作会被覆盖,让上层去拦
|
|
59
|
+
return 'update';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 落点路径上、**项目范围内**有没有链接——不含最后一段(那一段由调用方判,
|
|
65
|
+
* 消息不一样)。
|
|
66
|
+
*
|
|
67
|
+
* 为什么必须往上找:守卫原先只看叶子,于是 `.agents/skills` 自己是链接时,
|
|
68
|
+
* `.agents/skills/alpha` 不是链接,守卫整条放过——sync 就**写穿到链接指向的
|
|
69
|
+
* 地方**去了(实测过:内容落进了外部目录,退出码还是 0),而本工具对用户的
|
|
70
|
+
* 承诺是「不写进链接指向的地方」。
|
|
71
|
+
*
|
|
72
|
+
* 链接指向别处意味着那份内容可能**还有别的项目在管**(每个项目的记录是各管各的),
|
|
73
|
+
* 这边 `--prune` 一删,那边就凭空少东西。所以和叶子一样:拒绝,并说清怎么走。
|
|
74
|
+
*
|
|
75
|
+
* 上溯**从项目根开始**,不爬到文件系统根:项目根自己的祖先是不是链接
|
|
76
|
+
* (macOS 上 `/tmp` 就是)不归本工具管,管了只会全是误报。
|
|
77
|
+
*
|
|
78
|
+
* @param {string} projectRoot @param {string} abs
|
|
79
|
+
* @returns {string|null} 是链接的那一段的绝对路径
|
|
80
|
+
*/
|
|
81
|
+
function linkInPath(projectRoot, abs) {
|
|
82
|
+
const rel = path.relative(projectRoot, abs);
|
|
83
|
+
if (rel === '' || path.isAbsolute(rel) || rel.startsWith('..')) return null; // 落点不在项目内
|
|
84
|
+
|
|
85
|
+
let cur = projectRoot;
|
|
86
|
+
// 去掉最后一段(叶子),只看中间目录
|
|
87
|
+
for (const part of rel.split(path.sep).slice(0, -1)) {
|
|
88
|
+
cur = path.join(cur, part);
|
|
89
|
+
if (isSymlink(cur)) return cur;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 递归列出目录下的所有文件(相对路径,正斜杠)。
|
|
96
|
+
* @param {string} dir @returns {string[]}
|
|
97
|
+
*/
|
|
98
|
+
function walkFiles(dir) {
|
|
99
|
+
/** @type {string[]} */
|
|
100
|
+
const out = [];
|
|
101
|
+
const walk = (d, base) => {
|
|
102
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
103
|
+
const rel = base ? `${base}/${e.name}` : e.name;
|
|
104
|
+
if (e.isDirectory()) walk(path.join(d, e.name), rel);
|
|
105
|
+
else out.push(rel);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
walk(dir, '');
|
|
109
|
+
return out.sort();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 本次选择里有没有 hook 或 mcp —— **只有这两类会引用 `scripts/` 里的文件**。
|
|
114
|
+
* @param {string[]} items
|
|
115
|
+
*/
|
|
116
|
+
function wantsSupport(items) {
|
|
117
|
+
return items.some((spec) => {
|
|
118
|
+
const { kind } = parseItem(spec);
|
|
119
|
+
return kind === 'hook' || kind === 'mcp';
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `scripts/` 是支撑目录:hook 和 mcp 都按路径引用脚本,单独挑拣容易漏,
|
|
125
|
+
* 所以整目录同步。它不参与 bundle 的条目挑选。
|
|
126
|
+
*
|
|
127
|
+
* 「整目录」指的是**在 scripts/ 内部不再细分**,而不是「无条件同步」——
|
|
128
|
+
* 要不要同步它,由 `planInstall` 按本次选择决定,见那里的注释。
|
|
129
|
+
*/
|
|
130
|
+
function collectSupport(repoRoot, projectRoot) {
|
|
131
|
+
const from = path.resolve(repoRoot, SUPPORT_DIR);
|
|
132
|
+
if (!fs.existsSync(from)) return [];
|
|
133
|
+
|
|
134
|
+
return walkFiles(from).map((rel) => {
|
|
135
|
+
const src = path.join(from, rel);
|
|
136
|
+
const dst = path.resolve(projectRoot, CONTENT_ROOT, SUPPORT_DIR, rel);
|
|
137
|
+
return {
|
|
138
|
+
spec: `script:${rel}`,
|
|
139
|
+
kind: 'script',
|
|
140
|
+
id: rel,
|
|
141
|
+
from: src,
|
|
142
|
+
to: dst,
|
|
143
|
+
status: statusOf(src, dst),
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 算出「要装什么、每个的当前状态」,但不写盘。
|
|
150
|
+
* @param {string} repoRoot @param {string} projectRoot @param {string[]} items
|
|
151
|
+
*/
|
|
152
|
+
export function planInstall(repoRoot, projectRoot, items) {
|
|
153
|
+
/** @type {{spec: string, kind: string, id: string, from: string, to: string, status: string, linkAt?: string}[]} */
|
|
154
|
+
const entries = [];
|
|
155
|
+
|
|
156
|
+
for (const spec of items) {
|
|
157
|
+
const { kind, id } = parseItem(spec);
|
|
158
|
+
const from = itemPath(repoRoot, kind, id);
|
|
159
|
+
const to = projectItemPath(projectRoot, kind, id);
|
|
160
|
+
entries.push({ spec, kind, id, from, to, status: statusOf(from, to) });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// `scripts/` 只在**本轮真的选了 hook 或 mcp** 时才同步。
|
|
164
|
+
//
|
|
165
|
+
// 「整目录同步」的理由是「hook / mcp 按路径引用脚本,逐个挑拣容易漏,漏了就是
|
|
166
|
+
// 运行时静默失败」——那个理由只在有 hook / mcp 的时候成立。一个都没选的时候,
|
|
167
|
+
// `.agents/` 里没有任何东西会去引用它们,同步过去只是把一堆用不上的文件塞进
|
|
168
|
+
// 每个项目,而**它们是要提交进版本库的**。
|
|
169
|
+
//
|
|
170
|
+
// 注意判据是 `items`(本次选择),不是滤掉 protect 之后的计划:锁住的 hook
|
|
171
|
+
// 仍然躺在 `.agents/hooks/` 里,它引用的脚本还得留着。
|
|
172
|
+
if (wantsSupport(items)) entries.push(...collectSupport(repoRoot, projectRoot));
|
|
173
|
+
|
|
174
|
+
// 路径上有链接的一律拦下——那多半是 link 命令建的反向链接,
|
|
175
|
+
// 写进去等于把内容仓库的东西写进一个链接指向的地方。叶子和中间目录都查。
|
|
176
|
+
for (const e of entries) {
|
|
177
|
+
if (isSymlink(e.to)) {
|
|
178
|
+
e.status = 'blocked';
|
|
179
|
+
e.linkAt = e.to;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const up = linkInPath(projectRoot, e.to);
|
|
183
|
+
if (up) {
|
|
184
|
+
e.status = 'blocked';
|
|
185
|
+
e.linkAt = up;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return entries;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 落盘。
|
|
194
|
+
*
|
|
195
|
+
* 覆盖策略:先整体删掉目标再拷。不能用 `cpSync` 的 force——它只覆盖同名文件,
|
|
196
|
+
* 内容仓库里**删掉的文件**会残留在项目里,越积越多。
|
|
197
|
+
*
|
|
198
|
+
* @param {ReturnType<typeof planInstall>} entries
|
|
199
|
+
* @param {{dryRun?: boolean}} [opts]
|
|
200
|
+
*/
|
|
201
|
+
export function applyInstall(entries, opts = {}) {
|
|
202
|
+
const { dryRun = false } = opts;
|
|
203
|
+
let written = 0;
|
|
204
|
+
const skipped = entries.filter((e) => e.status === 'same').length;
|
|
205
|
+
/** @type {{spec: string, message: string}[]} */
|
|
206
|
+
const problems = [];
|
|
207
|
+
|
|
208
|
+
if (dryRun) {
|
|
209
|
+
return { written: 0, skipped, problems };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const e of entries) {
|
|
213
|
+
if (e.status === 'same' || e.status === 'blocked') continue;
|
|
214
|
+
try {
|
|
215
|
+
fs.mkdirSync(path.dirname(e.to), { recursive: true });
|
|
216
|
+
fs.rmSync(e.to, { recursive: true, force: true });
|
|
217
|
+
fs.cpSync(e.from, e.to, { recursive: true });
|
|
218
|
+
written += 1;
|
|
219
|
+
} catch (err) {
|
|
220
|
+
problems.push({ spec: e.spec, message: /** @type {Error} */ (err).message });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { written, skipped, problems };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 找出项目 `.agents/` 下、但不在本次安装清单里的条目。
|
|
229
|
+
*
|
|
230
|
+
* 只报告、不删除——无法区分「上次装完残留的」和「用户自己加的」,
|
|
231
|
+
* 代用户做删除决定风险太大。
|
|
232
|
+
*
|
|
233
|
+
* 不检查 `scripts/`:它随内容一起整体同步,不属于任何 bundle 条目,
|
|
234
|
+
* 列进来只会每次误报。
|
|
235
|
+
*
|
|
236
|
+
* @param {string} projectRoot @param {string[]} items
|
|
237
|
+
*/
|
|
238
|
+
export function findOrphans(projectRoot, items) {
|
|
239
|
+
const wanted = new Set(items.map((s) => parseItem(s)).map(({ kind, id }) => `${kind}:${id}`));
|
|
240
|
+
/** @type {string[]} */
|
|
241
|
+
const orphans = [];
|
|
242
|
+
|
|
243
|
+
for (const [kind, cfg] of Object.entries(ITEM_KINDS)) {
|
|
244
|
+
const dir = path.resolve(projectRoot, CONTENT_ROOT, cfg.dir);
|
|
245
|
+
/** @type {fs.Dirent[]} */
|
|
246
|
+
let entries;
|
|
247
|
+
try {
|
|
248
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
249
|
+
} catch {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
for (const e of entries) {
|
|
253
|
+
const id = cfg.ext === null ? e.name : e.name.endsWith(cfg.ext) ? e.name.slice(0, -cfg.ext.length) : null;
|
|
254
|
+
if (id === null) continue;
|
|
255
|
+
if (!wanted.has(`${kind}:${id}`)) orphans.push(`${kind}:${id}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return orphans.sort();
|
|
260
|
+
}
|
package/lib/log.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import process from 'node:process';
|
|
3
4
|
|
|
4
5
|
// 颜色仅在 TTY 下启用,遵循 NO_COLOR 约定
|
|
@@ -56,3 +57,13 @@ export const title = (msg) => {
|
|
|
56
57
|
export const plain = (msg = '') => {
|
|
57
58
|
if (!silent()) console.log(msg);
|
|
58
59
|
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 给人看的相对路径:一律正斜杠,跨平台长得一样。
|
|
63
|
+
*
|
|
64
|
+
* 这是**显示**规则,不是路径处理——真正拼路径的地方一律 `path.join`/`path.resolve`。
|
|
65
|
+
* 放在这里是因为它服务于输出:`status` / `sync` 原先各写了一份一模一样的实现。
|
|
66
|
+
*
|
|
67
|
+
* @param {string} from @param {string} to
|
|
68
|
+
*/
|
|
69
|
+
export const rel = (from, to) => path.relative(from, to).split(path.sep).join('/');
|