@geoly-ai/skills-hub 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.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/bin/skills-hub.mjs +26 -0
- package/package.json +44 -0
- package/src/adapters/index.mjs +832 -0
- package/src/artifact.mjs +376 -0
- package/src/atomic-fs.mjs +166 -0
- package/src/attestation.mjs +136 -0
- package/src/canonical-json.mjs +147 -0
- package/src/cli.mjs +208 -0
- package/src/commands/check.mjs +295 -0
- package/src/commands/context.mjs +235 -0
- package/src/commands/install.mjs +430 -0
- package/src/commands/locks.mjs +197 -0
- package/src/commands/output.mjs +127 -0
- package/src/commands/query.mjs +266 -0
- package/src/commands/recover.mjs +438 -0
- package/src/commands/registry.mjs +123 -0
- package/src/commands/resolve.mjs +171 -0
- package/src/commands/snapshot-access.mjs +91 -0
- package/src/commands/sync-lock.mjs +189 -0
- package/src/crc32c.mjs +27 -0
- package/src/exit-codes.mjs +265 -0
- package/src/fault-inject.mjs +379 -0
- package/src/install.mjs +732 -0
- package/src/journal.mjs +435 -0
- package/src/ledger.mjs +671 -0
- package/src/lock.mjs +98 -0
- package/src/lockfile.mjs +0 -0
- package/src/pack.mjs +792 -0
- package/src/packer.mjs +351 -0
- package/src/plan.mjs +519 -0
- package/src/recover.mjs +1345 -0
- package/src/safe-fs.mjs +252 -0
- package/src/sigstore.mjs +480 -0
- package/src/snapshot.mjs +528 -0
- package/src/stats.mjs +59 -0
- package/src/target.mjs +738 -0
- package/src/telemetry.mjs +393 -0
- package/src/tree-digest.mjs +103 -0
- package/src/trust-roots/README.md +31 -0
- package/src/trust-roots/sigstore-public-good.json +126 -0
- package/src/trust.mjs +563 -0
- package/src/untar.mjs +570 -0
- package/src/upload.mjs +268 -0
- package/src/vendor.mjs +465 -0
package/src/target.mjs
ADDED
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
// target 预检 —— 规范见 04-install.md §2.2 / §3.3 / §3.4 / §3.5 / §3.6
|
|
2
|
+
//
|
|
3
|
+
// 这个模块只做**编排**:底层的每一条判据都在 `src/safe-fs.mjs` 里,这里负责
|
|
4
|
+
// ① 按规格把它们凑齐,一条不漏;
|
|
5
|
+
// ② 🔴 **一次报出全部违规项**,而不是遇到第一个就退出 ——
|
|
6
|
+
// 用户不该修一个跑一次,尤其预检里有好几条是「改环境」而不是「改命令」。
|
|
7
|
+
//
|
|
8
|
+
// 🔴 **预检不能保证「世界不会变」**(§5.10 明文)。
|
|
9
|
+
// target 锁只约束遵守该锁的 CLI,不约束用户与其它进程。
|
|
10
|
+
// `precheckTarget()` 返回的是一个**带时间戳的快照结论**:它保证的是
|
|
11
|
+
// 「`checkedAt` 那一刻不存在已知的死路」,**不保证之后仍然成立**。
|
|
12
|
+
// 每一次真正的 rename、以及任何会毁掉恢复源的动作,都必须**在动作点复验并 fail-closed**。
|
|
13
|
+
// 因此本模块**故意不提供** `precheckAndLock` / `precheckAndInstall` 这类
|
|
14
|
+
// 会暗示原子性的组合 API —— 那种 API 形状本身就在骗人。
|
|
15
|
+
import {
|
|
16
|
+
lstatSync,
|
|
17
|
+
readdirSync,
|
|
18
|
+
realpathSync,
|
|
19
|
+
statSync,
|
|
20
|
+
existsSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
} from 'node:fs';
|
|
23
|
+
import { join, relative, isAbsolute, resolve, sep, parse } from 'node:path';
|
|
24
|
+
import {
|
|
25
|
+
assertSupportedFilesystem,
|
|
26
|
+
assertNotMountPoint,
|
|
27
|
+
assertNoMountPointsUnder,
|
|
28
|
+
assertNoSymlinkInChain,
|
|
29
|
+
assertPlainFileOrDir,
|
|
30
|
+
assertWritableDir,
|
|
31
|
+
_resetMountCache,
|
|
32
|
+
} from './safe-fs.mjs';
|
|
33
|
+
import {
|
|
34
|
+
STATE_DIR,
|
|
35
|
+
CLIENTS,
|
|
36
|
+
getAdapter,
|
|
37
|
+
gitignorePatternsFor,
|
|
38
|
+
GIT_CLEAN_WARNING,
|
|
39
|
+
} from './adapters/index.mjs';
|
|
40
|
+
|
|
41
|
+
/** 违规码。每一条都对应规格里的一条明文规则,测试逐条断言**报出了哪一项**。 */
|
|
42
|
+
export const V = Object.freeze({
|
|
43
|
+
UNSUPPORTED_FSTYPE: 'fs.unsupported-fstype', // §2.2
|
|
44
|
+
CROSS_DEVICE: 'fs.cross-device', // §2.2
|
|
45
|
+
OUTSIDE_BASE: 'target.outside-base', // 配置错误:target 不在可信 base 之下
|
|
46
|
+
SYMLINK_IN_CHAIN: 'target.symlink-in-chain', // §3.4
|
|
47
|
+
NOT_PLAIN_DIR: 'target.not-plain-dir', // §3.4 / 01-artifacts §5
|
|
48
|
+
STATE_SYMLINK: 'geoly.symlink-state-path', // §3.4
|
|
49
|
+
STATE_NOT_PLAIN: 'geoly.not-plain', // §3.4
|
|
50
|
+
GEOLY_IS_MOUNT: 'geoly.is-mount-point', // §3.4
|
|
51
|
+
GEOLY_MOUNT_UNDER: 'geoly.mount-point-under', // §3.4
|
|
52
|
+
NESTED_TARGET: 'target.nested', // §3.5
|
|
53
|
+
SCAN_INCOMPLETE: 'target.nested-scan-incomplete', // §3.5:扫不完就不能宣称没有嵌套
|
|
54
|
+
STATE_SCAN_INCOMPLETE: 'geoly.state-scan-incomplete', // §3.4:同理,扫不完就不能宣称没有 symlink
|
|
55
|
+
NOT_WRITABLE: 'target.not-writable', // §3.6
|
|
56
|
+
BASE_MISSING: 'target.base-missing', // 可信 base 本身不存在,symlink 链无从查起
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 🔴 底层判据的**测试注入缝**,故意用 Symbol 做 key。
|
|
61
|
+
*
|
|
62
|
+
* 有些拒绝规则(NFS/sshfs 挂载、只读挂载、bind mount、跨设备)在 CI 里造不出真实形状,
|
|
63
|
+
* 不注入就只能断言「抛了错」,而验收标准要的是「断言报出了哪一项违规」。
|
|
64
|
+
*
|
|
65
|
+
* 用 Symbol 而不是普通字符串 key,是为了让这个缝**不可能被顺手透传**:
|
|
66
|
+
* 普通调用方从 CLI 参数、JSON 配置、`{...opts}` 里拼出来的对象带不上一个
|
|
67
|
+
* 只在本模块导出的 Symbol。要用它必须显式 import,那就是一个有意的动作。
|
|
68
|
+
* 再加两道:结果里带 `depsOverridden`,且 `assertPrecheckOk` **拒绝**放行被注入过的结果。
|
|
69
|
+
*/
|
|
70
|
+
export const TEST_DEPS = Symbol('target.precheck.testDeps');
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 🔴 「这份结果是用真判据算出来的」这个事实**不能存在结果对象里**。
|
|
74
|
+
*
|
|
75
|
+
* 存成一个 `depsOverridden: false` 字段是不够的:拿到结果的人写一句
|
|
76
|
+
* `r.depsOverridden = false`(甚至 `r.ok = true`)就把边界拆了。
|
|
77
|
+
* 公开的布尔字段只是**给人看的**,不能拿来做放行判据。
|
|
78
|
+
*
|
|
79
|
+
* 判据放在模块私有的 WeakSet 里:外面拿不到这个引用,就伪造不出成员资格。
|
|
80
|
+
* 结果对象本身也冻结,省得有人改 `violations` 再拿去放行。
|
|
81
|
+
*/
|
|
82
|
+
const CLEAN_PRECHECKS = new WeakSet();
|
|
83
|
+
|
|
84
|
+
/** 递归冻结。`detail` 以后放进嵌套对象时,浅冻结会漏掉内层。 */
|
|
85
|
+
function deepFreeze(o) {
|
|
86
|
+
if (!o || typeof o !== 'object' || Object.isFrozen(o)) return o;
|
|
87
|
+
Object.freeze(o);
|
|
88
|
+
for (const v of Object.values(o)) deepFreeze(v);
|
|
89
|
+
return o;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 🔴 「有效 `.geoly` 状态」的判据(§3.5 收窄识别范围用)。
|
|
94
|
+
*
|
|
95
|
+
* 为什么不能「有个叫 `.geoly` 的目录就算」:那等于按名字扫后代,
|
|
96
|
+
* 规格明确点名这会**误伤普通目录**(有人手工建了个空 `.geoly`、或者某个 skill
|
|
97
|
+
* 自己带了同名目录)。判据必须是**实际存在的状态标记**。
|
|
98
|
+
*
|
|
99
|
+
* ⚠️ `audit-seq` 必须在清单里:§4 规定「没有 ledger 但 seq 存在」是**合法状态**
|
|
100
|
+
* (pre-commit 清理只删骨架、不删 seq)。漏掉它会把一个真 target 判成普通目录。
|
|
101
|
+
*/
|
|
102
|
+
const STATE_MARKER_FILES = [
|
|
103
|
+
'ledger.json',
|
|
104
|
+
'lock.db',
|
|
105
|
+
'lock.db-wal',
|
|
106
|
+
'lock.db-shm',
|
|
107
|
+
'generation',
|
|
108
|
+
'audit-seq',
|
|
109
|
+
'repair-intent.json',
|
|
110
|
+
'audit-archive-intent.json',
|
|
111
|
+
];
|
|
112
|
+
const STATE_MARKER_DIRS = ['journal', 'attic', 'quarantine', 'audit-archive'];
|
|
113
|
+
|
|
114
|
+
/** `.geoly` 之下会出现的状态路径,全部要以 lstat 无跟随方式检查(§3.4)。 */
|
|
115
|
+
const SCAN_DEFAULTS = Object.freeze({ maxDepth: 8, maxDirs: 5000 });
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 🔴 扫描上限必须校验。`NaN` 参与 `>=` 永远是 false ——
|
|
119
|
+
* 传进来一个 `NaN`(或 `'8'` 之类)会让 `visited >= maxDirs` 恒不成立,
|
|
120
|
+
* 有界遍历悄悄变成无界遍历。这不是「参数写错了自己负责」,
|
|
121
|
+
* 因为它不报错、看起来还更"彻底",只是会把整棵盘扫一遍。
|
|
122
|
+
*/
|
|
123
|
+
function normalizeScan(scan = {}) {
|
|
124
|
+
const pick = (v, dflt, name) => {
|
|
125
|
+
if (v === undefined) return dflt;
|
|
126
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
|
|
127
|
+
throw new Error(`scan.${name} 必须是有限的非负数,收到 ${JSON.stringify(v)}`);
|
|
128
|
+
}
|
|
129
|
+
return Math.floor(v);
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
maxDepth: pick(scan.maxDepth, SCAN_DEFAULTS.maxDepth, 'maxDepth'),
|
|
133
|
+
maxDirs: pick(scan.maxDirs, SCAN_DEFAULTS.maxDirs, 'maxDirs'),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── 有效状态判定 ─────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `dir` 是不是一个**带有效 `.geoly` 状态**的 target。
|
|
141
|
+
* 全程 `lstat` 无跟随:`.geoly` 是 symlink 时**不算**有效状态(它另有一条违规码),
|
|
142
|
+
* 也绝不跟过去看对面有什么。
|
|
143
|
+
*/
|
|
144
|
+
export function hasGeolyState(dir) {
|
|
145
|
+
const state = join(dir, STATE_DIR);
|
|
146
|
+
let st;
|
|
147
|
+
try {
|
|
148
|
+
st = lstatSync(state);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
// 🔴 fail-closed 同上:ENOENT 是「真没有」,其余(EACCES 等)是「看不了」。
|
|
151
|
+
// 把「看不了」判成「没有」会让一个真 target 被当成普通目录去替换。
|
|
152
|
+
return !isAbsent(err);
|
|
153
|
+
}
|
|
154
|
+
if (!st.isDirectory()) return false; // symlink / 普通文件都不算
|
|
155
|
+
let names;
|
|
156
|
+
try {
|
|
157
|
+
names = readdirSync(state);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
if (isAbsent(err)) return false; // 刚被删掉,确实没有
|
|
160
|
+
// 🔴 fail-closed:读不进去(EACCES/EPERM)**不等于**没有状态。
|
|
161
|
+
// 判成「普通目录」会让一个真 target 被当成可以随便替换的 skill 目录 ——
|
|
162
|
+
// 那正是 §3.5 要防的事故。读不了就当它有。
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
const set = new Set(names);
|
|
166
|
+
for (const f of STATE_MARKER_FILES) if (set.has(f)) return true;
|
|
167
|
+
for (const d of STATE_MARKER_DIRS) if (set.has(d)) return true;
|
|
168
|
+
for (const n of names) if (n.startsWith('tx-')) return true;
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── 嵌套 target(§3.5) ──────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
const isUnder = (child, parent) => {
|
|
175
|
+
if (child === parent) return false;
|
|
176
|
+
const p = parent.endsWith(sep) ? parent : parent + sep;
|
|
177
|
+
return child.startsWith(p);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 找出与 `targetPath` 构成嵌套关系的其它 target。
|
|
182
|
+
*
|
|
183
|
+
* 🔴 识别范围**必须收窄**(§3.5)——只算两类:
|
|
184
|
+
* ① 本次命令**目标集合**里的其它 target(无论它有没有状态,我们这就要往里写);
|
|
185
|
+
* ② **实际带有有效 `.geoly/` 状态**的目录(`hasGeolyState`)。
|
|
186
|
+
* 绝不按名字扫任意后代的 `.claude/skills` —— 那会误伤普通目录。
|
|
187
|
+
*/
|
|
188
|
+
export function findNestedTargets(targetPath, { targetSet = [], scan = {} } = {}) {
|
|
189
|
+
const { maxDepth, maxDirs } = normalizeScan(scan);
|
|
190
|
+
const self = tryRealpath(targetPath);
|
|
191
|
+
const hits = [];
|
|
192
|
+
const seen = new Set();
|
|
193
|
+
const add = (path, relation, via) => {
|
|
194
|
+
const key = `${relation}:${path}`;
|
|
195
|
+
if (seen.has(key)) return;
|
|
196
|
+
seen.add(key);
|
|
197
|
+
hits.push({ path, relation, via });
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// ① 目标集合
|
|
201
|
+
for (const other of targetSet) {
|
|
202
|
+
const o = tryRealpath(other);
|
|
203
|
+
if (o === self) continue;
|
|
204
|
+
if (isUnder(self, o)) add(o, 'ancestor', 'target-set');
|
|
205
|
+
else if (isUnder(o, self)) add(o, 'descendant', 'target-set');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ②a 向上:任一祖先带有效状态 → 我们在别人的 target 里面
|
|
209
|
+
// 一路走到文件系统根:外层 target 完全可能在可信 base 之上
|
|
210
|
+
// (例如整个仓库被 clone 进了某个 `<...>/skills/<name>/` 目录里)。
|
|
211
|
+
// 代价只是几次 lstat,换的是不漏判。
|
|
212
|
+
// 🔴 **包含文件系统根本身**:`cur !== parent` 当条件会在 `/` 上提前退出,
|
|
213
|
+
// 于是根目录里的一个有效 `.geoly` 会漏判。
|
|
214
|
+
for (let cur = parse(self).dir; ; cur = parse(cur).dir) {
|
|
215
|
+
if (hasGeolyState(cur)) add(cur, 'ancestor', 'geoly-state');
|
|
216
|
+
if (!cur || cur === parse(cur).dir) break; // 判完根再退出
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ②b 向下:有界遍历,找带有效状态的后代
|
|
220
|
+
const scanResult = walkBounded(self, { maxDepth, maxDirs }, (dir) => {
|
|
221
|
+
if (dir !== self && hasGeolyState(dir)) add(dir, 'descendant', 'geoly-state');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return { nested: hits, complete: scanResult.complete, visited: scanResult.visited };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 有界遍历。不跟随 symlink(`readdirSync` 的 Dirent 判类型,不 stat)、
|
|
229
|
+
* 跳过 `.geoly` 自身(它的内容由状态路径检查负责,不是嵌套候选)。
|
|
230
|
+
*
|
|
231
|
+
* 🔴 撞到上限**不静默放过**:调用方会因此记一条 `target.nested-scan-incomplete`。
|
|
232
|
+
* 「扫不完」与「扫完了没有」是两件事,把前者说成后者就是在假装证明了一个否定命题。
|
|
233
|
+
*/
|
|
234
|
+
function walkBounded(root, { maxDepth, maxDirs }, visit) {
|
|
235
|
+
let visited = 0;
|
|
236
|
+
let complete = true;
|
|
237
|
+
const stack = [[root, 0]];
|
|
238
|
+
while (stack.length) {
|
|
239
|
+
const [dir, depth] = stack.pop();
|
|
240
|
+
if (visited >= maxDirs) {
|
|
241
|
+
complete = false;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
visited += 1;
|
|
245
|
+
visit(dir);
|
|
246
|
+
if (depth >= maxDepth) {
|
|
247
|
+
// 深度到顶但下面还有目录 → 同样是「没扫完」
|
|
248
|
+
if (hasSubdir(dir)) complete = false;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
let ents;
|
|
252
|
+
try {
|
|
253
|
+
ents = readdirSync(dir, { withFileTypes: true });
|
|
254
|
+
} catch (err) {
|
|
255
|
+
// 🔴 fail-closed:**读不进去**(EACCES/EPERM)的子树里可能藏着一个真 target,
|
|
256
|
+
// 静默跳过等于宣称「这里没有」,而我们并没有看过。
|
|
257
|
+
// ⚠️ 但 ENOENT/ENOTDIR 是「本来就没有东西」——目录还没建、或扫描途中被删 ——
|
|
258
|
+
// 那不是盲区,不能因此把每一次「target 尚不存在」的预检都判成扫不完。
|
|
259
|
+
if (!isAbsent(err)) complete = false;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
for (const e of ents) {
|
|
263
|
+
if (!e.isDirectory()) continue; // Dirent 的 isDirectory 对 symlink 返回 false
|
|
264
|
+
if (e.name === STATE_DIR) continue;
|
|
265
|
+
stack.push([join(dir, e.name), depth + 1]);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return { complete, visited };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function hasSubdir(dir) {
|
|
272
|
+
try {
|
|
273
|
+
return readdirSync(dir, { withFileTypes: true }).some((e) => e.isDirectory() && e.name !== STATE_DIR);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
// 🔴 读不了 → 无法证明下面没有目录,按「还有」算;不存在则确实没有
|
|
276
|
+
return !isAbsent(err);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 「这里本来就没东西」而不是「有东西但我看不了」。两者的 fail-closed 处置相反。 */
|
|
281
|
+
const isAbsent = (err) => err && (err.code === 'ENOENT' || err.code === 'ENOTDIR');
|
|
282
|
+
|
|
283
|
+
function tryRealpath(p) {
|
|
284
|
+
const abs = isAbsolute(p) ? p : resolve(p);
|
|
285
|
+
try {
|
|
286
|
+
return realpathSync(abs);
|
|
287
|
+
} catch {
|
|
288
|
+
return abs;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── 状态路径的 symlink 检查(§3.4) ──────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 🔴 `.geoly` 及其下**全部**状态路径必须以 lstat 无跟随方式检查,遇 symlink 即拒绝。
|
|
296
|
+
* §10 泛称的「拒绝路径链 symlink」只覆盖 target 本身,不足以覆盖状态目录 ——
|
|
297
|
+
* 有人把 `<target>/.geoly/ledger.json` 换成软链指到别处,锁和账本就分家了。
|
|
298
|
+
*
|
|
299
|
+
* 检查**整棵 `.geoly`**(含 `quarantine/`、`repair-intent.json`),不是只查枚举的几条:
|
|
300
|
+
* 枚举清单会随 schema 演进而过时,整棵扫不会。
|
|
301
|
+
*
|
|
302
|
+
* ⚠️ **这不是 no-follow 保证,只是一次观察**:`readdirSync` 的 Dirent 说某一项当时
|
|
303
|
+
* 不是 symlink,但在我们递归进去之前它可以被换成 symlink,那次 `readdirSync(path)`
|
|
304
|
+
* 就会跟过去。真正的 no-follow 要 `openat(dirfd, …, O_NOFOLLOW)`,Node 没有暴露。
|
|
305
|
+
* 所以这里的结论只是「扫描当时看到的目录项里没有 symlink」——
|
|
306
|
+
* 动作点仍然必须复验并 fail-closed(见文件顶部)。
|
|
307
|
+
*/
|
|
308
|
+
export function scanStatePaths(stateDir, scanOpts = {}) {
|
|
309
|
+
const { maxDepth, maxDirs } = normalizeScan(scanOpts);
|
|
310
|
+
const bad = [];
|
|
311
|
+
let st;
|
|
312
|
+
try {
|
|
313
|
+
st = lstatSync(stateDir);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
if (isAbsent(err)) return { symlinks: [], notPlain: [], complete: true }; // 还不存在,后面才创建
|
|
316
|
+
return { symlinks: [], notPlain: [], complete: false }; // 🔴 看不了 ≠ 没问题
|
|
317
|
+
}
|
|
318
|
+
if (st.isSymbolicLink()) return { symlinks: [stateDir], notPlain: [], complete: true };
|
|
319
|
+
if (!st.isDirectory()) return { symlinks: [], notPlain: [stateDir], complete: true };
|
|
320
|
+
|
|
321
|
+
const symlinks = [];
|
|
322
|
+
let visited = 0;
|
|
323
|
+
let complete = true;
|
|
324
|
+
const stack = [[stateDir, 0]];
|
|
325
|
+
while (stack.length) {
|
|
326
|
+
const [dir, depth] = stack.pop();
|
|
327
|
+
if (visited >= maxDirs) {
|
|
328
|
+
complete = false;
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
visited += 1;
|
|
332
|
+
let ents;
|
|
333
|
+
try {
|
|
334
|
+
ents = readdirSync(dir, { withFileTypes: true });
|
|
335
|
+
} catch (err) {
|
|
336
|
+
if (!isAbsent(err)) complete = false; // 🔴 看不了就不能宣称这下面没有 symlink
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
for (const e of ents) {
|
|
340
|
+
const p = join(dir, e.name);
|
|
341
|
+
if (e.isSymbolicLink()) {
|
|
342
|
+
symlinks.push(p);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (e.isDirectory()) {
|
|
346
|
+
if (depth < maxDepth) stack.push([p, depth + 1]);
|
|
347
|
+
else complete = false;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (!e.isFile()) bad.push(p); // FIFO / socket / 设备节点
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return { symlinks, notPlain: bad, complete };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── 挂载点(§3.4) ───────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* 🔴 `<target>/.geoly` 自身不得是挂载点,其下也不得含挂载点(§3.4)。
|
|
360
|
+
* 判据以挂载表为准,不只比 `st_dev` —— bind mount 的 `st_dev` 可以相同。
|
|
361
|
+
*
|
|
362
|
+
* 🔴 **只在 `.geoly` 已存在时判**。`assertNotMountPoint` 内部会往上找第一个存在的
|
|
363
|
+
* 祖先来 realpath,`.geoly` 不存在时那个祖先就是 target 自己 ——
|
|
364
|
+
* 于是「target 恰好是个挂载点」会被误报成「.geoly 是挂载点」。
|
|
365
|
+
* 不存在的目录不可能是挂载点,跳过才是对的。
|
|
366
|
+
*/
|
|
367
|
+
export function checkGeolyMountPoints(stateDir, deps = DEFAULT_DEPS) {
|
|
368
|
+
const out = [];
|
|
369
|
+
try {
|
|
370
|
+
lstatSync(stateDir);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
// 🔴 只有「真不存在」才跳过。EACCES 是「有但看不了」——
|
|
373
|
+
// 直接返回空数组等于宣称「不是挂载点」,那是 fail-open。
|
|
374
|
+
if (isAbsent(err)) return out;
|
|
375
|
+
out.push({
|
|
376
|
+
code: V.STATE_SCAN_INCOMPLETE,
|
|
377
|
+
path: stateDir,
|
|
378
|
+
message: `${stateDir} 读不了(${err.code}),无法判定它是不是挂载点、其下有没有挂载点`,
|
|
379
|
+
});
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
deps.assertNotMountPoint(stateDir);
|
|
384
|
+
} catch (err) {
|
|
385
|
+
out.push({ code: V.GEOLY_IS_MOUNT, path: stateDir, message: err.message });
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
deps.assertNoMountPointsUnder(stateDir);
|
|
389
|
+
} catch (err) {
|
|
390
|
+
out.push({ code: V.GEOLY_MOUNT_UNDER, path: stateDir, message: err.message });
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── 主流程 ───────────────────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* 底层判据的默认接线。**生产路径一律用默认值**;
|
|
399
|
+
* `deps` 参数只是测试用的注入缝 —— 有些拒绝规则(NFS/sshfs 挂载、只读挂载、
|
|
400
|
+
* bind mount)在 CI 里造不出来,不注入就没法断言「报出了哪一项违规」,
|
|
401
|
+
* 而「只断言抛了错」正是这次要避免的。
|
|
402
|
+
*/
|
|
403
|
+
export const DEFAULT_DEPS = Object.freeze({
|
|
404
|
+
assertSupportedFilesystem,
|
|
405
|
+
assertNotMountPoint,
|
|
406
|
+
assertNoMountPointsUnder,
|
|
407
|
+
assertNoSymlinkInChain,
|
|
408
|
+
assertPlainFileOrDir,
|
|
409
|
+
assertWritableDir,
|
|
410
|
+
/** target 用 `stat`(它可以是被 realpath 过的正常目录),`.geoly` 用 `lstat`(绝不跟随)。 */
|
|
411
|
+
targetDev: (p) => statSync(p).dev,
|
|
412
|
+
stateDev: (p) => lstatSync(p).dev,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 对一个 target 做全部预检,**收集**违规项而不是抛出。
|
|
417
|
+
*
|
|
418
|
+
* @param {string} targetPath 绝对路径(可以尚不存在 —— `--create-missing` 会建它)
|
|
419
|
+
* @param {object} opts
|
|
420
|
+
* @param {string} opts.base 可信 base(symlink 链只查它之下;见 safe-fs 的说明)
|
|
421
|
+
* @param {string[]} opts.targetSet 本次命令的**全部** target(含自己),用于 §3.5 收窄
|
|
422
|
+
* @param {boolean} opts.requireWritable §3.6,默认开
|
|
423
|
+
* @returns {{ok:boolean, targetPath:string, checkedAt:string, violations:Array}}
|
|
424
|
+
*
|
|
425
|
+
* 🔴 返回 `ok: true` **不代表安装一定成功** —— 见文件顶部关于 TOCTOU 的说明。
|
|
426
|
+
*/
|
|
427
|
+
export function precheckTarget(targetPath, opts = {}) {
|
|
428
|
+
const {
|
|
429
|
+
base,
|
|
430
|
+
targetSet = [],
|
|
431
|
+
requireWritable = true,
|
|
432
|
+
scan = {},
|
|
433
|
+
refreshMounts = true,
|
|
434
|
+
} = opts;
|
|
435
|
+
const deps = opts[TEST_DEPS] ?? DEFAULT_DEPS;
|
|
436
|
+
const bounds = normalizeScan(scan); // 🔴 先校验上限,非法值直接抛,不静默变无界
|
|
437
|
+
if (!isAbsolute(targetPath)) throw new Error(`targetPath 必须是绝对路径:${targetPath}`);
|
|
438
|
+
|
|
439
|
+
// 🔴 挂载表在 safe-fs 里是**进程级缓存**的。预检必须拿最新的一份 ——
|
|
440
|
+
// 一个长跑的 CLI 用几分钟前的挂载快照去判「.geoly 是不是挂载点」,
|
|
441
|
+
// 等于没判。(`precheckTargets` 会关掉后续几次的刷新,同一批共用一张表。)
|
|
442
|
+
if (refreshMounts) _resetMountCache();
|
|
443
|
+
|
|
444
|
+
const violations = [];
|
|
445
|
+
const add = (code, path, message, extra) =>
|
|
446
|
+
// 🔴 `detail` 也要冻结:只冻外层挡不住 `violations[i].detail.relation = 'forged'`,
|
|
447
|
+
// 而 `detail` 正是给机器读的那部分,被改了下游就照着假信息做决定。
|
|
448
|
+
violations.push({ code, path, message, ...(extra ? { detail: deepFreeze(extra) } : {}) });
|
|
449
|
+
const capture = (code, path, fn) => {
|
|
450
|
+
try {
|
|
451
|
+
return fn();
|
|
452
|
+
} catch (err) {
|
|
453
|
+
add(code, path, err.message);
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const stateDir = join(targetPath, STATE_DIR);
|
|
459
|
+
const targetExists = existsSync2(targetPath);
|
|
460
|
+
|
|
461
|
+
// ── §2.2 文件系统类型(报出实际 fstype)
|
|
462
|
+
capture(V.UNSUPPORTED_FSTYPE, targetPath, () => deps.assertSupportedFilesystem(targetPath));
|
|
463
|
+
|
|
464
|
+
// ── §3.4 路径链 symlink(从可信 base 往下)
|
|
465
|
+
if (!base) {
|
|
466
|
+
// 🔴 没有可信 base 就**做不了**这一条检查。静默跳过等于让调用方以为查过了 ——
|
|
467
|
+
// 这是最容易被忽略的一种 fail-open:少传一个参数,一整条规则就没了。
|
|
468
|
+
add(V.BASE_MISSING, targetPath, '未提供可信 base,无法检查路径链上的符号链接(§3.4)');
|
|
469
|
+
} else {
|
|
470
|
+
if (!isAbsolute(base)) {
|
|
471
|
+
add(V.OUTSIDE_BASE, targetPath, `base 必须是绝对路径:${base}`);
|
|
472
|
+
} else {
|
|
473
|
+
const rel = relative(base, targetPath);
|
|
474
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
|
|
475
|
+
add(
|
|
476
|
+
V.OUTSIDE_BASE,
|
|
477
|
+
targetPath,
|
|
478
|
+
`target 不在可信 base ${base} 之下,无法只检查我们管辖范围内的 symlink`,
|
|
479
|
+
);
|
|
480
|
+
} else {
|
|
481
|
+
// 🔴 base 不存在时 `assertNoSymlinkInChain` 会在 `realpathSync(base)` 上吃 ENOENT,
|
|
482
|
+
// 把「目录还没建」误报成「路径链上有 symlink」。那条消息会把人带偏,
|
|
483
|
+
// 而且会让 `--create-missing` 这条路彻底走不通。分成一条自己的违规码。
|
|
484
|
+
// ⚠️ 只有 ENOENT/ENOTDIR 算「不存在」。EACCES 是「有但看不了」——
|
|
485
|
+
// 那不是 base-missing,它得原样报出来,否则诊断会把人指向错误的方向。
|
|
486
|
+
let baseErr = null;
|
|
487
|
+
try {
|
|
488
|
+
lstatSync(base);
|
|
489
|
+
} catch (err) {
|
|
490
|
+
baseErr = err;
|
|
491
|
+
}
|
|
492
|
+
if (baseErr && isAbsent(baseErr)) {
|
|
493
|
+
add(V.BASE_MISSING, base, `可信 base ${base} 不存在,无法检查路径链上的符号链接`);
|
|
494
|
+
} else if (baseErr) {
|
|
495
|
+
add(V.SYMLINK_IN_CHAIN, base, `可信 base ${base} 读不了(${baseErr.code}),无法检查路径链上的符号链接`);
|
|
496
|
+
} else {
|
|
497
|
+
capture(V.SYMLINK_IN_CHAIN, targetPath, () => deps.assertNoSymlinkInChain(base, rel));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── §3.4 target 本身的类型
|
|
504
|
+
if (targetExists) {
|
|
505
|
+
const st = capture(V.NOT_PLAIN_DIR, targetPath, () => deps.assertPlainFileOrDir(targetPath));
|
|
506
|
+
if (st && !st.isDirectory()) add(V.NOT_PLAIN_DIR, targetPath, `${targetPath} 不是目录`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── §3.4 `.geoly` 不得是挂载点、其下不得含挂载点
|
|
510
|
+
// 🔴 只在 `.geoly` **已存在**时判:`assertNotMountPoint` 内部会往上找第一个存在的祖先,
|
|
511
|
+
// `.geoly` 不存在时那个祖先就是 target 自己 —— 于是 target 是挂载点会被误报成
|
|
512
|
+
// 「.geoly 是挂载点」。不存在的目录不可能是挂载点,直接跳过才是对的。
|
|
513
|
+
const stateExists = existsSync2(stateDir);
|
|
514
|
+
violations.push(...checkGeolyMountPoints(stateDir, deps));
|
|
515
|
+
if (stateExists) {
|
|
516
|
+
// 🔴 §5.2 第 3 步要求 target **与 state** 都过文件系统预检。
|
|
517
|
+
// 只查 target 会漏掉「.geoly 被单独挂到一个 NFS 上」——
|
|
518
|
+
// 那正是锁与 journal 失效的那种情形。
|
|
519
|
+
capture(V.UNSUPPORTED_FSTYPE, stateDir, () => deps.assertSupportedFilesystem(stateDir));
|
|
520
|
+
|
|
521
|
+
// ── §2.2 同设备:stage/retired/attic 都在 `.geoly` 里,与 target 必须同设备,
|
|
522
|
+
// 否则 rename 会 EXDEV。(v8 布局下天然同设备 —— 除非有人在中间挂了东西,
|
|
523
|
+
// 那正是上面两条要抓的情形;这条是纵深防御。)
|
|
524
|
+
try {
|
|
525
|
+
const dTarget = deps.targetDev(targetPath);
|
|
526
|
+
const dState = deps.stateDev(stateDir);
|
|
527
|
+
if (dTarget !== dState) {
|
|
528
|
+
add(
|
|
529
|
+
V.CROSS_DEVICE,
|
|
530
|
+
stateDir,
|
|
531
|
+
`${stateDir} 与 ${targetPath} 不在同一设备上(${dState} vs ${dTarget}),rename 会 EXDEV`,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
} catch (err) {
|
|
535
|
+
// 🔴 fail-closed:读不到设备号就是**判不了**同设备与否。
|
|
536
|
+
// 吞掉它意味着「其它检查恰好都过」时会给出一份 clean 结果,
|
|
537
|
+
// 而 rename 到底会不会 EXDEV 我们并不知道。
|
|
538
|
+
add(
|
|
539
|
+
V.CROSS_DEVICE,
|
|
540
|
+
stateDir,
|
|
541
|
+
`读不到 ${targetPath} 或 ${stateDir} 的设备号(${err.code ?? err.message}),` +
|
|
542
|
+
'无法判定 stage 与 target 是否同设备(不同设备时 rename 会 EXDEV)',
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ── §3.4 状态路径逐个 lstat 无跟随
|
|
548
|
+
const stateScan = scanStatePaths(stateDir, bounds);
|
|
549
|
+
for (const p of stateScan.symlinks) {
|
|
550
|
+
add(V.STATE_SYMLINK, p, `状态路径是符号链接,拒绝(.geoly 之下一律不跟随):${p}`);
|
|
551
|
+
}
|
|
552
|
+
for (const p of stateScan.notPlain) {
|
|
553
|
+
add(V.STATE_NOT_PLAIN, p, `状态路径既不是普通文件也不是目录,拒绝:${p}`);
|
|
554
|
+
}
|
|
555
|
+
if (!stateScan.complete) {
|
|
556
|
+
add(
|
|
557
|
+
V.STATE_SCAN_INCOMPLETE,
|
|
558
|
+
stateDir,
|
|
559
|
+
`${stateDir} 没扫完(深度上限/目录数上限,或某个子目录读不进去),` +
|
|
560
|
+
'无法证明状态路径里没有符号链接',
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── §3.5 嵌套 target
|
|
565
|
+
const { nested, complete } = findNestedTargets(targetPath, { targetSet, scan: bounds });
|
|
566
|
+
for (const n of nested) {
|
|
567
|
+
add(
|
|
568
|
+
V.NESTED_TARGET,
|
|
569
|
+
n.path,
|
|
570
|
+
n.relation === 'ancestor'
|
|
571
|
+
? `${targetPath} 位于另一个 target ${n.path} 之内(识别依据:${n.via}),拒绝嵌套 target`
|
|
572
|
+
: `${targetPath} 之内还有另一个 target ${n.path}(识别依据:${n.via}),拒绝嵌套 target`,
|
|
573
|
+
{ relation: n.relation, via: n.via },
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
if (!complete) {
|
|
577
|
+
add(
|
|
578
|
+
V.SCAN_INCOMPLETE,
|
|
579
|
+
targetPath,
|
|
580
|
+
`嵌套 target 扫描未跑完(深度上限 ${bounds.maxDepth} / ` +
|
|
581
|
+
`目录数上限 ${bounds.maxDirs}),无法证明其下没有嵌套 target`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── §3.6 只读 target
|
|
586
|
+
if (requireWritable) {
|
|
587
|
+
const probe = targetExists ? targetPath : nearestExistingAncestor(targetPath);
|
|
588
|
+
if (probe) {
|
|
589
|
+
try {
|
|
590
|
+
deps.assertWritableDir(probe);
|
|
591
|
+
} catch (err) {
|
|
592
|
+
// 🔴 §3.6 要求报明「安装需要在 **`<target>`** 内创建 `.geoly/`」——
|
|
593
|
+
// 重点是那个**具体路径**。`assertWritableDir` 只说「在其中」,
|
|
594
|
+
// 而且它的只读挂载分支连这句都没有。所以一律自己补,不看它写了什么。
|
|
595
|
+
add(
|
|
596
|
+
V.NOT_WRITABLE,
|
|
597
|
+
probe,
|
|
598
|
+
`${err.message};安装需要在 ${targetPath} 内创建 .geoly/ 状态目录` +
|
|
599
|
+
(probe === targetPath ? '' : `(先要能写 ${probe})`),
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const clean = deps === DEFAULT_DEPS;
|
|
606
|
+
const result = Object.freeze({
|
|
607
|
+
ok: violations.length === 0,
|
|
608
|
+
targetPath,
|
|
609
|
+
stateDir,
|
|
610
|
+
base: base ?? null,
|
|
611
|
+
checkedAt: new Date().toISOString(),
|
|
612
|
+
// 🔴 提醒调用方:这是快照,不是保证。见文件顶部。
|
|
613
|
+
snapshotOnly: true,
|
|
614
|
+
// 供**人**阅读与断言。放行判据不是它(见 CLEAN_PRECHECKS)——
|
|
615
|
+
// 一个公开的布尔字段随手就能被改回 false。
|
|
616
|
+
depsOverridden: !clean,
|
|
617
|
+
violations: Object.freeze(violations.map((v) => Object.freeze(v))),
|
|
618
|
+
});
|
|
619
|
+
if (clean) CLEAN_PRECHECKS.add(result);
|
|
620
|
+
return result;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** 把预检结果变成一条错误。🔴 报**全部**违规项,不是第一条。 */
|
|
624
|
+
export function assertPrecheckOk(result) {
|
|
625
|
+
// 🔴 判据是模块私有的 WeakSet,不是结果里的字段:
|
|
626
|
+
// 注入过 deps 的结果不在里面,被篡改过(重建)的对象也不在里面。
|
|
627
|
+
// 一个被换成 no-op 的 deps 能让预检全绿而什么都没查过,所以这道边界不能可伪造。
|
|
628
|
+
if (!CLEAN_PRECHECKS.has(result)) {
|
|
629
|
+
throw new Error(
|
|
630
|
+
'这份预检结果不是本模块用真实判据算出来的(注入了 TEST_DEPS,或对象被替换/篡改过),' +
|
|
631
|
+
'不得用来放行安装',
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
if (result.ok) return result;
|
|
635
|
+
const lines = result.violations.map((v, i) => ` ${i + 1}. [${v.code}] ${v.message}`);
|
|
636
|
+
const err = new Error(
|
|
637
|
+
`target 预检不通过(${result.violations.length} 项):${result.targetPath}\n${lines.join('\n')}`,
|
|
638
|
+
);
|
|
639
|
+
// 机器可读:调用方不必去 regex 解析错误文本
|
|
640
|
+
err.violations = result.violations;
|
|
641
|
+
err.targetPath = result.targetPath;
|
|
642
|
+
throw err;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* 一次预检多个 target。
|
|
647
|
+
* 🔴 `targetSet` 自动串起来,这样 §3.5 的第 ① 类(本次命令目标集合内的嵌套)
|
|
648
|
+
* 才判得出来 —— 单独一个个预检是判不到的。
|
|
649
|
+
*/
|
|
650
|
+
export function precheckTargets(targetPaths, optsFor = () => ({})) {
|
|
651
|
+
const set = targetPaths.slice();
|
|
652
|
+
_resetMountCache(); // 整批共用同一张挂载表快照
|
|
653
|
+
// 🔴 强制项放在展开**之后**:`optsFor` 覆盖掉 `targetSet` 就等于关掉了
|
|
654
|
+
// §3.5 第 ① 类的判定,覆盖掉 `refreshMounts` 会让整批用不同的挂载快照。
|
|
655
|
+
return targetPaths.map((t) =>
|
|
656
|
+
precheckTarget(t, { ...optsFor(t), targetSet: set, refreshMounts: false }),
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function nearestExistingAncestor(p) {
|
|
661
|
+
let cur = parse(p).dir;
|
|
662
|
+
for (;;) {
|
|
663
|
+
if (existsSync2(cur)) return cur;
|
|
664
|
+
const up = parse(cur).dir;
|
|
665
|
+
if (!up || up === cur) return null;
|
|
666
|
+
cur = up;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** `existsSync` 会跟随 symlink;这里要的是「这个路径名存在与否」,用 lstat。 */
|
|
671
|
+
function existsSync2(p) {
|
|
672
|
+
try {
|
|
673
|
+
lstatSync(p);
|
|
674
|
+
return true;
|
|
675
|
+
} catch {
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ── 项目级 .gitignore(§3.3 / Q12) ─────────────────────────────────────────
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* 项目级安装必须让 git 忽略 🔴 **adapter 派生的实际路径**
|
|
684
|
+
* (`/.claude/skills/.geoly/`、`/.cursor/skills/.geoly/` …),
|
|
685
|
+
* **不是**根上的 `/.geoly/` —— M0 §3.3 注明 v8 在这里写错过。
|
|
686
|
+
*
|
|
687
|
+
* 只读:报缺哪几条,不写文件。写 `.gitignore` 是安装流程的动作,
|
|
688
|
+
* 由调用方在拿到用户同意后落盘。
|
|
689
|
+
*/
|
|
690
|
+
export function missingGitignorePatterns(projectRoot, clients = CLIENTS) {
|
|
691
|
+
const want = [...new Set(gitignorePatternsFor(clients))]; // 去重,顺序稳定
|
|
692
|
+
let text = '';
|
|
693
|
+
try {
|
|
694
|
+
text = readFileSync(join(projectRoot, '.gitignore'), 'utf8');
|
|
695
|
+
} catch {
|
|
696
|
+
return want;
|
|
697
|
+
}
|
|
698
|
+
// 只看 `<projectRoot>/.gitignore`。父级 gitignore、`.git/info/exclude`、
|
|
699
|
+
// 全局 core.excludesFile 都可能也覆盖,但那些**不随仓库走** ——
|
|
700
|
+
// 换个 clone 就没了,而 `.geoly/` 被提交一次就再也收不回。
|
|
701
|
+
const lines = text
|
|
702
|
+
.split(/\r?\n/)
|
|
703
|
+
.map((l) => l.trim())
|
|
704
|
+
.filter((l) => l && !l.startsWith('#'));
|
|
705
|
+
|
|
706
|
+
const covered = new Set();
|
|
707
|
+
for (const raw of lines) {
|
|
708
|
+
const neg = raw.startsWith('!');
|
|
709
|
+
const p = (neg ? raw.slice(1) : raw).replace(/\/$/, '');
|
|
710
|
+
// 🔴 后面的否定规则会把前面的忽略取消掉,git 的语义是**最后一条匹配的赢**
|
|
711
|
+
if (neg) covered.delete(p);
|
|
712
|
+
else covered.add(p);
|
|
713
|
+
}
|
|
714
|
+
// 只认逐字相同的那一条。不做 glob 语义推导 —— 「看起来能覆盖」不等于覆盖,
|
|
715
|
+
// 猜错的代价是把 `.geoly/`(含本地审计历史)提交进 git。
|
|
716
|
+
return want.filter((p) => !covered.has(p.replace(/\/$/, '')));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** 渲染一段可直接粘进 `.gitignore` 的块,附 `git clean -xfd` 警告。 */
|
|
720
|
+
export function renderGitignoreBlock(clients = CLIENTS) {
|
|
721
|
+
const pats = gitignorePatternsFor(clients);
|
|
722
|
+
return [
|
|
723
|
+
'# geoly skills-hub —— per-target 状态目录(04-install.md §3.3)',
|
|
724
|
+
'# 忽略的是 adapter 派生的实际路径,不是根上的 /.geoly/',
|
|
725
|
+
...pats,
|
|
726
|
+
'',
|
|
727
|
+
].join('\n');
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
export function gitignoreHint(projectRoot, clients = CLIENTS) {
|
|
731
|
+
const missing = missingGitignorePatterns(projectRoot, clients);
|
|
732
|
+
return {
|
|
733
|
+
missing,
|
|
734
|
+
ok: missing.length === 0,
|
|
735
|
+
warning: GIT_CLEAN_WARNING,
|
|
736
|
+
block: renderGitignoreBlock(clients),
|
|
737
|
+
};
|
|
738
|
+
}
|