agent-syncer 0.1.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.
@@ -0,0 +1,133 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { loadConfig } from '../config.js';
5
+ import { checkBlock } from '../gitignore.js';
6
+ import { STATUS, inspect } from '../link.js';
7
+ import { dim, fail, info, ok, plain, skip, title, warn } from '../log.js';
8
+ import { CONTENT_ROOT, KINDS, TOOLS, plannedLinks } from '../target.js';
9
+
10
+ /** 统一的相对路径显示(正斜杠,跨平台一致) */
11
+ const rel = (from, to) => path.relative(from, to).split(path.sep).join('/');
12
+
13
+ /** 统计目录下的条目数;目录不存在返回 null */
14
+ function countEntries(dir) {
15
+ try {
16
+ return fs.readdirSync(dir).length;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ const KIND_LABEL = {
23
+ skills: 'skills',
24
+ rules: 'rules',
25
+ commands: 'commands',
26
+ agents: 'agents',
27
+ };
28
+
29
+ /**
30
+ * agent-syncer status —— 纯只读。报告内容清单与每个链接的健康状况。
31
+ * @param {{cwd: string, flags: Record<string, any>}} ctx
32
+ */
33
+ export async function run({ cwd }) {
34
+ const config = loadConfig(cwd);
35
+
36
+ title('agent-syncer status');
37
+ plain(dim(`项目根:${cwd}`));
38
+
39
+ // ---- 内容 ----
40
+ title(`内容(${CONTENT_ROOT}/)`);
41
+ const contentRoot = path.resolve(cwd, CONTENT_ROOT);
42
+ if (!fs.existsSync(contentRoot)) {
43
+ fail(`${CONTENT_ROOT}/ 不存在——还没有任何内容`);
44
+ } else {
45
+ for (const kind of KINDS) {
46
+ const n = countEntries(path.resolve(contentRoot, kind));
47
+ if (n === null) {
48
+ skip(`${KIND_LABEL[kind].padEnd(9)} 无此目录`);
49
+ } else {
50
+ ok(`${KIND_LABEL[kind].padEnd(9)} ${String(n).padStart(3)} 项`);
51
+ }
52
+ }
53
+ }
54
+
55
+ // ---- 链接 ----
56
+ if (config.tools.length === 0) {
57
+ title('链接');
58
+ warn('未声明任何工具');
59
+ return 0;
60
+ }
61
+ if (!config.exists) {
62
+ plain(dim(`(未找到 agents.json:${config.warnings.join(';')})`));
63
+ }
64
+
65
+ const plan = plannedLinks(cwd, config.links);
66
+ let healthy = 0;
67
+ let absent = 0;
68
+ /** @type {string[]} */
69
+ const problems = [];
70
+
71
+ // 按工具分组展示,读起来比一条长清单清楚
72
+ for (const tool of config.tools) {
73
+ title(`链接 · ${TOOLS[tool].label}`);
74
+ for (const item of plan.filter((p) => p.tool === tool)) {
75
+ const st = inspect(item.target, item.source);
76
+ const target = rel(cwd, item.target);
77
+ const source = rel(cwd, item.source);
78
+
79
+ switch (st.status) {
80
+ case STATUS.HEALTHY:
81
+ ok(`${target.padEnd(20)} ${dim(`→ ${source}`)}`);
82
+ healthy += 1;
83
+ break;
84
+ case STATUS.ABSENT:
85
+ skip(`${target.padEnd(20)} ${dim('未创建')}`);
86
+ absent += 1;
87
+ break;
88
+ case STATUS.BROKEN:
89
+ fail(`${target.padEnd(20)} 断链 → ${st.actual ?? st.raw}(目标已不存在)`);
90
+ problems.push(`${target} 是断链,运行 link 可重建`);
91
+ break;
92
+ case STATUS.ELSEWHERE:
93
+ warn(`${target.padEnd(20)} 指向别处 → ${st.actual}`);
94
+ problems.push(`${target} 指向别处,确认后可用 link --force 接管`);
95
+ break;
96
+ case STATUS.REPLACED:
97
+ warn(`${target.padEnd(20)} 是实体目录/文件,不是链接`);
98
+ problems.push(`${target} 是实体目录,link 不会覆盖它`);
99
+ break;
100
+ default:
101
+ fail(`${target.padEnd(20)} 未知状态:${st.status}`);
102
+ problems.push(`${target} 状态未知:${st.status}`);
103
+ }
104
+ }
105
+ }
106
+
107
+ // ---- .gitignore ----
108
+ title('.gitignore 托管段');
109
+ const gi = checkBlock(cwd, config.links);
110
+ if (!gi.present) {
111
+ warn('未找到 agent-syncer 托管段,运行 link 会写入');
112
+ } else if (gi.missing.length > 0) {
113
+ warn(`托管段已存在,但缺少 ${gi.missing.length} 个条目,运行 link 会补齐`);
114
+ for (const m of gi.missing) plain(dim(` · ${m}`));
115
+ } else {
116
+ ok('完整');
117
+ }
118
+
119
+ // ---- 小结 ----
120
+ title('小结');
121
+ plain(` 健康 ${healthy} 未创建 ${absent}${problems.length ? ` 问题 ${problems.length}` : ''}`);
122
+
123
+ if (problems.length > 0) {
124
+ title('需要你处理');
125
+ for (const p of problems) plain(` · ${p}`);
126
+ return 1;
127
+ }
128
+
129
+ if (absent > 0) {
130
+ info(`有 ${absent} 个链接未创建,运行 ${dim('agent-syncer link')} 即可建好`);
131
+ }
132
+ return 0;
133
+ }
package/lib/config.js ADDED
@@ -0,0 +1,170 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import {
5
+ KINDS,
6
+ TOOL_NAMES,
7
+ allSpecs,
8
+ contentDir,
9
+ isTool,
10
+ parseSpec,
11
+ toolsOfSpecs,
12
+ } from './target.js';
13
+
14
+ export const CONFIG_FILENAME = 'agents.json';
15
+
16
+ /**
17
+ * 读取项目配置 agents.json。
18
+ *
19
+ * 两种写法都支持:
20
+ * { "links": ["claude/skills", "trae/skills"] } ← 精确到目录(推荐,交互式勾选写出的就是它)
21
+ * { "tools": ["claude", "trae"] } ← 等价于这些工具的全部条目
22
+ *
23
+ * 文件不存在时按「项目里已有哪些工具目录 + .agents/ 下已有哪些内容」推断,
24
+ * 本轮只做推断、不落盘——让交互式命令去问用户。
25
+ *
26
+ * @param {string} projectRoot
27
+ * @returns {{links: string[], tools: string[], path: string, exists: boolean, inferred: boolean, warnings: string[]}}
28
+ */
29
+ export function loadConfig(projectRoot) {
30
+ const cfgPath = path.resolve(projectRoot, CONFIG_FILENAME);
31
+ /** @type {string[]} */
32
+ const warnings = [];
33
+
34
+ if (!fs.existsSync(cfgPath)) {
35
+ const links = inferLinks(projectRoot);
36
+ return {
37
+ links,
38
+ tools: toolsOfSpecs(links),
39
+ path: cfgPath,
40
+ exists: false,
41
+ inferred: true,
42
+ warnings,
43
+ };
44
+ }
45
+
46
+ /** @type {any} */
47
+ let raw;
48
+ try {
49
+ raw = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
50
+ } catch (e) {
51
+ throw new Error(`${CONFIG_FILENAME} 不是合法的 JSON:${/** @type {Error} */ (e).message}`);
52
+ }
53
+
54
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
55
+ throw new Error(`${CONFIG_FILENAME} 的顶层必须是一个对象`);
56
+ }
57
+
58
+ /** @type {string[]} */
59
+ let links;
60
+ if (raw.links !== undefined) {
61
+ links = normalizeLinks(raw.links, warnings);
62
+ } else if (raw.tools !== undefined) {
63
+ // 与 links 分支保持一致的排序,否则两种写法会产出不同顺序的 diff
64
+ links = sortSpecs(normalizeTools(raw.tools, warnings).flatMap(allSpecs));
65
+ } else {
66
+ warnings.push(`${CONFIG_FILENAME} 里既没有 links 也没有 tools`);
67
+ links = [];
68
+ }
69
+
70
+ return {
71
+ links,
72
+ tools: toolsOfSpecs(links),
73
+ path: cfgPath,
74
+ exists: true,
75
+ inferred: false,
76
+ warnings,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * 写入配置。按固定顺序(工具顺序 → 内容类型顺序)排列,
82
+ * 这样每次勾选后产生的 diff 是稳定的,不会因为点击顺序变来变去。
83
+ * @param {string} projectRoot @param {string[]} links
84
+ */
85
+ export function saveConfig(projectRoot, links) {
86
+ const p = path.resolve(projectRoot, CONFIG_FILENAME);
87
+ const sorted = sortSpecs(links);
88
+ fs.writeFileSync(p, `${JSON.stringify({ links: sorted }, null, 2)}\n`);
89
+ return p;
90
+ }
91
+
92
+ /** 按 工具顺序 → 内容类型顺序 排序并去重 */
93
+ export function sortSpecs(specs) {
94
+ const parsed = specs.map((s) => ({ spec: s, ...parseSpec(s) }));
95
+ parsed.sort((a, b) => {
96
+ const t = TOOL_NAMES.indexOf(a.tool) - TOOL_NAMES.indexOf(b.tool);
97
+ return t !== 0 ? t : KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind);
98
+ });
99
+ return [...new Set(parsed.map((p) => p.spec))];
100
+ }
101
+
102
+ /**
103
+ * 校验并归一化 links 字段
104
+ * @param {unknown} value @param {string[]} warnings
105
+ * @returns {string[]}
106
+ */
107
+ export function normalizeLinks(value, warnings = []) {
108
+ if (!Array.isArray(value)) {
109
+ throw new Error('links 必须是字符串数组,例如 ["claude/skills", "trae/skills"]');
110
+ }
111
+ /** @type {string[]} */
112
+ const out = [];
113
+ for (const item of value) {
114
+ if (typeof item !== 'string') {
115
+ throw new Error(`links 里出现了非字符串项:${JSON.stringify(item)}`);
116
+ }
117
+ const spec = item.trim();
118
+ try {
119
+ parseSpec(spec);
120
+ } catch (e) {
121
+ warnings.push(`忽略无效条目 "${item}":${/** @type {Error} */ (e).message}`);
122
+ continue;
123
+ }
124
+ if (!out.includes(spec)) out.push(spec);
125
+ }
126
+ return sortSpecs(out);
127
+ }
128
+
129
+ /**
130
+ * 校验并归一化 tools 字段
131
+ * @param {unknown} value @param {string[]} warnings
132
+ * @returns {string[]}
133
+ */
134
+ export function normalizeTools(value, warnings = []) {
135
+ if (!Array.isArray(value)) {
136
+ throw new Error('tools 必须是字符串数组,例如 ["claude", "trae", "codex"]');
137
+ }
138
+ /** @type {string[]} */
139
+ const out = [];
140
+ for (const item of value) {
141
+ if (typeof item !== 'string') {
142
+ throw new Error(`tools 里出现了非字符串项:${JSON.stringify(item)}`);
143
+ }
144
+ const name = item.trim().toLowerCase();
145
+ if (!isTool(name)) {
146
+ warnings.push(`忽略未知工具 "${item}"(可用值:${TOOL_NAMES.join(', ')})`);
147
+ continue;
148
+ }
149
+ if (!out.includes(name)) out.push(name);
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /**
155
+ * 没有配置时的推断:只推断「工具目录已存在」且「内容目录已存在」的组合。
156
+ * 两者缺一都不猜——多猜一个就会凭空建出一个没人要的目录。
157
+ * @param {string} projectRoot
158
+ */
159
+ function inferLinks(projectRoot) {
160
+ /** @type {string[]} */
161
+ const links = [];
162
+ for (const tool of TOOL_NAMES) {
163
+ if (!fs.existsSync(path.resolve(projectRoot, `.${tool}`))) continue;
164
+ for (const spec of allSpecs(tool)) {
165
+ const { kind } = parseSpec(spec);
166
+ if (fs.existsSync(contentDir(projectRoot, kind))) links.push(spec);
167
+ }
168
+ }
169
+ return links;
170
+ }
@@ -0,0 +1,108 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { CONTENT_ROOT, KINDS, TOOLS, parseSpec } from './target.js';
5
+
6
+ export const BEGIN_MARK = '# >>> agent-sync >>>';
7
+ export const END_MARK = '# <<< agent-sync <<<';
8
+
9
+ /**
10
+ * 生成应写入 .gitignore 的托管段。
11
+ *
12
+ * 为什么必须忽略这些链接:junction / symlink 记录的是**绝对路径**,
13
+ * 提交上去在别人的机器上全是错的;更危险的是 git 会**穿透链接**,
14
+ * 把 .agents/ 里的内容原样再提交一份,仓库里出现两套内容。
15
+ *
16
+ * 只忽略**确实建了链接**的目录:如果某个目录是用户自己的实体目录,
17
+ * 忽略它会导致内容进不了版本库,那是帮倒忙。
18
+ *
19
+ * 注意:.claude/settings.json 与 .claude/hooks/ **不能**忽略——
20
+ * 那些是项目真实配置,需要进版本库。
21
+ *
22
+ * @param {string[]} specs 形如 "claude/skills"
23
+ */
24
+ export function buildBlock(specs) {
25
+ /** @type {string[]} */
26
+ const lines = [
27
+ BEGIN_MARK,
28
+ '# 由 agent-sync 维护,请勿手工修改此段',
29
+ `${CONTENT_ROOT}/*`,
30
+ ];
31
+
32
+ // 白名单:只放行真正的内容目录,其余(临时文件、缓存)一律忽略
33
+ for (const kind of KINDS) {
34
+ lines.push(`!${CONTENT_ROOT}/${kind}/`);
35
+ lines.push(`!${CONTENT_ROOT}/${kind}/*`);
36
+ }
37
+
38
+ /** @type {string[]} */
39
+ const targets = [];
40
+ for (const spec of specs) {
41
+ const { tool, kind } = parseSpec(spec);
42
+ const rel = TOOLS[tool].links[kind];
43
+ if (!targets.includes(rel)) targets.push(rel);
44
+ }
45
+ lines.push(...targets);
46
+
47
+ lines.push(END_MARK);
48
+ return lines.join('\n');
49
+ }
50
+
51
+ /** 读取 .gitignore 内容,不存在则返回空串 */
52
+ export function readGitignore(projectRoot) {
53
+ const p = path.resolve(projectRoot, '.gitignore');
54
+ try {
55
+ return fs.readFileSync(p, 'utf8');
56
+ } catch {
57
+ return '';
58
+ }
59
+ }
60
+
61
+ /**
62
+ * 只读检查:托管段是否存在、缺哪些条目。
63
+ * @param {string} projectRoot @param {string[]} specs
64
+ */
65
+ export function checkBlock(projectRoot, specs) {
66
+ const content = readGitignore(projectRoot);
67
+ const start = content.indexOf(BEGIN_MARK);
68
+ const end = content.indexOf(END_MARK);
69
+
70
+ if (start === -1 || end === -1 || end < start) {
71
+ return { present: false, missing: [], extra: [] };
72
+ }
73
+
74
+ const block = content.slice(start, end);
75
+ const expected = buildBlock(specs)
76
+ .split('\n')
77
+ .filter((l) => l && !l.startsWith('#'));
78
+ const missing = expected.filter((l) => !block.includes(l));
79
+
80
+ return { present: true, missing, extra: [] };
81
+ }
82
+
83
+ /**
84
+ * 写入 / 更新托管段。幂等。
85
+ * @param {string} projectRoot @param {string[]} specs
86
+ * @param {{dryRun?: boolean}} [opts]
87
+ */
88
+ export function writeBlock(projectRoot, specs, opts = {}) {
89
+ const p = path.resolve(projectRoot, '.gitignore');
90
+ const current = readGitignore(projectRoot);
91
+ const block = buildBlock(specs);
92
+
93
+ const start = current.indexOf(BEGIN_MARK);
94
+ const end = current.indexOf(END_MARK);
95
+
96
+ /** @type {string} */
97
+ let next;
98
+ if (start !== -1 && end !== -1 && end > start) {
99
+ next = current.slice(0, start) + block + current.slice(end + END_MARK.length);
100
+ } else {
101
+ const sep = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
102
+ next = `${current}${sep}${current.length > 0 ? '\n' : ''}${block}\n`;
103
+ }
104
+
105
+ const changed = next !== current;
106
+ if (changed && !opts.dryRun) fs.writeFileSync(p, next);
107
+ return { changed, block };
108
+ }
package/lib/link.js ADDED
@@ -0,0 +1,220 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+
6
+ /**
7
+ * 链接状态。
8
+ *
9
+ * - healthy 链接存在,且指向内容源
10
+ * - broken 是链接,但它指向的目标已不存在(断链)
11
+ * - elsewhere 是链接,但指向别处(不是我们管的)
12
+ * - replaced 路径存在,但不是链接(用户放了实体目录/文件)
13
+ * - absent 路径不存在
14
+ */
15
+ export const STATUS = /** @type {const} */ ({
16
+ HEALTHY: 'healthy',
17
+ BROKEN: 'broken',
18
+ ELSEWHERE: 'elsewhere',
19
+ REPLACED: 'replaced',
20
+ ABSENT: 'absent',
21
+ });
22
+
23
+ /**
24
+ * Windows 路径大小写不敏感,比较前归一化。
25
+ * @param {string} a @param {string} b
26
+ */
27
+ export function samePath(a, b) {
28
+ const na = path.resolve(a);
29
+ const nb = path.resolve(b);
30
+ return process.platform === 'win32' ? na.toLowerCase() === nb.toLowerCase() : na === nb;
31
+ }
32
+
33
+ /** 路径是否存在(含断链,因为 lstat 对断链仍有效) */
34
+ function lexists(p) {
35
+ try {
36
+ fs.lstatSync(p);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /** 是否是链接(对断链也返回 true) */
44
+ export function isSymlink(p) {
45
+ try {
46
+ return fs.lstatSync(p).isSymbolicLink();
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * 把 readlink 的结果归一化成绝对路径。
54
+ *
55
+ * 两种形态都要支持:
56
+ * - junction 返回绝对路径(Windows 上形如 C:\proj\.agents\skills)
57
+ * - 相对符号链接返回相对字符串,可能带尾斜杠(形如 .agents/rules/)
58
+ *
59
+ * @param {string} linkAbs @param {string} raw
60
+ */
61
+ export function resolveLinkTarget(linkAbs, raw) {
62
+ return path.resolve(path.dirname(linkAbs), raw);
63
+ }
64
+
65
+ /**
66
+ * 探测一个链接路径当前的状态。
67
+ * 纯只读,不修改任何东西。
68
+ *
69
+ * @param {string} linkAbs
70
+ * @param {string} expectedTarget
71
+ * @returns {{status: string, actual?: string, raw?: string, message?: string}}
72
+ */
73
+ export function inspect(linkAbs, expectedTarget) {
74
+ if (!lexists(linkAbs)) return { status: STATUS.ABSENT };
75
+ if (!isSymlink(linkAbs)) return { status: STATUS.REPLACED };
76
+
77
+ /** @type {string} */
78
+ let raw;
79
+ try {
80
+ raw = fs.readlinkSync(linkAbs);
81
+ } catch (e) {
82
+ return { status: STATUS.BROKEN, message: /** @type {Error} */ (e).message };
83
+ }
84
+
85
+ const actual = resolveLinkTarget(linkAbs, raw);
86
+
87
+ if (!fs.existsSync(actual)) {
88
+ return { status: STATUS.BROKEN, actual, raw };
89
+ }
90
+ if (!samePath(actual, expectedTarget)) {
91
+ return { status: STATUS.ELSEWHERE, actual, raw };
92
+ }
93
+ return { status: STATUS.HEALTHY, actual, raw };
94
+ }
95
+
96
+ /**
97
+ * 把系统错误归类,好给出可操作的提示。
98
+ * @param {NodeJS.ErrnoException} e
99
+ * @param {string} source
100
+ */
101
+ export function classifyError(e, source) {
102
+ const code = e.code ?? '';
103
+ if (process.platform === 'win32' && /^\\\\/.test(source)) {
104
+ return { reason: 'unc-path', message: '目标位于 UNC 网络路径,Windows 的 junction 不支持' };
105
+ }
106
+ if (code === 'EPERM' || code === 'EACCES') {
107
+ return {
108
+ reason: 'permission',
109
+ message:
110
+ '权限不足。Windows 上目录链接应使用 junction(免管理员),' +
111
+ '文件链接才需要管理员或开发者模式——本工具只链接目录,出现此错误通常是被杀软或策略拦截',
112
+ };
113
+ }
114
+ if (code === 'EEXIST') return { reason: 'exists', message: '路径已存在' };
115
+ if (code === 'ENOENT') return { reason: 'missing', message: '父目录或链接目标不存在' };
116
+ if (code === 'UNKNOWN') {
117
+ return { reason: 'unknown', message: '未知错误,常见于目标为网络驱动器且不支持重解析点' };
118
+ }
119
+ return { reason: code || 'unknown', message: e.message };
120
+ }
121
+
122
+ /**
123
+ * 创建链接(原子写:先建临时链接再 rename,失败时原路径不受影响)。
124
+ *
125
+ * 调用前应先 inspect();本函数只负责「路径不存在时创建」这一件事,
126
+ * 若路径已存在会直接拒绝,绝不覆盖。
127
+ *
128
+ * @param {string} source 内容源(绝对路径,必须已存在)
129
+ * @param {string} target 链接路径
130
+ * @param {{dryRun?: boolean, log?: (msg: string) => void}} [opts]
131
+ */
132
+ export function createLink(source, target, opts = {}) {
133
+ const { dryRun = false } = opts;
134
+
135
+ if (!fs.existsSync(source)) {
136
+ return { ok: false, reason: 'source-missing', message: `内容目录不存在:${source}` };
137
+ }
138
+ if (!fs.statSync(source).isDirectory()) {
139
+ return {
140
+ ok: false,
141
+ reason: 'not-a-directory',
142
+ message: `只链接目录,不链接文件:${source}`,
143
+ };
144
+ }
145
+ if (lexists(target)) {
146
+ return { ok: false, reason: 'exists', message: '路径已存在,拒绝覆盖' };
147
+ }
148
+
149
+ if (dryRun) return { ok: true, action: 'create', dryRun: true };
150
+
151
+ try {
152
+ fs.mkdirSync(path.dirname(target), { recursive: true });
153
+ } catch (e) {
154
+ return { ok: false, ...classifyError(/** @type {NodeJS.ErrnoException} */ (e), source) };
155
+ }
156
+
157
+ // Windows 目录必须用 junction:普通目录 symlink 需要管理员权限或开发者模式
158
+ const type = process.platform === 'win32' ? 'junction' : 'dir';
159
+ const tmp = `${target}.agent-sync.tmp`;
160
+
161
+ try {
162
+ fs.rmSync(tmp, { force: true, recursive: true });
163
+ fs.symlinkSync(source, tmp, type);
164
+ fs.renameSync(tmp, target);
165
+ return { ok: true, action: 'create' };
166
+ } catch (e) {
167
+ try {
168
+ fs.rmSync(tmp, { force: true, recursive: true });
169
+ } catch {
170
+ // 清理失败不影响主流程的错误上报
171
+ }
172
+ return { ok: false, ...classifyError(/** @type {NodeJS.ErrnoException} */ (e), source) };
173
+ }
174
+ }
175
+
176
+ /**
177
+ * 删除一个链接。
178
+ *
179
+ * 安全约束:**只删链接**。若路径是实体目录或实体文件,一律拒绝——
180
+ * 那可能是用户自己的东西,绝不代用户做删除决定。
181
+ *
182
+ * @param {string} target
183
+ */
184
+ export function removeLink(target) {
185
+ if (!lexists(target)) return { ok: true, action: 'noop' };
186
+ if (!isSymlink(target)) {
187
+ return { ok: false, reason: 'not-a-link', message: '不是链接,拒绝删除(可能是你自己的目录)' };
188
+ }
189
+ try {
190
+ fs.unlinkSync(target);
191
+ return { ok: true, action: 'removed' };
192
+ } catch (e) {
193
+ return { ok: false, ...classifyError(/** @type {NodeJS.ErrnoException} */ (e), target) };
194
+ }
195
+ }
196
+
197
+ /**
198
+ * 实测当前环境能否创建目录链接。
199
+ * 在系统临时目录里真建一次再删掉——比检查平台/权限的启发式可靠。
200
+ */
201
+ export function probeLinkCapability() {
202
+ const tmpRoot = fs.mkdtempSync(path.join(process.env.TEMP ?? '/tmp', 'agent-sync-probe-'));
203
+ const src = path.join(tmpRoot, 'src');
204
+ const dst = path.join(tmpRoot, 'dst');
205
+ try {
206
+ fs.mkdirSync(src);
207
+ createLink(src, dst);
208
+ const probe = inspect(dst, src);
209
+ const type = resolveLinkTarget(dst, fs.readlinkSync(dst));
210
+ return {
211
+ ok: probe.status === STATUS.HEALTHY,
212
+ type: process.platform === 'win32' ? 'junction' : 'dir',
213
+ resolved: type,
214
+ };
215
+ } catch (e) {
216
+ return { ok: false, message: /** @type {Error} */ (e).message };
217
+ } finally {
218
+ fs.rmSync(tmpRoot, { force: true, recursive: true });
219
+ }
220
+ }
package/lib/log.js ADDED
@@ -0,0 +1,58 @@
1
+ // @ts-check
2
+ import process from 'node:process';
3
+
4
+ // 颜色仅在 TTY 下启用,遵循 NO_COLOR 约定
5
+ const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
6
+
7
+ // 逐次读取而非模块加载时快照:这样测试可以在 import 之后再关掉输出
8
+ const silent = () => Boolean(process.env.AGENT_SYNC_SILENT);
9
+
10
+ const ESC = '';
11
+
12
+ /** @param {string} code */
13
+ const wrap = (code) => (/** @type {string} */ s) =>
14
+ useColor ? `${ESC}[${code}m${s}${ESC}[0m` : s;
15
+
16
+ export const dim = wrap('2');
17
+ export const bold = wrap('1');
18
+ export const red = wrap('31');
19
+ export const green = wrap('32');
20
+ export const yellow = wrap('33');
21
+ export const cyan = wrap('36');
22
+
23
+ export const SYM = {
24
+ ok: '✅',
25
+ warn: '⚠️ ',
26
+ err: '❌',
27
+ skip: '➖',
28
+ info: 'ℹ️ ',
29
+ };
30
+
31
+ /** @param {string} msg */
32
+ export const ok = (msg) => {
33
+ if (!silent()) console.log(`${SYM.ok} ${msg}`);
34
+ };
35
+ /** @param {string} msg */
36
+ export const warn = (msg) => {
37
+ if (!silent()) console.log(`${SYM.warn}${yellow(msg)}`);
38
+ };
39
+ /** @param {string} msg */
40
+ export const fail = (msg) => {
41
+ if (!silent()) console.error(`${SYM.err} ${red(msg)}`);
42
+ };
43
+ /** @param {string} msg */
44
+ export const skip = (msg) => {
45
+ if (!silent()) console.log(`${SYM.skip} ${dim(msg)}`);
46
+ };
47
+ /** @param {string} msg */
48
+ export const info = (msg) => {
49
+ if (!silent()) console.log(`${SYM.info}${msg}`);
50
+ };
51
+ /** @param {string} msg */
52
+ export const title = (msg) => {
53
+ if (!silent()) console.log(`\n${bold(msg)}`);
54
+ };
55
+ /** @param {string} [msg] */
56
+ export const plain = (msg = '') => {
57
+ if (!silent()) console.log(msg);
58
+ };