@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,378 @@
1
+ // `remove <name>` —— 09-cli.md §1:「**减引用;为空才删目录**」。
2
+ //
3
+ // 这条命令的全部难点在那六个字上:一个目录可能被好几个 root 请求
4
+ // (用户直接装的 + 一个 pack 带进来的),`why <name>` 回答的正是这个。
5
+ // 🔴 **只有引用归零才删目录。**
6
+ //
7
+ // 取锁(04-install.md §5.1 的取锁表):
8
+ // `remove` = repo 锁(仅项目级)+ 全部 target 锁,**不取 metadata 锁**。
9
+ // 因此本命令**完全不解析当前快照、不出网** —— 减一条引用不需要知道
10
+ // 「现在最新是哪一版」。项目级 lockfile 重算要的 `asset_sha256` 从
11
+ // **缓存里的历史快照**取(与 `sync-lock` 同一条路径,逐份独立验签)。
12
+ //
13
+ // 🔴 **`<name>` 只接受 entry 目录名**(规范写的就是 `remove <name>`)。
14
+ // 删掉一整条 pack / `all@snapshot` root 是另一种影响面大得多的操作,
15
+ // 规范里没有它的语法 —— 不在这里偷塞一个进去。
16
+ // 后果如实记在交付汇报里:**pack 成员目前删不掉**(它的引用永远不归零)。
17
+
18
+ import { existsSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { planTargets, assertPlanOk } from '../adapters/index.mjs';
21
+ import { precheckTarget, assertPrecheckOk } from '../target.mjs';
22
+ import { layout, readLedger, nextGeneration, ensureGenerationWatermark } from '../ledger.mjs';
23
+ import { derivePlan } from '../plan.mjs';
24
+ import { runTransaction, nowUtc } from '../install.mjs';
25
+ import { recover } from '../recover.mjs';
26
+ import { parseSafeRelPath } from '../safe-fs.mjs';
27
+ import { withOrderedLocks } from './locks.mjs';
28
+ import { annotations, annotationSuffix } from './output.mjs';
29
+ import { UsageError, EXIT, classify } from '../exit-codes.mjs';
30
+ import { getVerifier } from './snapshot-access.mjs';
31
+ import { prewarmLockfileInputs } from './project-lockfile.mjs';
32
+ import {
33
+ assertEntryTreeIntact, assertLedgerGraphUsable, confirmYes, directRootKeyFor,
34
+ graphFingerprint, orphanRootsOf, readTargetLedger,
35
+ } from './refgraph.mjs';
36
+
37
+ /**
38
+ * 一个 target 上「减掉 `name` 的 direct 引用」之后的图。
39
+ *
40
+ * @returns {null | {name, entry, dropRoot, keep:boolean, postEdges:Map, retire:string[],
41
+ * removeRoots:string[], artifacts:Map}}
42
+ * `null` = 这个 target 上没有这条 entry(skipped,算成功)
43
+ */
44
+ export function planRemoval(L, name, target) {
45
+ const entry = L.entries?.[name];
46
+ if (!entry) return null;
47
+
48
+ // 🔴 判据是「root key == `direct:` + entry 的 artifact」,不是「root 的 name 等于目录名」。
49
+ // 只比名字的话,一张已经错了的账本(entry 记 x@1、却挂着请求 x@2 的 direct root)
50
+ // 会在这里被静默「修正」—— 那是替坏账本圆谎。
51
+ const dropRoot = directRootKeyFor(entry);
52
+ const has = (entry.requested_by ?? []).includes(dropRoot);
53
+ if (!has) {
54
+ throw new UsageError(
55
+ `${target}:${name} 上没有可减的**直接**引用 —— 它由下面这些 root 请求:\n`
56
+ + (entry.requested_by ?? []).map((k) => ` ${k}`).join('\n') + '\n'
57
+ + ' 「减引用」减的是**你自己那一条**(`direct:<artifact>`);'
58
+ + 'pack / `all@snapshot` 带进来的成员不由它决定去留。\n'
59
+ + ' 🔴 规范只给了 `remove <name>` 这一种语法(09-cli.md §1),'
60
+ + '没有「删掉整条 pack root」的入口 —— 本 CLI 不自己发明一个。\n'
61
+ + ` 想看清楚谁在要它:\`skills-hub why ${name}\`。`,
62
+ { telemetryReason: 'version-conflict' },
63
+ );
64
+ }
65
+
66
+ // 🔴 direct root 按构造只指向它自己那一个 entry。真有别的 entry 也指着它 ——
67
+ // 那是一张我们不认识的图,删掉这条 root 会造出悬挂边。如实拒绝,不「顺手修」。
68
+ const alsoWanted = Object.entries(L.entries)
69
+ .filter(([n, e]) => n !== name && (e.requested_by ?? []).includes(dropRoot))
70
+ .map(([n]) => n);
71
+ if (alsoWanted.length) {
72
+ const e = new UsageError(
73
+ `${target}:root ${dropRoot} 同时被 ${alsoWanted.join(', ')} 引用 —— `
74
+ + 'direct root 按构造只指向它自己那一个 entry,这张图不自洽。拒绝改它。',
75
+ { telemetryReason: 'ledger-corrupt' },
76
+ );
77
+ e.exitCode = EXIT.INTEGRITY;
78
+ throw e;
79
+ }
80
+
81
+ const remaining = (entry.requested_by ?? []).filter((k) => k !== dropRoot);
82
+ const keep = remaining.length > 0;
83
+
84
+ // 事务后的完整边集(只有这一条 entry 变了)
85
+ const postEdges = new Map();
86
+ const artifacts = new Map();
87
+ for (const [n, e] of Object.entries(L.entries)) {
88
+ if (n === name) continue;
89
+ postEdges.set(n, [...(e.requested_by ?? [])]);
90
+ artifacts.set(n, e.artifact);
91
+ }
92
+ if (keep) { postEdges.set(name, remaining); artifacts.set(name, entry.artifact); }
93
+
94
+ const retire = keep ? [] : [name];
95
+ // 🔴 要删的目录必须**逐字节**还是账本声称的那棵树 —— 只查「在不在」的话,
96
+ // `derivePlan` 会对当前磁盘内容重算 `old_digest`,于是一棵被外部改过的目录
97
+ // 照样被归档删除、命令还返回 0(Codex 2026-09-04 P0)。
98
+ if (!keep) assertEntryTreeIntact(target, name, entry.tree_digest, target);
99
+ const removeRoots = orphanRootsOf(L, postEdges);
100
+ return { artifacts, dropRoot, entry, keep, name, postEdges, removeRoots, retire };
101
+ }
102
+
103
+ export async function cmdRemove(ctx, argv, out) {
104
+ const names = [];
105
+ for (const a of argv) {
106
+ if (a.startsWith('-')) throw new UsageError(`remove 不认得 flag ${a}`);
107
+ names.push(a);
108
+ }
109
+ if (names.length !== 1) {
110
+ throw new UsageError('用法:skills-hub remove <name>(恰好一个目录名)。\n'
111
+ + ' 🔴 `<name>` 是**磁盘上的目录名**(= 制品的 name),不是 spec、也不是 root key。');
112
+ }
113
+ const name = names[0];
114
+ // 目录名必须过路径 grammar —— 与 derivePlan 的 `claim()` 同一道门,早一步报
115
+ try { parseSafeRelPath(name); } catch (e) { throw new UsageError(`remove 的 <name> 不合法:${e.message}`); }
116
+
117
+ // ── 目标解析。🔴 **不传 createMissing**:remove 没有理由建出任何目录 ─────────
118
+ const tplan = planTargets({
119
+ clients: ctx.clients, scope: ctx.scope, home: ctx.home, env: ctx.env, projectRoot: ctx.projectRoot,
120
+ });
121
+ for (const w of tplan.warnings) out.warn(w);
122
+ assertPlanOk(tplan);
123
+ if (ctx.createMissing) {
124
+ out.warn('--create-missing 在 remove 上不生效(没有账本的 target 只会被跳过,不会被创建)。');
125
+ }
126
+ for (const s of tplan.skipped) out.note(`跳过 ${s.client}/${s.scope}:${s.reason} —— ${s.message}`);
127
+ const skippedRows = [...tplan.skipped];
128
+
129
+ // ── 预览(锁外,只读)─────────────────────────────────────────────────────
130
+ const previews = [];
131
+ for (const t of tplan.selected) {
132
+ if (!existsSync(t.target)) {
133
+ skippedRows.push({ client: t.client, scope: t.scope, reason: 'no-target', message: `${t.target} 不存在` });
134
+ continue;
135
+ }
136
+ const L = readTargetLedger(t.target);
137
+ if (L === null) {
138
+ skippedRows.push({ client: t.client, scope: t.scope, reason: 'no-ledger', message: `${t.target} 没有账本` });
139
+ continue;
140
+ }
141
+ assertLedgerGraphUsable(L, `${t.target}/.geoly/ledger.json`);
142
+ const p = planRemoval(L, name, t.target);
143
+ if (p === null) {
144
+ skippedRows.push({ client: t.client, scope: t.scope, reason: 'not-installed', message: `${name} 不在 ${t.target} 的账本里` });
145
+ out.note(`跳过 ${t.client}/${t.scope}:${name} 不在它的账本里`);
146
+ continue;
147
+ }
148
+ previews.push({ t, L, p, fingerprint: fingerprintOf(p) });
149
+ }
150
+
151
+ if (previews.length === 0) {
152
+ out.line(`remove ${name}:没有任何被检查的 target 装着它。`);
153
+ return out.emit('remove', {
154
+ name,
155
+ removed: [],
156
+ skipped: skippedRows.map((s) => ({ client: s.client, reason: s.reason, scope: s.scope })),
157
+ targets: [],
158
+ }, EXIT.OK);
159
+ }
160
+
161
+ // ── 确认:🔴 只有**真的会删目录**时才问;只减一条边不问 ────────────────────
162
+ const willDelete = previews.filter((x) => !x.p.keep);
163
+ const lines = [`remove ${name}:`];
164
+ for (const { t, p } of previews) {
165
+ lines.push(p.keep
166
+ ? ` ${t.client}/${t.scope} ${t.target}\n 只减引用 —— 减掉 ${p.dropRoot} 之后还剩:${p.postEdges.get(name).join(', ')},**目录保留**`
167
+ : ` ${t.client}/${t.scope} ${t.target}\n 引用归零 —— 🔴 **会删掉目录** ${join(t.target, name)}(旧树先进 attic,可 recover --from-generation 复位)`);
168
+ }
169
+ if (willDelete.length > 0) {
170
+ await confirmYes(ctx, out, { lines, question: `确认删除 ${willDelete.length} 个目录?` });
171
+ } else {
172
+ for (const l of lines) out.line(l);
173
+ out.line(' (本次不会删除任何目录,只改账本的引用边)');
174
+ }
175
+
176
+ // ── 项目级 lockfile:🔴 **在提交之前**先把要用到的历史快照读回来并验签 ─────
177
+ // 钩子是在 `runCleanup()` 末尾调的,那时 journal 都清了、recover 无事可做
178
+ // (sync-lock.mjs 里明写着这是 R-11 的已知非原子缺口)。
179
+ // 缓存缺一张历史快照就会在那一刻炸 —— 事务已提交、lockfile 却是旧的。
180
+ // 预热挡不住所有情况(磁盘中途坏掉仍然会),但**能把最常见的一格
181
+ // 「缓存里根本没有那张快照」搬到还没动手的时候**。
182
+ let hook;
183
+ if (ctx.scope === 'project') {
184
+ const verifier = await getVerifier(ctx);
185
+ // remove 只减引用,不引入任何新的 (artifact, snapshot) 组合 —— 预热的并集
186
+ // 全部来自各 target 账本里现有的 entry(prewarm 自己会去收)。
187
+ hook = await prewarmLockfileInputs(ctx, {
188
+ needs: [], ours: previews.map((x) => x.t.target), out, verifier,
189
+ });
190
+ }
191
+
192
+ // ── 取锁:repo → target(全序)──────────────────────────────────────────
193
+ const results = [];
194
+ const t0 = Date.now();
195
+ withOrderedLocks(
196
+ {
197
+ baseFor: (path) => tplan.selected.find((t) => t.target === path)?.base ?? null,
198
+ projectRoot: ctx.scope === 'project' ? ctx.projectRoot : null,
199
+ targets: previews.map((x) => x.t.target),
200
+ },
201
+ ({ targets: ordered }) => {
202
+ const byPath = new Map(previews.map((x) => [x.t.target, x]));
203
+ for (const o of ordered) {
204
+ const pv = byPath.get(o.path);
205
+ const started = Date.now();
206
+ try {
207
+ const r = removeOneTarget(ctx, pv, { name, hook, out });
208
+ results.push({ ...r, client: pv.t.client, ok: true, ms: Date.now() - started, scope: pv.t.scope, target: pv.t.target });
209
+ } catch (err) {
210
+ const cls = classify(err);
211
+ results.push({
212
+ client: pv.t.client, error: err.message, exit_code: cls.code, ms: Date.now() - started,
213
+ ok: false, reason: cls.reason, scope: pv.t.scope, target: pv.t.target, _err: err,
214
+ });
215
+ }
216
+ }
217
+ },
218
+ );
219
+
220
+ emitTelemetry(ctx, results, previews);
221
+
222
+ // ── §7:逐 target 结果表 ─────────────────────────────────────────────────
223
+ out.line(`remove 结果(${name},共 ${results.length} 个 target):`);
224
+ for (const r of results) {
225
+ const a = annotations({ offline: ctx.offline });
226
+ r.annotations = a;
227
+ out.line(` ${r.ok ? 'ok ' : 'failed '}${r.client}/${r.scope} ${r.target}${annotationSuffix(a)}`);
228
+ if (!r.ok) out.line(` ${r.error.split('\n')[0]}`);
229
+ else if (r.deleted) out.line(` 第 ${r.generation} 代,已删除目录 ${name}(旧树在 attic/${r.generation}/)`);
230
+ else out.line(` 第 ${r.generation} 代,只减引用;${name} 仍被 ${r.remaining.join(', ')} 请求,目录保留`);
231
+ }
232
+
233
+ const failed = results.filter((r) => !r.ok);
234
+ let exit = EXIT.OK;
235
+ if (failed.length === results.length && failed.length > 0) exit = classify(failed[0]._err).code;
236
+ else if (failed.length > 0) exit = EXIT.PARTIAL;
237
+
238
+ const body = {
239
+ duration_ms: Date.now() - t0,
240
+ name,
241
+ skipped: skippedRows.map((s) => ({ client: s.client, reason: s.reason, scope: s.scope })),
242
+ targets: results.map((r) => ({
243
+ annotations: r.annotations,
244
+ client: r.client,
245
+ deleted: r.ok ? r.deleted : undefined,
246
+ error: r.ok ? undefined : r.error,
247
+ exit_code: r.ok ? 0 : r.exit_code,
248
+ generation: r.ok ? r.generation : undefined,
249
+ ok: r.ok,
250
+ remaining: r.ok ? r.remaining : undefined,
251
+ removed_roots: r.ok ? r.removedRoots : undefined,
252
+ scope: r.scope,
253
+ target: r.target,
254
+ })),
255
+ };
256
+ if (exit !== EXIT.OK && exit !== EXIT.PARTIAL) {
257
+ return out.emitError('remove', classify(failed[0]._err), failed[0]._err, body);
258
+ }
259
+ return out.emit('remove', body, exit);
260
+ }
261
+
262
+ function fingerprintOf(p) {
263
+ return graphFingerprint({
264
+ artifacts: p.artifacts,
265
+ postEdges: p.postEdges,
266
+ removeRoots: p.removeRoots,
267
+ retire: p.retire,
268
+ writeRoots: {},
269
+ });
270
+ }
271
+
272
+ /** 单个 target 的第 2–10 步。🔴 全同步 —— 它在锁与事务里面,不能 await。 */
273
+ function removeOneTarget(ctx, pv, { name, hook, out }) {
274
+ const target = pv.t.target;
275
+ const P0 = layout(target);
276
+
277
+ // ── 第 2 步:残留事务分流 ────────────────────────────────────────────────
278
+ const rec = recover(target, { mode: 'auto', onLedgerChanged: hook, keepGenerations: ctx.keepGenerations });
279
+ if (rec.outcome !== 'nothing') out.note(`${pv.t.client}:入口分流 —— ${rec.outcome}`);
280
+
281
+ // ── 第 3 步:预检 ───────────────────────────────────────────────────────
282
+ const pre = precheckTarget(target, { base: pv.t.base, targetSet: [target] });
283
+ assertPrecheckOk(pre);
284
+
285
+ // 🔴 **锁内重读并重算**:预览是在没有任何锁的情况下读的,从那时到现在
286
+ // 另一个进程完全可以改掉这张图(`recover` 自己也会)。
287
+ // 比的是**语义指纹**,不是整份 plan —— generation / 时间戳本来就会变。
288
+ const L = readLedger(P0.ledger);
289
+ assertLedgerGraphUsable(L, `${target}/.geoly/ledger.json`);
290
+ const p = planRemoval(L, name, target);
291
+ if (p === null) {
292
+ // 预览时还在、现在没了 —— 别人已经把它删了。这不是错,但也不许假装是我们干的。
293
+ return { deleted: false, generation: L.last_applied_generation, remaining: [], removedRoots: [], noop: true };
294
+ }
295
+ if (fingerprintOf(p) !== pv.fingerprint) {
296
+ throw new UsageError(
297
+ `${target}:账本在确认之后被改动了(引用图与你看到的那份不一致)。什么都没做 —— 请重跑 remove。`,
298
+ { telemetryReason: 'version-conflict' },
299
+ );
300
+ }
301
+
302
+ ensureGenerationWatermark(P0);
303
+ const generation = nextGeneration(P0);
304
+ const at = nowUtc(ctx.now());
305
+
306
+ // 🔴 保留下来的 entry 走 `install` 数组:磁盘树没变 ⇒ `derivePlan` 算出
307
+ // `treeDigest(T) === tree_digest` ⇒ 落到 **`adopt_assertions`(ledger-only)**,
308
+ // 不构造物理 swap(那会撞「禁止 old_digest == new_digest」),也不需要任何字节。
309
+ // 它在写 ledger post 之前会走 `reverifyAssertions` 严格复验 —— 改账本之前
310
+ // 先证明那棵树还是账本声称的那棵。
311
+ const install = p.keep ? [{
312
+ artifact: p.entry.artifact,
313
+ installed_at: p.entry.installed_at,
314
+ name,
315
+ requested_by: p.postEdges.get(name),
316
+ snapshot: p.entry.snapshot,
317
+ srcDir: undefined, // adopt 分支不需要源目录
318
+ tree_digest: p.entry.tree_digest,
319
+ }] : [];
320
+
321
+ const plan = derivePlan({
322
+ generation,
323
+ install,
324
+ ledger: L,
325
+ ledgerExisted: true,
326
+ removeRoots: p.removeRoots,
327
+ replace: new Set(),
328
+ retire: p.retire,
329
+ roots: {},
330
+ target,
331
+ });
332
+
333
+ // 🔴 保留分支必须真的落成逻辑项。如果它变成了物理 `swap`,说明磁盘树与账本
334
+ // 不符(`derivePlan` 会去 stage 一个我们根本没有的源目录),要在动手之前就炸。
335
+ if (p.keep && !plan.adopt_assertions?.[name]) {
336
+ const e = new UsageError(
337
+ `${target}/${name} 与账本记录的树不符:remove 只改引用、不换字节,无法继续。\n`
338
+ + ' 账本与磁盘不符 —— `check` 能把不符之处报全(它只诊断、不修复)。',
339
+ { telemetryReason: 'digest-mismatch' },
340
+ );
341
+ e.exitCode = EXIT.INTEGRITY;
342
+ throw e;
343
+ }
344
+
345
+ // 🔴 `floor: null` 是**显式决定**,不是忘了传:remove 不解析快照、
346
+ // 没有任何 registry 出处,拿「现在的 floor」去卡一次纯本地的减引用讲不通。
347
+ runTransaction(target, plan, {
348
+ floor: null,
349
+ keepGenerations: ctx.keepGenerations,
350
+ now: at,
351
+ onLedgerChanged: hook,
352
+ });
353
+
354
+ return {
355
+ deleted: !p.keep,
356
+ generation,
357
+ remaining: p.keep ? p.postEdges.get(name) : [],
358
+ removedRoots: p.removeRoots,
359
+ };
360
+ }
361
+
362
+ function emitTelemetry(ctx, results, previews) {
363
+ const rec = ctx.record;
364
+ if (!rec) return;
365
+ const byTarget = new Map(previews.map((x) => [x.t.target, x]));
366
+ for (const r of results) {
367
+ const pv = byTarget.get(r.target);
368
+ rec({
369
+ artifact: pv?.p.entry.artifact,
370
+ client: r.client,
371
+ kind: 'remove',
372
+ ms: r.ms,
373
+ reason: r.ok ? undefined : r.reason,
374
+ result: r.ok ? 'ok' : 'failed',
375
+ scope: r.scope,
376
+ });
377
+ }
378
+ }