@geoly-ai/skills-hub 0.3.2 → 0.3.4

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,290 @@
1
+ // M4 的公共零件 —— `update` 与 `remove` 都要在**账本的 root ↔ entry 二部图**上算,
2
+ // 而不是在「本次命令行说了什么」上算。
3
+ //
4
+ // 规格:04-install.md §4(`roots` / `entries[*].requested_by` 的 refcount 语义)、
5
+ // §4.1、§5.1 的取锁表、§8.1(lockfile 是这张图的无损投影)。
6
+ //
7
+ // 🔴 **消费一张图之前先证明它是闭合的。** `readLedger()` 的 `validateLedger` 只查
8
+ // 单条记录的形状,**不查** `requested_by` 指向的 root 存不存在(R-11 第二条,
9
+ // `pack.assertRefGraphClosed` 就是那道补上的门)。不先过这道门,后面所有
10
+ // 「减引用 / 换引用」的计算都是在一张不可信的图上「自洽地」改写 ——
11
+ // 看起来每一步都对,结果是把一条悬挂边变成一条更难发现的悬挂边。
12
+
13
+ import { existsSync, lstatSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { readLedger, layout } from '../ledger.mjs';
16
+ import { parseRootKey, parseArtifactId, assertRefGraphClosed, WireError } from '../pack.mjs';
17
+ import { strictlyMatches } from '../plan.mjs';
18
+ import { stringify } from '../canonical-json.mjs';
19
+ import { UsageError, EXIT } from '../exit-codes.mjs';
20
+
21
+ /** 本模块内的闭合门失败 —— 由 `assertLedgerGraphUsable` 统一改档成 2。 */
22
+ function bad(msg) { throw new WireError('E_LEDGER_LABEL', msg); }
23
+
24
+ /** 读一个 target 的账本;没有就返回 `null`(不是错 —— 那个 target 只是没被管过)。 */
25
+ export function readTargetLedger(target) {
26
+ const P = layout(target);
27
+ if (!existsSync(P.ledger)) return null;
28
+ return readLedger(P.ledger);
29
+ }
30
+
31
+ /**
32
+ * 🔴 消费前的闭合门:每个 root key 过 grammar,每条 `requested_by` 指向的 root 必须存在。
33
+ *
34
+ * 🔴 **在调用点显式改档**,不靠 `classify()` 认 name:`pack.mjs` 抛的是 `WireError`,
35
+ * 而 `WireError` 在 `exit-codes.mjs` 里写死落 **1(解析失败)** —— 那是给
36
+ * 「用户给的字符串不合 grammar」用的。这里的输入不是用户敲的,是**我们自己
37
+ * 写出来的账本**:它不自洽属于「磁盘上的字节对不对」,该落 **2(完整性失败)**。
38
+ * exit-codes.mjs 顶上那条纪律(「凡是这里抛的错其实是另一档,都在调用点显式包装,
39
+ * 绝不对错误文案做正则」)说的就是这一格。
40
+ */
41
+ export function assertLedgerGraphUsable(L, where) {
42
+ try {
43
+ // ① 边闭合 + root key grammar
44
+ for (const key of Object.keys(L.roots ?? {})) parseRootKey(key, `${where}.roots[${key}]`);
45
+ assertRefGraphClosed(L, where);
46
+ // ② 🔴 **顶点标签也要闭合**(Codex 2026-09-04 P2-1)。只查「边指得到人」不够:
47
+ // 一张「边个个闭合、标签却全错」的账本照样能被消费。这与 04-install.md §8.1
48
+ // 对 lockfile 的「双向图闭合(边 + 顶点标签)」是同一条纪律。
49
+ for (const [key, r] of Object.entries(L.roots ?? {})) {
50
+ const rk = parseRootKey(key);
51
+ // 🔴 记录里的 `kind` 必须与 key 的 grammar 推出来的 kind 一致 ——
52
+ // 否则 `remove` / `update` 里所有按 `kind` 分流的判定都会走错分支。
53
+ if (r.kind !== rk.kind) {
54
+ bad(`${where}.roots[${key}]:记录的 kind=${r.kind} 与 key 推出的 ${rk.kind} 不一致`);
55
+ }
56
+ // 🔴 `all@snapshot:<N>` 的 N 就写在 key 里,记录里的 snapshot 必须等于它。
57
+ if (rk.kind === 'all' && r.snapshot !== rk.snapshot) {
58
+ bad(`${where}.roots[${key}]:记录的 snapshot=${r.snapshot} 与 key 里的 ${rk.snapshot} 不一致`);
59
+ }
60
+ if (rk.kind === 'pack' && r.artifact !== key) {
61
+ bad(`${where}.roots[${key}]:pack root 的 key 必须等于它的 artifact,得到 ${r.artifact}`);
62
+ }
63
+ if (rk.kind === 'direct') {
64
+ if (`direct:${r.artifact}` !== key) {
65
+ bad(`${where}.roots[${key}]:direct root 的 key 必须是 "direct:" + artifact,得到 ${r.artifact}`);
66
+ }
67
+ // 🔴 `install` 从不为 pack 建 direct root(pack root 的 key 就是 ArtifactId
68
+ // 本身)。出现了就说明这张图不是我们写的 —— 这是**完整性**问题(2),
69
+ // 不是「两样东西不能共存」(3)。
70
+ if (rk.artifact.kind !== 'skill') {
71
+ bad(`${where}.roots[${key}]:direct root 只能指向 skill,得到 ${rk.artifact.kind}`);
72
+ }
73
+ }
74
+ }
75
+ for (const [name, e] of Object.entries(L.entries ?? {})) {
76
+ const a = parseArtifactId(e.artifact, `${where}.entries[${name}].artifact`);
77
+ if (a.name !== name) {
78
+ bad(`${where}.entries[${name}]:目录名必须等于它 artifact 的 name(${a.name})`);
79
+ }
80
+ }
81
+ } catch (cause) {
82
+ const e = new UsageError(
83
+ `${where} 的引用图不自洽:${cause.message}\n`
84
+ + ' 账本是我们自己写出来的 —— 它不闭合说明这份状态已经被改坏或与本 CLI 的版本不符。\n'
85
+ + ' 🔴 拒绝在一张不可信的图上算「减引用 / 换引用」:那样每一步看起来都对,\n'
86
+ + ' 结果只是把一条悬挂边变成一条更难发现的悬挂边。',
87
+ { telemetryReason: 'ledger-corrupt' },
88
+ );
89
+ e.exitCode = EXIT.INTEGRITY;
90
+ e.cause = cause;
91
+ throw e;
92
+ }
93
+ return L;
94
+ }
95
+
96
+ /**
97
+ * `direct:` root 与 entry 的**精确**对应关系。
98
+ *
99
+ * 🔴 判据是「root key == `direct:` + entry 的 artifact」,**不是**「root 的 name
100
+ * 等于目录名」。后者会把一张已经错了的账本(entry 记着 `x@1`,却挂着请求 `x@2`
101
+ * 的 direct root)在 `remove` 时静默「修正」掉 —— 那是替坏账本圆谎,
102
+ * 而不是如实拒绝。
103
+ */
104
+ export function directRootKeyFor(entry) {
105
+ return `direct:${entry.artifact}`;
106
+ }
107
+
108
+ /**
109
+ * 事务后**没有任何 entry 指向**的 root。
110
+ *
111
+ * 与 `commands/install.mjs` 的 `orphanRootsAfter` 同一条判据(事务后的全景),
112
+ * 但入参是「完整的 post 图」而不是「install 请求 + retire 名单」——
113
+ * `update` 会同时改很多条边,用增量形状描述不了。
114
+ *
115
+ * @param {object} ledger 当前账本(提供 roots 的键集)
116
+ * @param {Map<string,string[]>} postEdges 事务后 name → requested_by
117
+ * @param {string[]} extraRootKeys 本次会**新写入**的 root(它们还不在账本里)
118
+ */
119
+ export function orphanRootsOf(ledger, postEdges, extraRootKeys = []) {
120
+ const live = new Set();
121
+ for (const list of postEdges.values()) for (const k of list) live.add(k);
122
+ const known = new Set([...Object.keys(ledger.roots ?? {}), ...extraRootKeys]);
123
+ return [...known].filter((k) => !live.has(k)).sort();
124
+ }
125
+
126
+ /**
127
+ * 一个 entry 在磁盘上是不是**仍然**是账本声称的那棵树。
128
+ *
129
+ * 🔴 用的是 `strictlyMatches`(摘要 + 结构与元数据),不是只比摘要 ——
130
+ * `geoly-tree-v1` 不覆盖空目录与部分元数据(01-artifacts.md §6.2.1),
131
+ * 只比摘要的话「看起来验过了」而实际没有。这与 `derivePlan` 走 adopt 分支时
132
+ * 以及 `reverifyAssertions` 用的是**同一个函数**,所以两处不会分叉。
133
+ */
134
+ export function entryStillMatches(target, name, digest) {
135
+ const notDir = rootIsNotAPlainDir(target, name);
136
+ if (notDir) return { ok: false, why: notDir };
137
+ return strictlyMatches(join(target, name), digest);
138
+ }
139
+
140
+ /**
141
+ * 🔴 `strictPayloadCheck()` 是从 `readdirSync(dir)` **开始**递归的 —— 它查的是
142
+ * 每一个**子项**是不是 symlink,**没有查那个根自己**(Codex 2026-09-04 复评)。
143
+ * 于是 `target/<name>` 被换成一条指向外部、内容恰好相同的软链时,
144
+ * 「严格验明」仍然会返回成功 —— 而我们接下来要么按它改账本、要么把它退役删掉。
145
+ *
146
+ * ⚠️ **诚实边界**:这是补在 M4 这一侧的门。`plan.strictlyMatches()` 本身仍有这个
147
+ * 缺口,`install` 的 §4.2 adopt 分支照样会走进去 —— 那是既有实现的问题,
148
+ * 不在本轮范围内,如实记进交付汇报,**不假装它被闭合了**。
149
+ */
150
+ function rootIsNotAPlainDir(target, name) {
151
+ const dir = join(target, name);
152
+ let st;
153
+ try { st = lstatSync(dir); } catch (e) {
154
+ if (e?.code === 'ENOENT') return null; // 「不存在」由调用方各自处置
155
+ return `无法 lstat(${e.code})—— 看不见就不能声称它是安全的`;
156
+ }
157
+ if (st.isSymbolicLink()) return ' 它是一条 symlink(不是我们放进去的目录)';
158
+ if (!st.isDirectory()) return '它不是普通目录';
159
+ return null;
160
+ }
161
+
162
+ /**
163
+ * 一个**要被退役(删掉)**的 entry,必须**逐字节**还是账本声称的那棵树。
164
+ *
165
+ * 🔴 **只查「目录在不在」是不够的**(Codex 2026-09-04 P0)。`derivePlan` 的
166
+ * `retire-only` 分支会对**当前磁盘内容**重算 `old_digest` —— 于是一棵被外部
167
+ * 改过的目录照样会被归档然后删除,而命令**返回 0**。
168
+ * ⚠️ 它确实进了 attic(数据不是永久丢失),但:
169
+ * ① 用户的改动在一次「成功」的命令里被无声移走;
170
+ * ② 与 keep 分支的判据自相矛盾 —— 那一边(adopt)是严格复验、不符就退 2。
171
+ * **同一个命令里两条分支用两套判据**,正是「看起来守住了、其实只守住一半」。
172
+ *
173
+ * 判据用 `strictlyMatches`(摘要 + 结构与元数据),与 adopt 分支、
174
+ * `reverifyAssertions` 是**同一个函数** —— 三处不会分叉。
175
+ */
176
+ export function assertEntryTreeIntact(target, name, digest, where) {
177
+ const dir = join(target, name);
178
+ const notDir = rootIsNotAPlainDir(target, name);
179
+ if (notDir) throw integrityError(`${where}:${dir} ${notDir}。🔴 拒绝把它当成我们的制品处置。`);
180
+ if (!existsSync(dir)) {
181
+ throw integrityError(
182
+ `${where}:账本里记着 ${name},但 ${dir} 不存在。\n`
183
+ + ' 账本与磁盘不符 —— 这不是残留事务(没有 journal 可续做),`recover` 对它无事可做。\n'
184
+ + ' `check` 能把不符之处报全(它只诊断、不修复);要恢复那棵树请用\n'
185
+ + ' `recover --from-generation <N>`,或重新 `install` 它。',
186
+ );
187
+ }
188
+ const m = strictlyMatches(dir, digest);
189
+ if (m.ok) return;
190
+ throw integrityError(
191
+ `${where}:${dir} 已经不是账本记录的那棵树(${m.why})。\n`
192
+ + ' 本次操作会**删掉这个目录**,而它现在装着的不是我们放进去的东西 ——\n'
193
+ + ' 🔴 拒绝,不在一次「成功」的命令里无声移走你自己的改动。\n'
194
+ + ' 出路:把改动挪走(或提交到别处)后重跑;`check` 能把不符之处报全。',
195
+ );
196
+ }
197
+
198
+ function integrityError(message) {
199
+ const e = new UsageError(message, { telemetryReason: 'digest-mismatch' });
200
+ e.exitCode = EXIT.INTEGRITY;
201
+ return e;
202
+ }
203
+
204
+ /**
205
+ * 「确认之后、取锁之前」这段窗口的**语义指纹**。
206
+ *
207
+ * 🔴 不能拿整份 plan 去比:`generation` / `installed_at` / stage 路径本来就会变
208
+ * (Codex 2026-09-04)。要比的是**语义**:事务后的图长什么样、哪些要退役、
209
+ * 哪些 root 要写、哪些要删。指纹一致就说明「用户看到并同意的那件事」没有变。
210
+ */
211
+ export function graphFingerprint({ postEdges, artifacts, retire, writeRoots, removeRoots }) {
212
+ return stringify({
213
+ artifacts: Object.fromEntries([...artifacts.entries()].sort(([a], [b]) => (a < b ? -1 : 1))),
214
+ edges: Object.fromEntries([...postEdges.entries()]
215
+ .sort(([a], [b]) => (a < b ? -1 : 1))
216
+ .map(([k, v]) => [k, [...v].sort()])),
217
+ remove_roots: [...removeRoots].sort(),
218
+ retire: [...retire].sort(),
219
+ write_roots: Object.fromEntries([...Object.entries(writeRoots)]
220
+ .sort(([a], [b]) => (a < b ? -1 : 1))
221
+ // 🔴 `intent` **必须进指纹**(Codex 2026-09-04 P1-1)。少了它,
222
+ // 「同一条 root、同一个制品、同一份成员图,只有 no_bundled / pre /
223
+ // allow_yanked 变了」这一格会指纹相等 —— 于是并发改掉的 intent
224
+ // 会被旧计划覆盖,而账本记的是本机历史、历史必须是真的。
225
+ .map(([k, r]) => [k, {
226
+ artifact: r.artifact ?? null,
227
+ intent: r.intent ?? null,
228
+ kind: r.kind,
229
+ snapshot: r.snapshot,
230
+ }])),
231
+ });
232
+ }
233
+
234
+ /**
235
+ * 交互确认(**可跳过的**那一类,`--yes` 足够)。
236
+ *
237
+ * 🔴 与 09-cli.md §3 的全量确认**不是同一件事**:那一条要求输入数量数字、
238
+ * 且非交互下只认 `--yes-i-really-want-everything`。这里是普通的破坏性确认。
239
+ */
240
+ export async function confirmYes(ctx, out, { lines, question }) {
241
+ for (const l of lines) out.line(l);
242
+ if (ctx.yes) { out.note('--yes:跳过确认'); return; }
243
+ const tty = ctx.stdin?.isTTY === true;
244
+ if (!tty) {
245
+ throw new UsageError(
246
+ `${question}\n 非交互下必须显式给 --yes(09-cli.md §2:跳过可跳过的确认)。什么都没做。`,
247
+ { telemetryReason: 'user-abort' },
248
+ );
249
+ }
250
+ out.line(`${question} 输入 y 确认(回车不算确认):`);
251
+ const answer = (await readLine(ctx.stdin)).trim();
252
+ if (answer !== 'y' && answer !== 'Y') {
253
+ throw new UsageError(`未确认(得到 ${JSON.stringify(answer)})。什么都没做。`,
254
+ { telemetryReason: 'user-abort' });
255
+ }
256
+ }
257
+
258
+ /**
259
+ * 读一行。🔴 **先看它还能不能读** —— 已 end / 已 destroy 的 stdin 会让 Promise
260
+ * 永远 pending,命令挂死且没有任何输出(与 `commands/install.mjs` 的 `readLine`
261
+ * 是同一条教训、同一份判据)。
262
+ */
263
+ function readLine(stdin) {
264
+ return new Promise((resolve, reject) => {
265
+ if (stdin === null || stdin === undefined) { resolve(''); return; }
266
+ if (stdin.readableEnded === true || stdin.destroyed === true) { resolve(''); return; }
267
+ let buf = '';
268
+ const cleanup = () => {
269
+ stdin.removeListener('data', onData);
270
+ stdin.removeListener('end', onEnd);
271
+ stdin.removeListener('close', onEnd);
272
+ stdin.removeListener('error', onErr);
273
+ stdin.pause?.();
274
+ };
275
+ const onData = (chunk) => {
276
+ buf += chunk.toString('utf8');
277
+ const nl = buf.indexOf('\n');
278
+ if (nl === -1) return;
279
+ cleanup();
280
+ resolve(buf.slice(0, nl));
281
+ };
282
+ const onEnd = () => { cleanup(); resolve(buf); };
283
+ const onErr = (e) => { cleanup(); reject(e); };
284
+ stdin.on('data', onData);
285
+ stdin.on('end', onEnd);
286
+ stdin.on('close', onEnd);
287
+ stdin.on('error', onErr);
288
+ stdin.resume?.();
289
+ });
290
+ }
@@ -1,16 +1,17 @@
1
1
  // registry 读取层 —— 02-registry.md §6 的**取字节**那一半。
2
2
  //
3
- // 🔴 **M1 只有缓存适配器,没有网络客户端。** 两个理由,都不是「还没写」:
3
+ // 🔴 **本模块一个字节都不出网** —— 这是设计,不是缺口(0.3.0 起已有网络层)。
4
4
  //
5
- // `snapshot.resolveCurrent()` 是**同步**函数,它要求 `fetchTimestamp()` /
6
- // `fetchSnapshot(N)` 同步返回 `{ bytes, bundle }`。内建 `fetch` 返回 Promise,
7
- // 接不进去。把内核改成 async 是**内核 API 变更**,不在本块的文件边界内。
8
- // ② 真实 registry 端点在 M1 还不存在。造一个假的出网路径,只会让
9
- // 「`--offline` 有没有被绕过」这个问题**看起来**被回答了。
5
+ // `snapshot.resolveCurrent()` 是**同步**函数,它要求 `fetchTimestamp()` /
6
+ // `fetchSnapshot(N)` 同步返回 `{ bytes, bundle }`。内建 `fetch` 返回 Promise,
7
+ // 接不进去。所以出网被整个挪到了它**之前**:`src/preheat.mjs` 下载到 staging,
8
+ // 验签通过后原子提升进缓存,然后这一层照旧只读缓存。
10
9
  //
11
- // 因此:**本模块一个字节都不出网**,`--offline` 与否走的是同一条码。
12
- // 缓存未命中就是退出码 6,文案里如实说明是「M1 没有网络客户端」还是「离线未命中」。
13
- // 这条缺口写进交付汇报。
10
+ // 于是 `--offline` 与否走的是**同一条码** —— 不存在「另一条不出网的实现」,
11
+ // 也就不会有「离线路径其实偷偷出网了」这种事。
12
+ //
13
+ // 📌 2026-09-03 之前这里确实没有网络层,那时的文案说的是「M1 没有网络客户端」。
14
+ // 现在有了;缓存未命中意味着 preheat 没取回来(通常是被降级成了「用缓存继续」)。
14
15
  //
15
16
  // 缓存布局(内容寻址,摘要即身份):
16
17
  // <cacheDir>/timestamp.json ← **单资产信封**(正文 + bundle 合一)
@@ -52,8 +53,8 @@ function miss(what, path, offline) {
52
53
  ? `${what} 未命中缓存(--offline):${path}\n`
53
54
  + ' 离线模式只用缓存;先在联网状态下取一次,或去掉 --offline。'
54
55
  : `${what} 未命中缓存:${path}\n`
55
- + ' ⚠️ M1 CLI **没有网络客户端**(见 src/commands/registry.mjs 顶部注释)——\n'
56
- + ' 这一条不是「网络失败」,是「本地缓存里没有,而本版本还取不了」。',
56
+ + ' ⚠️ 出网发生在 preheat(install 之前),本层只读缓存。走到这里说明\n'
57
+ + ' preheat 没有把它取回来 —— 通常是上游报错被降级成了「用本地缓存继续」。',
57
58
  { telemetryReason: offline ? 'offline' : 'not-found' },
58
59
  );
59
60
  }