@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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin/skills-hub.mjs +26 -0
  4. package/package.json +44 -0
  5. package/src/adapters/index.mjs +832 -0
  6. package/src/artifact.mjs +376 -0
  7. package/src/atomic-fs.mjs +166 -0
  8. package/src/attestation.mjs +136 -0
  9. package/src/canonical-json.mjs +147 -0
  10. package/src/cli.mjs +208 -0
  11. package/src/commands/check.mjs +295 -0
  12. package/src/commands/context.mjs +235 -0
  13. package/src/commands/install.mjs +430 -0
  14. package/src/commands/locks.mjs +197 -0
  15. package/src/commands/output.mjs +127 -0
  16. package/src/commands/query.mjs +266 -0
  17. package/src/commands/recover.mjs +438 -0
  18. package/src/commands/registry.mjs +123 -0
  19. package/src/commands/resolve.mjs +171 -0
  20. package/src/commands/snapshot-access.mjs +91 -0
  21. package/src/commands/sync-lock.mjs +189 -0
  22. package/src/crc32c.mjs +27 -0
  23. package/src/exit-codes.mjs +265 -0
  24. package/src/fault-inject.mjs +379 -0
  25. package/src/install.mjs +732 -0
  26. package/src/journal.mjs +435 -0
  27. package/src/ledger.mjs +671 -0
  28. package/src/lock.mjs +98 -0
  29. package/src/lockfile.mjs +0 -0
  30. package/src/pack.mjs +792 -0
  31. package/src/packer.mjs +351 -0
  32. package/src/plan.mjs +519 -0
  33. package/src/recover.mjs +1345 -0
  34. package/src/safe-fs.mjs +252 -0
  35. package/src/sigstore.mjs +480 -0
  36. package/src/snapshot.mjs +528 -0
  37. package/src/stats.mjs +59 -0
  38. package/src/target.mjs +738 -0
  39. package/src/telemetry.mjs +393 -0
  40. package/src/tree-digest.mjs +103 -0
  41. package/src/trust-roots/README.md +31 -0
  42. package/src/trust-roots/sigstore-public-good.json +126 -0
  43. package/src/trust.mjs +563 -0
  44. package/src/untar.mjs +570 -0
  45. package/src/upload.mjs +268 -0
  46. package/src/vendor.mjs +465 -0
@@ -0,0 +1,171 @@
1
+ // 制品 spec 的解析 —— 09-cli.md §5「解析规则」。
2
+ //
3
+ // 1. 含 `@` → 精确版本;否则取 `latest`(非 yank、非 prerelease、**非 degraded**)。
4
+ // 2. 含 `/` → namespace 已定;否则 ①先在 `geoly` 里找 ②全快照找唯一匹配
5
+ // ③多 namespace 同名 → **报错列候选**,不猜。
6
+ // 3. `pack:` 前缀强制 kind;无前缀且同名同时存在 skill 与 pack → 报错列候选。
7
+ // 4. 目标 pack 的全部版本都 `degraded` → 报「无可安装版本」并列出各版本被哪个成员拖累。
8
+ //
9
+ // 🔴 「不猜」是这条规则的全部意义。任何一处 fallback 都会让同一条命令在不同快照下
10
+ // 装出不同东西,而用户完全看不出来。
11
+
12
+ import { UsageError, AmbiguousError, ConflictError } from '../exit-codes.mjs';
13
+ import { compareSemver, parseSemver } from '../snapshot.mjs';
14
+
15
+ export const DEFAULT_NAMESPACE = 'geoly';
16
+
17
+ const RE_SPEC = /^(?:(skill|pack):)?(?:([a-z0-9][a-z0-9-]*)\/)?([a-z0-9][a-z0-9-]*)(?:@(.+))?$/;
18
+
19
+ /** 把一条 spec 拆成 `{ kind, namespace, name, version }`,缺席的为 `null`。 */
20
+ export function parseSpec(spec) {
21
+ if (typeof spec !== 'string' || spec === '') throw new UsageError('制品 spec 不能为空');
22
+ const m = RE_SPEC.exec(spec);
23
+ if (!m) {
24
+ throw new UsageError(
25
+ `制品 spec 不合 grammar:${spec}\n`
26
+ + ' 形如 `[skill:|pack:][<namespace>/]<name>[@<version>]`,'
27
+ + 'namespace 与 name 只允许小写字母、数字与连字符。',
28
+ );
29
+ }
30
+ const [, kind, namespace, name, version] = m;
31
+ if (version !== undefined) parseSemver(version, `spec ${spec} 的版本`); // 形式非法即拒
32
+ return { raw: spec, kind: kind ?? null, namespace: namespace ?? null, name, version: version ?? null };
33
+ }
34
+
35
+ /**
36
+ * 在快照里解析一条 spec,返回**唯一**的 record。
37
+ *
38
+ * @param {object} snap `parseSnapshot()` 的产物
39
+ * @param {object} q `parseSpec()` 的产物
40
+ * @param {object} o
41
+ * @param {boolean} o.pre 允许预发布版本
42
+ * @param {boolean} o.allowYanked 允许装 yanked(🔴 只放行 `yanked`,**绝不放行 `degraded`**,§8.1.1)
43
+ */
44
+ export function resolveSpec(snap, q, { pre = false, allowYanked = false } = {}) {
45
+ // ── 规则 2/3:先把候选收窄到「同名」的全部 record ────────────────────────
46
+ let pool = snap.artifacts.filter((r) => r.name === q.name);
47
+ if (q.kind) pool = pool.filter((r) => r.kind === q.kind);
48
+ if (q.namespace) pool = pool.filter((r) => r.namespace === q.namespace);
49
+ if (pool.length === 0) {
50
+ throw new UsageError(
51
+ `快照 ${snap.snapshot} 里找不到 ${q.raw}。`
52
+ + (q.namespace ? '' : `(未指定 namespace 时先找 ${DEFAULT_NAMESPACE},再找全快照的唯一匹配)`),
53
+ { telemetryReason: 'not-found' },
54
+ );
55
+ }
56
+
57
+ // 规则 3:无 kind 前缀而 skill 与 pack 同名 → 报错列候选
58
+ if (!q.kind) {
59
+ const kinds = [...new Set(pool.map((r) => r.kind))].sort();
60
+ if (kinds.length > 1) {
61
+ throw new AmbiguousError(
62
+ `${q.name} 在快照 ${snap.snapshot} 里同时存在 skill 与 pack —— 请加前缀点名:\n`
63
+ + kinds.map((k) => ` ${k}:${q.name}`).join('\n'),
64
+ kinds.map((k) => `${k}:${q.name}`),
65
+ );
66
+ }
67
+ }
68
+
69
+ // 规则 2:namespace 没定 → ① geoly ② 全快照唯一匹配 ③ 多 namespace 同名报错
70
+ if (!q.namespace) {
71
+ const inDefault = pool.filter((r) => r.namespace === DEFAULT_NAMESPACE);
72
+ if (inDefault.length > 0) pool = inDefault;
73
+ else {
74
+ const nss = [...new Set(pool.map((r) => r.namespace))].sort();
75
+ if (nss.length > 1) {
76
+ throw new AmbiguousError(
77
+ `${q.name} 在多个 namespace 下都存在,不猜 —— 请点名其中一个:\n`
78
+ + nss.map((ns) => ` ${ns}/${q.name}`).join('\n'),
79
+ nss.map((ns) => `${ns}/${q.name}`),
80
+ );
81
+ }
82
+ }
83
+ }
84
+
85
+ const kind = pool[0].kind;
86
+ const ns = pool[0].namespace;
87
+
88
+ // ── 规则 1:精确版本 ─────────────────────────────────────────────────────
89
+ if (q.version !== null) {
90
+ const rec = pool.find((r) => r.version === q.version);
91
+ if (!rec) {
92
+ throw new UsageError(
93
+ `${kind}:${ns}/${q.name}@${q.version} 不在快照 ${snap.snapshot} 里。已有版本:`
94
+ + pool.map((r) => r.version).sort().join(', '),
95
+ { telemetryReason: 'not-found' },
96
+ );
97
+ }
98
+ assertInstallable(rec, snap, { allowYanked });
99
+ return rec;
100
+ }
101
+
102
+ // ── 规则 1:latest ───────────────────────────────────────────────────────
103
+ // 🔴 snapshot.latest 已经由 `parseSnapshot()` 校验过自洽(非 yank、非 prerelease、
104
+ // **非 degraded** 的最高版本)。默认路径直接用它,不自己再算一遍 ——
105
+ // 自己算就会和快照的 latest 投影分叉。
106
+ if (!pre) {
107
+ const key = `${kind}:${ns}/${q.name}`;
108
+ const v = snap.latest[key];
109
+ if (v === undefined) {
110
+ // 规则 4:一个可装版本都没有 —— 说清是被什么挡住的
111
+ throw noInstallableVersion(kind, ns, q.name, pool, snap);
112
+ }
113
+ const rec = pool.find((r) => r.version === v);
114
+ assertInstallable(rec, snap, { allowYanked });
115
+ return rec;
116
+ }
117
+
118
+ // `--pre`:把预发布也纳入,仍然排除 yanked 与 degraded
119
+ const cands = pool.filter((r) => r.status !== 'yanked' && r.status !== 'degraded');
120
+ if (cands.length === 0) throw noInstallableVersion(kind, ns, q.name, pool, snap);
121
+ cands.sort((a, b) => compareSemver(a._semver, b._semver));
122
+ const rec = cands[cands.length - 1];
123
+ assertInstallable(rec, snap, { allowYanked });
124
+ return rec;
125
+ }
126
+
127
+ function noInstallableVersion(kind, ns, name, pool, snap) {
128
+ const lines = pool
129
+ .slice()
130
+ .sort((a, b) => (a.version < b.version ? -1 : 1))
131
+ .map((r) => {
132
+ if (r.status !== 'degraded') return ` ${r.version} —— status=${r.status}`;
133
+ // 规则 4:列出**各版本被哪个成员拖累**。degraded 是 pack 的状态,
134
+ // 成员信息在 pack 的载荷里而不在快照 record 里 —— 快照能给的只有
135
+ // 「该 pack 版本被标记为 degraded」。🔴 如实说清楚这一点,不要假装知道成员名。
136
+ return ` ${r.version} —— degraded(被某个已 yank 的成员拖累;成员清单在 pack 载荷里,`
137
+ + `快照 record 不携带,需取回该 pack 后才能列出)`;
138
+ });
139
+ return new ConflictError(
140
+ `${kind}:${ns}/${name} 在快照 ${snap.snapshot} 里没有可安装版本:\n${lines.join('\n')}\n`
141
+ + '🔴 --allow-yanked 只放行 yanked,**绝不放行 degraded**(04-install.md §8.1.1)。'
142
+ + '要装就自己按成员逐个装。',
143
+ { telemetryReason: 'version-conflict' },
144
+ );
145
+ }
146
+
147
+ /** 状态门。🔴 `--allow-yanked` 只放行 `yanked` 一种(§8.1.1)。 */
148
+ export function assertInstallable(rec, snap, { allowYanked = false } = {}) {
149
+ if (rec.status === 'degraded') {
150
+ throw new ConflictError(
151
+ `${rec.id} 的状态是 degraded(某个成员被 yank)。`
152
+ + '🔴 --allow-yanked **不放行 degraded**(04-install.md §8.1.1)——'
153
+ + '它是针对具体制品的知情豁免,而 degraded 会连带装一个你没有豁免过的成员。'
154
+ + '要装请按成员逐个装。',
155
+ { telemetryReason: 'version-conflict' },
156
+ );
157
+ }
158
+ if (rec.status === 'yanked') {
159
+ if (!allowYanked) {
160
+ const y = snap.yanked.find((x) => x.id === rec.id);
161
+ throw new ConflictError(
162
+ `${rec.id} 已被 yank:${y?.reason ?? '(快照未给原因)'}`
163
+ + (y?.advisory ? `\n advisory:${y.advisory}` : '')
164
+ + (y?.superseded_by ? `\n 已被 ${y.superseded_by} 取代` : '')
165
+ + '\n 要取证安装请显式给 --allow-yanked(会大声告警并写进账本)。',
166
+ { telemetryReason: 'yanked' },
167
+ );
168
+ }
169
+ }
170
+ return rec;
171
+ }
@@ -0,0 +1,91 @@
1
+ // 快照取用 —— 02-registry.md §6(解析当前)与 §6.1(历史读取路径)在命令面的入口。
2
+ //
3
+ // 🔴 **这是两条不同的路径,不能混用**:
4
+ // · `resolveCurrent()`:回答「现在最新的是哪张快照」,会**推进 trust floor**,
5
+ // 并且「N 小于本地 floor 即拒绝」。metadata 锁在 `advanceTrustFloor()` 内部起落 ——
6
+ // 🔴 命令面**不得**再包一层 metadata 锁(`src/lock.mjs` 禁止重入,会当场抛)。
7
+ // · `readHistoricalSnapshot()`:**只读**。可用于 `--snapshot <N>` 复现、
8
+ // `check` 的第一问、lockfile 闭包重解析。
9
+ // ❌ 不得用它回答「现在还能不能用」—— 那必须查当前快照。
10
+
11
+ import { existsSync } from 'node:fs';
12
+ import { mkdirChainFsync } from '../atomic-fs.mjs';
13
+ import { resolveCurrent, readHistoricalSnapshot } from '../snapshot.mjs';
14
+ import { readTrustFloor } from '../trust.mjs';
15
+ import { realVerifier } from './context.mjs';
16
+ import { EXIT, classify } from '../exit-codes.mjs';
17
+
18
+ export async function getVerifier(ctx) {
19
+ // 🔴 验签没有逃生口。`ctx.verifier` 只有 `main(argv, deps)` 的调用方给得了;
20
+ // 生产入口一个 dep 都不传,因此这里落到真验签器(内置信任根)。
21
+ return ctx.verifier ?? realVerifier();
22
+ }
23
+
24
+ /**
25
+ * @returns {{snapshot:object, stale:boolean, floor:object|null, pinned:boolean, verifier:Function}}
26
+ */
27
+ export async function resolveSnapshotForCommand(ctx) {
28
+ const verifier = await getVerifier(ctx);
29
+
30
+ if (ctx.snapshot !== null) {
31
+ // `--snapshot <N>`:钉快照复现。走 §6.1 的**只读**路径,**不推进 floor**。
32
+ const { bytes, bundle } = ctx.registry.fetchSnapshot(ctx.snapshot);
33
+ const { snapshot } = readHistoricalSnapshot({
34
+ bytes, bundle, verifier, expectSnapshot: ctx.snapshot,
35
+ });
36
+ // 🔴 R-9 的 floor 复验仍然要做:钉快照不代表可以放弃「安装期间 floor 没被推进」
37
+ // 这条检查。取磁盘上当前的 floor 当期望值;还没 bootstrap 就显式 null
38
+ // (`assertFloorBarrier` 要求**显式**给出,忘了传是拒绝、不是跳过)。
39
+ // 🔴 **这条路径只读,不建目录**:`--snapshot` / `check` 的第一问都走它,
40
+ // 让一次只读查询顺手建出全局状态目录,与「历史读取路径只读」自相矛盾。
41
+ let floor = null;
42
+ if (existsSync(ctx.stateDir)) floor = readTrustFloor(ctx.stateDir);
43
+ return { snapshot, stale: false, floor, pinned: true, verifier };
44
+ }
45
+
46
+ // 只有「解析当前」这条路径会**推进 floor**,也只有它需要状态目录存在
47
+ mkdirChainFsync(ctx.stateDir); // resolveStateDir 要 realpath 它
48
+ const r = resolveCurrent({
49
+ stateDir: ctx.stateDir,
50
+ fetchTimestamp: () => ctx.registry.fetchTimestamp(),
51
+ fetchSnapshot: (n) => ctx.registry.fetchSnapshot(n),
52
+ verifier,
53
+ cliVersion: ctx.cliVersion,
54
+ now: ctx.now().getTime(),
55
+ offline: ctx.offline,
56
+ allowStale: ctx.allowStale,
57
+ });
58
+ return { snapshot: r.snapshot, stale: r.stale === true, floor: r.floor, pinned: false, verifier };
59
+ }
60
+
61
+ /**
62
+ * 历史快照的 memo 读取器(`check` / `sync-lock` / lockfile 闭包共用)。
63
+ * 🔴 每一份都**独立验签**(identity = release.yml)——命中缓存只省网络,不省校验。
64
+ */
65
+ /**
66
+ * 🔴 只读命令(`list` / `check`)在取不到当前快照时可以**降级并如实标注**,
67
+ * 但**只有「取不到」这一种**可以降级。
68
+ *
69
+ * 早先 `list` / `check` 是 `try { … } catch { 标 offline 继续 }` ——
70
+ * 那会把这三类一起吞掉,而它们各自在 §6 里有自己的格子:
71
+ * · `StaleError`(8):timestamp 过期且未给 `--allow-stale`。吞掉它 = CI 拿着
72
+ * 一张过期的信任根照常绿灯,正是 `--allow-stale` 存在的理由被绕过;
73
+ * · `IntegrityError` / `TarViolation`(2):验签失败、摘要不符、抗回滚命中;
74
+ * · `MinCliVersionError`(11)。
75
+ *
76
+ * 判据是**退出码分类**,不是错误文案:只有落在 `NETWORK`(6)那一格的才可降级。
77
+ */
78
+ export function isDegradable(err) {
79
+ return classify(err).code === EXIT.NETWORK;
80
+ }
81
+
82
+ export function historicalReader(ctx, verifier) {
83
+ const memo = new Map();
84
+ return function read(n) {
85
+ if (memo.has(n)) return memo.get(n);
86
+ const { bytes, bundle } = ctx.registry.fetchSnapshot(n);
87
+ const { snapshot } = readHistoricalSnapshot({ bytes, bundle, verifier, expectSnapshot: n });
88
+ memo.set(n, snapshot);
89
+ return snapshot;
90
+ };
91
+ }
@@ -0,0 +1,189 @@
1
+ // `sync-lock` —— 在 repo 锁下**幂等重算**并原子写 `geoly-skills.lock.json`(04-install.md §8.1)。
2
+ //
3
+ // 🔴 也是 `install` / `recover` 收尾时那个 `onLedgerChanged` 钩子的实现:
4
+ // §5.1 末尾要求 install / update / remove / **recover** 成功之后都要在 repo 锁下重算。
5
+ // 钩子被调用时 repo 锁**已经由命令层持着** —— 所以本模块**不自己取 repo 锁**
6
+ // (`src/lock.mjs` 禁止重入,取第二次会当场抛)。取锁是调用方的事。
7
+ //
8
+ // 🔴 「任一项目级 target 处于未恢复事务中 → **拒绝重算**并要求先 recover」(§8.1)。
9
+
10
+ import { existsSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { planTargets, assertPlanOk } from '../adapters/index.mjs';
13
+ import { layout, readLedger } from '../ledger.mjs';
14
+ import { listJournalGenerations, readJournal } from '../journal.mjs';
15
+ import { projectLockfile, writeLockfile } from '../lockfile.mjs';
16
+ import { REPO } from '../trust.mjs';
17
+ import { withOrderedLocks } from './locks.mjs';
18
+ import { getVerifier, historicalReader, resolveSnapshotForCommand } from './snapshot-access.mjs';
19
+ import { UsageError, EXIT, NetworkError } from '../exit-codes.mjs';
20
+ import { annotations } from './output.mjs';
21
+
22
+ export const LOCKFILE_NAME = 'geoly-skills.lock.json';
23
+
24
+ /** §8.1:有未恢复事务就拒绝重算。 */
25
+ export function assertNoUnrecoveredTx(target) {
26
+ const P = layout(target);
27
+ if (!existsSync(P.state)) return;
28
+ if (existsSync(P.repairIntent)) {
29
+ throw new UsageError(
30
+ `${target} 存在未完成的 repair intent:请先跑 \`skills-hub recover --reinstall\`,再重算 lockfile。`,
31
+ { exitCode: EXIT.NEEDS_RECOVER },
32
+ );
33
+ }
34
+ for (const g of listJournalGenerations(P.journalDir)) {
35
+ const J = readJournal(layout(target, g).journal);
36
+ if (J.phase !== 'completed') {
37
+ const e = new UsageError(
38
+ `${target} 的第 ${g} 代事务停在 ${J.phase}:lockfile **拒绝重算**(04-install.md §8.1)。\n`
39
+ + ' 请先 `skills-hub recover --continue` 或 `--rollback`。',
40
+ );
41
+ e.exitCode = EXIT.NEEDS_RECOVER;
42
+ throw e;
43
+ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * 从**已验签的**快照里取 entry 的 `asset_sha256`。
49
+ *
50
+ * 🔴 账本里没有这个字段(它是本机运行历史之外的东西),只能回快照取。
51
+ * 取不到就**失败**,绝不填一个占位值 —— lockfile 是别的机器的权威输入。
52
+ */
53
+ function assetSha256For(artifactId, entrySnapshot, { current, readHistorical }) {
54
+ const fromCurrent = current?.artifacts.find((r) => r.id === artifactId);
55
+ if (fromCurrent) return fromCurrent.asset.sha256;
56
+ const snap = readHistorical(entrySnapshot);
57
+ const rec = snap.artifacts.find((r) => r.id === artifactId);
58
+ if (!rec) {
59
+ throw new NetworkError(
60
+ `快照 ${entrySnapshot} 里找不到 ${artifactId},无法取它的 asset_sha256 —— lockfile 拒绝重算。`,
61
+ { telemetryReason: 'not-found' },
62
+ );
63
+ }
64
+ return rec.asset.sha256;
65
+ }
66
+
67
+ /**
68
+ * 重算(不取锁)。返回 `{ path, lockfile, targets }`;没有任何项目级 target 时返回 `null`。
69
+ *
70
+ * @param {object} o.current 当前快照(可为 null;那时逐个回历史快照取)
71
+ * @param {(n:number)=>object} o.readHistorical
72
+ */
73
+ export function recalcLockfile(ctx, { current = null, readHistorical, inFlightTarget = null }) {
74
+ if (ctx.scope !== 'project') return null;
75
+ const tplan = planTargets({
76
+ clients: ctx.clients,
77
+ scope: 'project',
78
+ home: ctx.home,
79
+ env: ctx.env,
80
+ projectRoot: ctx.projectRoot,
81
+ });
82
+ assertPlanOk(tplan);
83
+
84
+ const targets = [];
85
+ for (const t of tplan.selected) {
86
+ const P = layout(t.target);
87
+ if (!existsSync(P.ledger)) continue;
88
+ // 🔴 口子只对**正在收尾的那一个 target** 开,不是对整次重算开。
89
+ // §8.1 那条「有未恢复事务就拒绝重算」防的是**旁观者**拿中间态去投影;
90
+ // 而钩子是由事务本身在账本已经写定之后调的(rollback 的 `finalizeRollback()`
91
+ // 第 ③ 步就在第 ⑤ 步把 phase 置成 completed 之前)——
92
+ // 它对**自己**的 journal 有权威,对**别的** target 没有。
93
+ // 早先这里是一个整次生效的 `allowInFlight`,多 target recover 时会把
94
+ // 另一个 target 的中间态一起投影进 lockfile(Codex 第三轮 P0-2)。
95
+ if (t.target !== inFlightTarget) assertNoUnrecoveredTx(t.target);
96
+ const L = readLedger(P.ledger);
97
+ if (Object.keys(L.entries).length === 0 && Object.keys(L.roots).length === 0) continue;
98
+ const assetSha256 = {};
99
+ for (const [name, e] of Object.entries(L.entries)) {
100
+ assetSha256[name] = assetSha256For(e.artifact, e.snapshot, { current, readHistorical });
101
+ }
102
+ targets.push({
103
+ client: t.client,
104
+ scope: 'project',
105
+ // 🔴 `path` **只能由 adapter 推导**(§8.1 的闭合验证要求),仓库内相对路径
106
+ path: `${t.adapter.dirName}/skills`,
107
+ ledger: L,
108
+ assetSha256,
109
+ });
110
+ }
111
+ const lf = projectLockfile({ registry: REPO, targets });
112
+ const path = join(ctx.projectRoot, LOCKFILE_NAME);
113
+ writeLockfile(path, lf);
114
+ return { path, lockfile: lf, targets: targets.map((t) => t.path) };
115
+ }
116
+
117
+ /**
118
+ * 造 `onLedgerChanged` 钩子。
119
+ *
120
+ * 🔴 **入口就查**:`install.assertLockfileHook()` 在 `runTransaction` 的**第一行**
121
+ * 就要求项目级 target 必须注入它 —— 缺了要在「放弃还免费」的时候报,
122
+ * 而不是等事务提交完、journal 都删了才炸。
123
+ *
124
+ * ⚠️ **已知非原子路径(R-11 第四条)**:`runCleanup()` 先清 tx 与 journal,
125
+ * **最后**才调本钩子。钩子抛错时 ledger 已提交、project lockfile 可能陈旧,
126
+ * 而 recover 已经没有 journal 可重试。兜底是:`check` 会报「lockfile 过时」,
127
+ * 用户跑 `sync-lock` 补。**这是已知缺口,不是被闭合了。**
128
+ */
129
+ export function makeLockfileHook(ctx, { snap = null, verifier = null } = {}) {
130
+ if (ctx.scope !== 'project') return undefined;
131
+ // 🔴 `runLockfileRecalc(target, P, opts)` 会把**当前 target** 传进来 ——
132
+ // 口子就窄化在这个参数上。
133
+ return function onLedgerChanged(inFlightTarget = null) {
134
+ // 🔴 历史快照读取器**每次现造**:memo 缓在钩子外面会让一次长跑里的
135
+ // 「快照被换掉」看不见。它只省重复解析,不省任何一次验签。
136
+ const readHistorical = verifier === null
137
+ ? (n) => {
138
+ throw new NetworkError(
139
+ `重算 lockfile 需要快照 ${n} 里的 asset_sha256,但本次运行没有可用的验签器。`
140
+ + '请跑 `skills-hub sync-lock` 补齐。',
141
+ { telemetryReason: 'not-found' },
142
+ );
143
+ }
144
+ : historicalReader(ctx, verifier);
145
+ recalcLockfile(ctx, { current: snap, inFlightTarget, readHistorical });
146
+ };
147
+ }
148
+
149
+ export async function cmdSyncLock(ctx, argv, out) {
150
+ for (const a of argv) throw new UsageError(`sync-lock 不认得参数 ${a}`);
151
+ if (ctx.scope !== 'project') {
152
+ throw new UsageError('sync-lock 只对项目级安装有意义:请加 `--project [path]`(04-install.md §8)。');
153
+ }
154
+ const verifier = await getVerifier(ctx);
155
+ const readHistorical = historicalReader(ctx, verifier);
156
+ // 当前快照能拿到就拿(省去逐条回历史快照);拿不到不是错 —— 历史路径仍然可用
157
+ let current = null;
158
+ try { current = (await resolveSnapshotForCommand(ctx)).snapshot; } catch { current = null; }
159
+
160
+ let result = null;
161
+ withOrderedLocks({ projectRoot: ctx.projectRoot, targets: [] }, () => {
162
+ result = recalcLockfile(ctx, { current, readHistorical });
163
+ });
164
+
165
+ // 🔴 埋点在**收尾处**,不在事务关键路径上;`record()` 自己不抛。
166
+ // `kind` 取 KINDS 里现成的 `sync-lock`。
167
+ if (ctx.record) ctx.record({ kind: 'sync-lock', result: 'ok', scope: 'project' });
168
+
169
+ if (result === null) {
170
+ out.line('没有可投影的项目级 target(没有账本)。');
171
+ return out.emit('sync-lock', { lockfile: null, targets: [] }, EXIT.OK);
172
+ }
173
+ out.line(`已重算 ${result.path}`);
174
+ for (const t of result.lockfile.targets) {
175
+ out.line(` ${t.client}/${t.scope} ${t.path} roots=${t.roots.length} entries=${t.entries.length}`);
176
+ }
177
+ return out.emit('sync-lock', {
178
+ lockfile: result.path,
179
+ targets: result.lockfile.targets.map((t) => ({
180
+ // 🔴 标注挂在每一个 target 对象上(§7)
181
+ annotations: annotations({ offline: ctx.offline }),
182
+ client: t.client,
183
+ entries: t.entries.length,
184
+ path: t.path,
185
+ roots: t.roots.length,
186
+ scope: t.scope,
187
+ })),
188
+ }, EXIT.OK);
189
+ }
package/src/crc32c.mjs ADDED
@@ -0,0 +1,27 @@
1
+ // crc32c(Castagnoli)—— journal 自校验用,规范见 11-wire-contract.md §5。
2
+ //
3
+ // 🔴 覆盖范围由调用方给:§11 §5 说的是「**去掉 `crc32c` 这一个 key 之后**该对象的
4
+ // canonical 字节(含结尾换行)」。本模块只管算,不管取哪些字节 ——
5
+ // 把「取哪些字节」写进这里会让它在别处(例如 audit archive)被误用。
6
+ //
7
+ // 这份实现原先躺在 test/harness/crc32c.mjs 里(框架先于内核落地)。搬到 src/ 之后
8
+ // 两份必须逐字节等价,test/crc32c.test.mjs 有一条测试把它们钉在一起。
9
+
10
+ const POLY = 0x82f63b78; // 反射多项式
11
+ const TABLE = new Uint32Array(256);
12
+ for (let i = 0; i < 256; i++) {
13
+ let c = i;
14
+ for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ POLY : c >>> 1;
15
+ TABLE[i] = c >>> 0;
16
+ }
17
+
18
+ export function crc32c(buf) {
19
+ let c = 0xffffffff;
20
+ for (let i = 0; i < buf.length; i++) c = TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
21
+ return (c ^ 0xffffffff) >>> 0;
22
+ }
23
+
24
+ /** §11 §5:小写 hex,**8 个字符,固定宽度补零** */
25
+ export function crc32cHex(buf) {
26
+ return crc32c(buf).toString(16).padStart(8, '0');
27
+ }