@geoly-ai/skills-hub 0.3.3 → 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.
- package/README.md +103 -27
- package/package.json +1 -1
- package/src/cli.mjs +4 -0
- package/src/commands/install.mjs +1 -1
- package/src/commands/project-lockfile.mjs +116 -0
- package/src/commands/refgraph.mjs +290 -0
- package/src/commands/registry.mjs +12 -11
- package/src/commands/remove.mjs +378 -0
- package/src/commands/update.mjs +835 -0
- package/src/download.mjs +15 -2
package/README.md
CHANGED
|
@@ -12,6 +12,21 @@
|
|
|
12
12
|
|---|---|
|
|
13
13
|
| registry 浏览站 | **https://skills-hub-pearl.vercel.app/** |
|
|
14
14
|
| 埋点摄入端 | `https://skills-hub-telemetry.vercel.app/v1/events` |
|
|
15
|
+
| **分发(客户端真正取字节的地方)** | GitHub Releases —— 见下 |
|
|
16
|
+
|
|
17
|
+
分发地址**不配置、不发现**,由客户端从**已验签**的对象推导
|
|
18
|
+
(locator 契约见 [`docs/m0/02-registry.md`](docs/m0/02-registry.md) §4.0):
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
/releases/download/timestamp/timestamp.json ← 新鲜度锚点(滚动,每 3 天刷新)
|
|
22
|
+
/releases/download/hub-v<N>/hub-<N>.json[.sigstore.json]
|
|
23
|
+
/releases/download/hub-v<N>/<asset.file> ← 制品资产
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
🔴 **`hub-v<N>` 与 CLI 的 `v<x.y.z>` 是两个 Release,刻意分开。**
|
|
27
|
+
合成一个的话,「快照号 N → 哪个 CLI 版本」这个映射就**不在任何签名对象里**,
|
|
28
|
+
客户端拿着验过签的快照也推不出去哪儿下载 —— 那正是 0.2.0
|
|
29
|
+
「已发布但没人能装」的根因。
|
|
15
30
|
|
|
16
31
|
🔴 **不是 `skills-hub.vercel.app`** —— `.vercel.app` 子域名全局唯一,项目名撞车时
|
|
17
32
|
Vercel 会自动追加一个随机词(这就是 `-pearl` 的来历)。那个裸域名**不属于本项目**,
|
|
@@ -24,17 +39,35 @@ Vercel 会自动追加一个随机词(这就是 `-pearl` 的来历)。那个
|
|
|
24
39
|
|
|
25
40
|
## 安装
|
|
26
41
|
|
|
42
|
+
不用先装 CLI —— `npx` 直接跑:
|
|
43
|
+
|
|
27
44
|
```sh
|
|
28
|
-
|
|
29
|
-
skills-hub --
|
|
45
|
+
# 装一个 skill
|
|
46
|
+
npx @geoly-ai/skills-hub install skill:geoly-ai/skills-hub-install --clients claude
|
|
47
|
+
|
|
48
|
+
# 装一整套矩阵(pack 是一个制品,成员一次装完)
|
|
49
|
+
npx @geoly-ai/skills-hub install pack:prompts-map/prompt-map --clients claude
|
|
50
|
+
|
|
51
|
+
# 装全部可装的(要 --yes-i-really-want-everything,--yes 不够)
|
|
52
|
+
npx @geoly-ai/skills-hub install --all --clients claude --yes-i-really-want-everything
|
|
30
53
|
```
|
|
31
54
|
|
|
32
|
-
|
|
33
|
-
|
|
55
|
+
首次装到某个 client 时目录可能还不存在,加 `--create-missing claude`。
|
|
56
|
+
装过一次之后 `--offline` 可用(资产按 sha256 内容寻址缓存在
|
|
57
|
+
`~/.cache/geoly-skills`)。
|
|
58
|
+
|
|
59
|
+
想常驻就装全局:`npm i -g @geoly-ai/skills-hub`。
|
|
60
|
+
|
|
61
|
+
**已发布**:[`@geoly-ai/skills-hub@0.3.3`](https://www.npmjs.com/package/@geoly-ai/skills-hub)
|
|
62
|
+
(带 npm provenance;发布 workflow 会用**本包自带的验签器 + 内置信任根**
|
|
34
63
|
自验一遍它自己签的 tarball)。
|
|
35
64
|
|
|
36
65
|
平台:**macOS / Linux / WSL**,**Node ≥ 22.13**。
|
|
37
66
|
|
|
67
|
+
> ⚠️ **在企业代理后面需要 Node ≥ 24。** Node 的内建 fetch 直到 24 才支持
|
|
68
|
+
> `HTTPS_PROXY` / `NO_PROXY`(CLI 会自动启用它)。22.x 用户可以先在能直连的
|
|
69
|
+
> 网络里跑一次把缓存热起来,之后 `--offline` 可用。这是**已知缺口**。
|
|
70
|
+
|
|
38
71
|
### 当前能装到哪几端
|
|
39
72
|
|
|
40
73
|
| client | 全局 | 项目级 | 说明 |
|
|
@@ -52,36 +85,65 @@ skills-hub --help
|
|
|
52
85
|
| 阶段 | 状态 |
|
|
53
86
|
|---|---|
|
|
54
87
|
| **M0 · 制品与信任模型** | ✅ 已通过(v45,2026-08-25) |
|
|
55
|
-
| **M1 · 只读分发** | ✅
|
|
56
|
-
| **M2 · pack 与受控 catalog** |
|
|
57
|
-
|
|
|
58
|
-
|
|
|
88
|
+
| **M1 · 只读分发** | ✅ 已完成(0.1.0 首发)—— resolve / install / recover / check / list-search-why / sync-lock |
|
|
89
|
+
| **M2 · pack 与受控 catalog** | ✅ 命令面与 promotion 的派生均已就绪;元数据来源待 M3 |
|
|
90
|
+
| **分发真的通了** | ✅ **0.3.3** —— registry 上线 23 个制品 / 3 张快照,单个 skill、整套 `pack:`、`--offline` 三条路径在干净环境实测通过 |
|
|
91
|
+
| M3 · 投稿与审核 | 🚧 元数据(`owner` / `review` / `provenance`)仍靠 promotion 的显式 `--inputs` |
|
|
92
|
+
| M4 · update / remove | ✅ 命令面已实现 —— 边界逐条列在下面「明确没做到的」里 |
|
|
59
93
|
|
|
60
|
-
|
|
61
|
-
|
|
94
|
+
> 📌 **「发布了」不等于「能装」。** 0.1.0 与 0.2.0 都能发布、能验签、能浏览 registry,
|
|
95
|
+
> 但**任何一次 `install` 都取不到字节**:客户端推不出下载地址、CLI 没有网络层、
|
|
96
|
+
> 服务端一个 Release 都没有。这三件事到 0.3.3 才全部闭合,
|
|
97
|
+
> 判据是**从 npm 装下来的那个包在干净 home 上真的装成了**,不是测试绿。
|
|
62
98
|
|
|
63
|
-
|
|
99
|
+
**1386 个测试**在 Node 22.13.0 / 24.19.0 双版本全绿;穷举崩溃注入(真内核 **72** 个注入点逐个反向命中,
|
|
100
|
+
数目取自 `test/harness/fault-points.mjs` 的 CATALOG)是 CI 的合并门。
|
|
101
|
+
|
|
102
|
+
### 🔴 现在明确**没有**做到的(截至 0.3.3)
|
|
64
103
|
|
|
65
104
|
不写清楚就等于默认承诺了,所以逐条列出:
|
|
66
105
|
|
|
67
|
-
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
`
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
106
|
+
- **投稿流水线还没接上**:record 必填的 `owner` / `review`(以及 pack 的
|
|
107
|
+
`provenance`)目前由 promotion 的显式 `--inputs` 提供,不是自动产出的。
|
|
108
|
+
- **Node 22.x 在代理后面装不了**(见上面的安装说明)。
|
|
109
|
+
- **`--from-generation` 只做到编译计划**,接成正向事务的入口还没写
|
|
110
|
+
(M4 的 `update` / `remove` **没有**顺手把它接上 —— 两条命令都只用现成的
|
|
111
|
+
`runTransaction`,不新增 journal op、不新增故障注入点)。
|
|
112
|
+
- **`remove` 只减「你自己那一条 direct 引用」**:`remove <name>` 减掉的是
|
|
113
|
+
`direct:<该 entry 的 artifact>` 那条边。一个**只被 pack / `all@snapshot` 请求**的
|
|
114
|
+
成员因此删不掉(它的引用永远不归零)—— 规范只给了 `remove <name>` 这一种语法,
|
|
115
|
+
没有「删掉整条 pack root」的入口,CLI **不自己发明一个**。
|
|
116
|
+
出路是 `update pack:<name>`(新版本不再含它就会被退役)。
|
|
117
|
+
- **`update` 不接受 `--snapshot`**:钉快照能决定「解析到哪个版本」,
|
|
118
|
+
但回答不了「现在还该不该用」(那必须查当前快照)。两者怎么组合规范没写。
|
|
119
|
+
- **`update` 的冲突没有 `--replace` 出路**:在一次升级里顺手删掉你先前装过的东西
|
|
120
|
+
不是你表达过的意思。
|
|
121
|
+
- **项目级 lockfile 的重算仍不是原子的**(R-11 第四条)。M4 把两格提前到**动手之前**
|
|
122
|
+
就失败(缓存里没有要用到的历史快照;任一在册项目 target 的引用图不闭合),
|
|
123
|
+
但**没有做完整的 dry-run**(没有真的用 post-state 复算一次 `projectLockfile()`),
|
|
124
|
+
磁盘在预热与收尾之间坏掉也仍会落回那个缺口 ——
|
|
125
|
+
兜底照旧是 `check` 报「lockfile 过时」+ `sync-lock`。
|
|
126
|
+
🔴 另外,**`--clients` 会同时收窄「投影哪些 target」与「预热哪些 target」**:
|
|
127
|
+
两者内部一致(不会出现「预热漏了、重算却要」),但显式 `--clients` 时
|
|
128
|
+
未点名的项目 target **不进 lockfile** —— 这是 `recalcLockfile()` 既有的性质
|
|
129
|
+
(`install` / `sync-lock` 同样如此),不是 M4 引入的,本轮也没有改它。
|
|
130
|
+
- **`plan.strictlyMatches()` 不查被验目录**自己**是不是 symlink**(它从
|
|
131
|
+
`readdirSync(dir)` 开始递归,查的是子项)。`update` / `remove` 在自己这一侧
|
|
132
|
+
补了这道门(`refgraph.entryStillMatches` / `assertEntryTreeIntact`),
|
|
133
|
+
但 `install` 的 §4.2 adopt 分支仍会走进去 —— **既有缺口,本轮未修**。
|
|
134
|
+
- **有 lockfile 时 `install` 仍不「只按 lockfile 装」**(04-install.md §8 的那一条):
|
|
135
|
+
lockfile 目前只被写出与被 `check` 比对,还没有当成 `install` 的权威输入。
|
|
136
|
+
- **`replaces` 与 `--freeze-attic` 在 `update` / `remove` 上同样没实现** ——
|
|
137
|
+
它们在 `install` 上本来就没实现,M4 没有扩大范围。
|
|
76
138
|
- **`--release-frozen` 如实拒绝**(没有按 label 解冻 attic 的导出),不提供假装成功的路径。
|
|
77
139
|
- `cursor` 未验证;`search` 搜不了 description(快照 record 里没有这个字段)。
|
|
78
140
|
|
|
79
141
|
已知且**明确接受**的残余风险见 [`docs/m1/01-residual-risks.md`](docs/m1/01-residual-risks.md)(R-1 … R-11)
|
|
80
|
-
与 [`docs/m2/01-residual-risks.md`](docs/m2/01-residual-risks.md)(R-12 … R-
|
|
142
|
+
与 [`docs/m2/01-residual-risks.md`](docs/m2/01-residual-risks.md)(R-12 … R-21),
|
|
81
143
|
M0 正文的勘误见 [`docs/m0/ERRATA.md`](docs/m0/ERRATA.md)(E-1 … E-8)。
|
|
82
144
|
|
|
83
|
-
M2
|
|
84
|
-
[`docs/m2/00-delivery.md`](docs/m2/00-delivery.md)
|
|
145
|
+
M2 交出了什么、**明确没做到什么**,见
|
|
146
|
+
[`docs/m2/00-delivery.md`](docs/m2/00-delivery.md)(当时的三条待拍板项现已全部闭合)。
|
|
85
147
|
|
|
86
148
|
## 从哪读起
|
|
87
149
|
|
|
@@ -94,12 +156,26 @@ M2 交出了什么、**明确没做到什么**、以及三条待拍板项,见
|
|
|
94
156
|
|
|
95
157
|
## 已经能跑的
|
|
96
158
|
|
|
159
|
+
装好之后(安装见上):
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
skills-hub install <spec> --clients claude # 装
|
|
163
|
+
skills-hub update [<spec>…] | --all # 重解析 root:展示 diff、确认后应用
|
|
164
|
+
skills-hub remove <name> # 减引用;🔴 引用归零才删目录
|
|
165
|
+
skills-hub list --installed # 看装了什么
|
|
166
|
+
skills-hub check # 字节对不对 + 现在还该不该用
|
|
167
|
+
skills-hub why <name> # 这东西是谁请求装的
|
|
168
|
+
skills-hub recover # 装到一半崩了之后收拾现场
|
|
169
|
+
skills-hub stats # 本地埋点文本报表
|
|
170
|
+
skills-hub telemetry status # 埋点/上报开关
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
从源码开发:
|
|
174
|
+
|
|
97
175
|
```sh
|
|
98
176
|
node bin/skills-hub.mjs --help
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
npm test # 60 个测试
|
|
102
|
-
npm run test:matrix # 在 Node 22.13 / 24.19 上各跑一遍
|
|
177
|
+
npm test # 1386 个测试
|
|
178
|
+
npm run test:matrix # 在 Node 22.13 / 24.19 上各跑一遍
|
|
103
179
|
```
|
|
104
180
|
|
|
105
181
|
基础模块:`canonical-json`、`atomic-fs`、`safe-fs`、`tree-digest`/`tx-digest`、
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -27,6 +27,8 @@ const HELP = `skills-hub —— geoly skill 分发(M1 + M2 的命令面)
|
|
|
27
27
|
[--reset-generation N] [--resume-cleanup]
|
|
28
28
|
vendor <pack-spec> --out <dir> [--layout flat]
|
|
29
29
|
把 pack 与全部成员物化成一棵目录树(不走安装账本)
|
|
30
|
+
update [<spec>…] | --all 重解析账本里的 root:展示 diff、确认后在一个事务里应用
|
|
31
|
+
remove <name> 减引用;🔴 **引用归零才删目录**(why <name> 看谁在要它)
|
|
30
32
|
|
|
31
33
|
stats [--json] [--export <file>] 本地埋点报表
|
|
32
34
|
telemetry <status|flush> 埋点/上报开关与队列
|
|
@@ -71,6 +73,8 @@ const COMMANDS = {
|
|
|
71
73
|
check: () => import('./commands/check.mjs').then((m) => m.cmdCheck),
|
|
72
74
|
'sync-lock': () => import('./commands/sync-lock.mjs').then((m) => m.cmdSyncLock),
|
|
73
75
|
recover: () => import('./commands/recover.mjs').then((m) => m.cmdRecover),
|
|
76
|
+
remove: () => import('./commands/remove.mjs').then((m) => m.cmdRemove),
|
|
77
|
+
update: () => import('./commands/update.mjs').then((m) => m.cmdUpdate),
|
|
74
78
|
vendor: () => import('./commands/vendor.mjs').then((m) => m.cmdVendor),
|
|
75
79
|
};
|
|
76
80
|
|
package/src/commands/install.mjs
CHANGED
|
@@ -42,7 +42,7 @@ import { makeLockfileHook } from './sync-lock.mjs';
|
|
|
42
42
|
*
|
|
43
43
|
* 多个制品要**嵌套**:任意一层抛错,它自己和外层的隔离目录都会被清掉。
|
|
44
44
|
*/
|
|
45
|
-
function withVerifiedArtifacts(items, parent, fn, acc = []) {
|
|
45
|
+
export function withVerifiedArtifacts(items, parent, fn, acc = []) {
|
|
46
46
|
if (items.length === 0) return fn(acc);
|
|
47
47
|
const [head, ...tail] = items;
|
|
48
48
|
return withVerifiedArtifact({ bytes: head.bytes, record: head.record, parent }, (art) =>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// 项目级 lockfile 的**预热 + 钩子** —— M4(`update` / `remove`)专用的接线。
|
|
2
|
+
//
|
|
3
|
+
// 背景(R-11 第四条,sync-lock.mjs 里已经如实写着):`onLedgerChanged` 钩子是在
|
|
4
|
+
// `runCleanup()` 的**最后**才调的 —— 那时 tx 与 journal 都清掉了,账本已经提交。
|
|
5
|
+
// 钩子这时抛错的话,事务已经生效、项目 lockfile 却还是旧的,而 `recover` 已经
|
|
6
|
+
// 没有 journal 可重试。🔴 **这是已知缺口,不是被闭合了。**
|
|
7
|
+
//
|
|
8
|
+
// 钩子最容易抛的那一格是**可以提前挡掉的**:重算需要每个 entry 的 `asset_sha256`,
|
|
9
|
+
// 而账本里没有这个字段,只能回**历史快照**取(sync-lock.mjs 的 `assetSha256For`)。
|
|
10
|
+
// 缓存里少一张快照 → 取不到 → 抛。这一格与「事务本身」毫无关系,
|
|
11
|
+
// 完全可以在**还没动手**的时候就发现。
|
|
12
|
+
//
|
|
13
|
+
// 所以本模块做两件事:
|
|
14
|
+
// ① `prewarmLockfileInputs()`:在取锁与提交**之前**,把 post-state 会用到的
|
|
15
|
+
// 每一张历史快照都取回来、**逐份独立验签**、并确认目标 record 真的在里面;
|
|
16
|
+
// ② 返回一个共用**同一份 memo** 的 `onLedgerChanged` 钩子,于是收尾那一刻
|
|
17
|
+
// 不再需要任何新的 I/O 决策。
|
|
18
|
+
//
|
|
19
|
+
// ⚠️ **诚实边界**:这把「缓存里根本没有那张快照」搬到了免费的时刻,
|
|
20
|
+
// 但**不是**原子性修复。磁盘在两者之间坏掉、别的进程清了缓存,仍然会落回
|
|
21
|
+
// 那个已知缺口(兜底仍是 `check` 报「lockfile 过时」+ 用户跑 `sync-lock`)。
|
|
22
|
+
|
|
23
|
+
import { existsSync } from 'node:fs';
|
|
24
|
+
import { planTargets, assertPlanOk } from '../adapters/index.mjs';
|
|
25
|
+
import { layout, readLedger } from '../ledger.mjs';
|
|
26
|
+
import { historicalReader, isDegradable } from './snapshot-access.mjs';
|
|
27
|
+
import { recalcLockfile, assertNoUnrecoveredTx } from './sync-lock.mjs';
|
|
28
|
+
import { assertLedgerGraphUsable } from './refgraph.mjs';
|
|
29
|
+
import { NetworkError } from '../exit-codes.mjs';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} o
|
|
33
|
+
* @param {Function} o.verifier
|
|
34
|
+
* @param {object|null} [o.current] 当前快照(有就先在它里面找,省一次历史读取)
|
|
35
|
+
* @param {Array<{artifact:string, snapshot:number}>} [o.needs]
|
|
36
|
+
* 本次事务**写入之后**会存在的 entry。与「账本里现有的」取并集。
|
|
37
|
+
* @param {string[]} [o.ours]
|
|
38
|
+
* 本次命令**会自己处理(含 `recover(auto)` 续做)**的 target。
|
|
39
|
+
* 它们的未完成事务不在这里查 —— 那会把一次正常运行拒掉。
|
|
40
|
+
* @returns {Function|undefined} `onLedgerChanged` 钩子;非项目级时 `undefined`
|
|
41
|
+
*/
|
|
42
|
+
export async function prewarmLockfileInputs(
|
|
43
|
+
ctx, { verifier, current = null, needs = [], ours = [], out } = {},
|
|
44
|
+
) {
|
|
45
|
+
if (ctx.scope !== 'project') return undefined;
|
|
46
|
+
const readHistorical = historicalReader(ctx, verifier);
|
|
47
|
+
|
|
48
|
+
// ① 并集:本次要写入的 + **全部**项目级 target 账本里现有的。
|
|
49
|
+
// 🔴 后者不能省:`recalcLockfile()` 投影的是所有项目级 target,
|
|
50
|
+
// 不是只有我们这次动过的那几个 —— 只预热自己那份,别人那份照样会在收尾时炸。
|
|
51
|
+
const pairs = new Map();
|
|
52
|
+
for (const n of needs) pairs.set(`${n.artifact}\u0000${n.snapshot}`, n);
|
|
53
|
+
const tplan = planTargets({
|
|
54
|
+
clients: ctx.clients, scope: 'project', home: ctx.home, env: ctx.env, projectRoot: ctx.projectRoot,
|
|
55
|
+
});
|
|
56
|
+
assertPlanOk(tplan);
|
|
57
|
+
for (const t of tplan.selected) {
|
|
58
|
+
const P = layout(t.target);
|
|
59
|
+
if (!existsSync(P.ledger)) continue;
|
|
60
|
+
// 🔴 **本次不会去恢复的那些 target,未完成事务也要现在就查**
|
|
61
|
+
// (Codex 2026-09-04 复评 P1-1)。`recalcLockfile()` 对它们会调
|
|
62
|
+
// `assertNoUnrecoveredTx()`;而那发生在收尾钩子里 —— 那时**我们自己**这个
|
|
63
|
+
// target 已经提交、journal 已经清掉,lockfile 却停在旧版本。
|
|
64
|
+
// ⚠️ 只查「不是我们要处理的」那些:我们自己的 target 上,
|
|
65
|
+
// `cleanup_pending` 这类残留会被入口的 `recover(auto)` 正常续做完,
|
|
66
|
+
// 在这里查会把一次完全正常的运行拒掉。
|
|
67
|
+
if (!ours.includes(t.target)) assertNoUnrecoveredTx(t.target);
|
|
68
|
+
const other = readLedger(P.ledger);
|
|
69
|
+
// 🔴 **别的项目 target 的账本也要过闭合门**(Codex 2026-09-04 P1-2):
|
|
70
|
+
// `recalcLockfile()` 投影的是**全部**项目级 target。别人那份有一条悬挂
|
|
71
|
+
// `requested_by` 时,本次事务会照常提交、journal 照常清掉,然后收尾的
|
|
72
|
+
// 钩子才失败 —— lockfile 停在旧版本且没有 journal 可重试。
|
|
73
|
+
// ⚠️ **诚实边界**:这仍不是完整的 dry-run(没有真的构造一次 post-state
|
|
74
|
+
// 的 `projectLockfile()`),只覆盖「图不闭合」与「历史快照取不到」两格。
|
|
75
|
+
assertLedgerGraphUsable(other, `${t.target}/.geoly/ledger.json`);
|
|
76
|
+
for (const e of Object.values(other.entries ?? {})) {
|
|
77
|
+
pairs.set(`${e.artifact}\u0000${e.snapshot}`, { artifact: e.artifact, snapshot: e.snapshot });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ② 逐条证明「那张快照取得回来、验得过签、里面确实有这个 record」。
|
|
82
|
+
// 🔴 **不 try/catch 吞掉**:取不到就是现在失败,而不是提交之后失败。
|
|
83
|
+
const missing = [];
|
|
84
|
+
for (const { artifact, snapshot } of pairs.values()) {
|
|
85
|
+
if (current?.artifacts.some((r) => r.id === artifact)) continue;
|
|
86
|
+
let snap;
|
|
87
|
+
try { snap = readHistorical(snapshot); } catch (e) {
|
|
88
|
+
// 🔴 **只有「取不到」(退出码 6)才算 missing**(Codex 2026-09-04 P1-3)。
|
|
89
|
+
// 一律 catch 成 `NetworkError` 会把**验签失败 / 摘要不符 / 快照解析失败**
|
|
90
|
+
// 从 2(完整性)降成 6(网络)—— 那正是 `snapshot-access.isDegradable`
|
|
91
|
+
// 那条注释在防的事:把三类不同的失败吞成同一句「网络不好」。
|
|
92
|
+
if (!isDegradable(e)) throw e;
|
|
93
|
+
missing.push(`${artifact}(快照 ${snapshot} 取不回来:${e.message.split('\n')[0]})`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!snap.artifacts.some((r) => r.id === artifact)) {
|
|
97
|
+
missing.push(`${artifact}(快照 ${snapshot} 里没有这条 record)`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (missing.length) {
|
|
101
|
+
throw new NetworkError(
|
|
102
|
+
'项目级 lockfile 重算需要每个 entry 的 asset_sha256(账本里没有这个字段,只能回历史快照取),'
|
|
103
|
+
+ '下面这些取不到:\n' + missing.map((m) => ` ${m}`).join('\n') + '\n'
|
|
104
|
+
+ ' 🔴 现在拒绝,是为了不在**事务已经提交之后**才发现 —— 那时 lockfile 会停在旧版本,'
|
|
105
|
+
+ '而 recover 已经没有 journal 可重试。\n'
|
|
106
|
+
+ ` 出路:联网跑一次(去掉 --offline)把快照热进缓存,或先 \`skills-hub sync-lock\`。`,
|
|
107
|
+
{ telemetryReason: 'not-found' },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
out?.note?.(`项目级 lockfile:已预热 ${pairs.size} 条 entry 需要的历史快照`);
|
|
111
|
+
|
|
112
|
+
// ③ 钩子与预热**共用同一份 memo**:收尾那一刻不再有任何新的 I/O 决策。
|
|
113
|
+
return function onLedgerChanged(inFlightTarget = null) {
|
|
114
|
+
recalcLockfile(ctx, { current, inFlightTarget, readHistorical });
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -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
|
+
}
|