@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,235 @@
1
+ // 全局 flag 解析与运行时上下文 —— 09-cli.md §0 / §2。
2
+ //
3
+ // 🔴 三条硬约束在本文件落地:
4
+ // · `--offline` 置位后**整个 CLI 不得有任何网络出口**(含埋点上报);
5
+ // · **没有** `--no-verify` / `--insecure` / `--force` / `--force-unlock` / `--assume-idle`
6
+ // —— 不是「不实现」,是**显式拒绝并说明为什么没有**。默默当成未知 flag
7
+ // 会让人以为拼错了,然后去翻文档找正确写法;
8
+ // · 平台契约(§0):`win32` 且非 WSL → 直接拒绝并给 WSL 指引。
9
+ //
10
+ // 🔴 依赖注入走 `main(argv, deps)` 的**第二个形参**,不走 argv、不走环境变量。
11
+ // 这是结构性的:用户能控制的只有 argv 与 env,两者都到不了 `deps`。
12
+ // (同 `target.mjs` 的 `TEST_DEPS` 与 `adapters` 的 `TEST_GATES` 的理由,
13
+ // 但这里连 Symbol 都不需要 —— 形参本身就够不着。)
14
+
15
+ import { homedir, platform } from 'node:os';
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { isAbsolute, resolve as resolvePath } from 'node:path';
18
+ import { UsageError, UnsupportedError } from '../exit-codes.mjs';
19
+
20
+ /** §2 那张表。`arg: true` = 后面跟一个值。 */
21
+ const GLOBAL_FLAGS = Object.freeze({
22
+ '--clients': { arg: true },
23
+ '--create-missing': { arg: true },
24
+ '--project': { arg: 'optional' },
25
+ '--shadow-global': { arg: false },
26
+ '--snapshot': { arg: true },
27
+ '--offline': { arg: false },
28
+ '--allow-stale': { arg: false },
29
+ '--allow-yanked': { arg: false },
30
+ '--replace': { arg: true, repeat: true },
31
+ '--no-bundled': { arg: false },
32
+ '--freeze-attic': { arg: true },
33
+ '--keep-generations': { arg: true },
34
+ '--json': { arg: false },
35
+ '--yes': { arg: false },
36
+ '--yes-i-really-want-everything': { arg: false },
37
+ '--pre': { arg: false },
38
+ });
39
+
40
+ /**
41
+ * 🔴 明令不存在的开关。每一条都要说清**为什么没有**,否则用户只会觉得是拼写问题。
42
+ * 文案取自 09-cli.md §2 末尾的四条理由。
43
+ */
44
+ const REMOVED_FLAGS = Object.freeze({
45
+ '--no-verify': '验签与摘要校验**不可关闭**(09-cli.md §2)。没有这个开关,也不会有。',
46
+ '--insecure': '同 --no-verify:完整性校验没有逃生口(09-cli.md §2)。',
47
+ '--force': '替换必须**点名**:用 `--replace <name>` 指定那一个目录,不提供泛化的大锤(§4.2)。',
48
+ '--force-unlock':
49
+ '锁是 node:sqlite 的排他事务,**进程退出由内核释放**——崩溃后下一次运行直接就能取到,'
50
+ + '不需要任何人工干预(04-install.md §5.1)。',
51
+ '--clear-lock': '同 --force-unlock:协议里没有任何 unlink,也就没有可清的东西(§5.1)。',
52
+ '--assume-idle':
53
+ '本工具**不检测也不阻断**正在运行的 agent(D5,04-install.md §9),所以没有可假设的东西。',
54
+ '--allow-pending':
55
+ 'Q12 是阻塞门,不是建议。要开某一格就补那一格的实测证据(docs/m1/01-residual-risks.md R-4)。',
56
+ });
57
+
58
+ /** §0 平台契约。WSL 的判据取 `/proc/version` 里的 microsoft 标记。 */
59
+ export function assertPlatformSupported(env = process.env, plat = platform()) {
60
+ if (plat !== 'win32') return { platform: plat, wsl: isWsl() };
61
+ throw new UnsupportedError(
62
+ 'Windows 原生不受支持(09-cli.md §0)。请在 WSL 里安装并运行:\n'
63
+ + ' 1. 在 PowerShell 里跑 `wsl --install`(或装一个已有发行版)\n'
64
+ + ' 2. 进入 WSL 后在**WSL 的文件系统里**(不是 /mnt/c)安装 Node ≥ 22.13\n'
65
+ + ' 3. 在 WSL 里重跑本命令\n'
66
+ + '⚠️ 不要把 target 放在 /mnt/c 下 —— 那是 9p/drvfs,属于被拒绝的文件系统(§2.2)。',
67
+ );
68
+ }
69
+
70
+ function isWsl() {
71
+ try { return /microsoft/i.test(readFileSync('/proc/version', 'utf8')); } catch { return false; }
72
+ }
73
+
74
+ /**
75
+ * 从 argv 里摘掉全局 flag,返回 `{ globals, rest }`。
76
+ *
77
+ * 🔴 **不**在这里给未知 flag 报错:命令自己还有子 flag(`--continue`、`--packs` …)。
78
+ * 未知 flag 由各命令的解析器报,那里才知道自己认得哪些。
79
+ * 但**被删除的开关**在这里就拦下 —— 它们对任何命令都不存在。
80
+ */
81
+ export function parseGlobals(argv) {
82
+ const globals = {
83
+ clients: null,
84
+ createMissing: null,
85
+ project: undefined, // undefined = 没给;string = 给了(可能是空串→cwd)
86
+ shadowGlobal: false,
87
+ snapshot: null,
88
+ offline: false,
89
+ allowStale: false,
90
+ allowYanked: false,
91
+ replace: [],
92
+ noBundled: false,
93
+ freezeAttic: null,
94
+ keepGenerations: 3,
95
+ json: false,
96
+ yes: false,
97
+ yesEverything: false,
98
+ pre: false,
99
+ };
100
+ const rest = [];
101
+ for (let i = 0; i < argv.length; i++) {
102
+ const a = argv[i];
103
+ if (Object.hasOwn(REMOVED_FLAGS, a)) {
104
+ throw new UsageError(`没有 \`${a}\` 这个开关。${REMOVED_FLAGS[a]}`);
105
+ }
106
+ // 🔴 `--flag=value` 与 `--flag value` 都要认:只认一种会让「照文档抄」的人踩空
107
+ const eq = a.indexOf('=');
108
+ const name = a.startsWith('--') && eq > 2 ? a.slice(0, eq) : a;
109
+ const inlineVal = a.startsWith('--') && eq > 2 ? a.slice(eq + 1) : undefined;
110
+ const spec = GLOBAL_FLAGS[name];
111
+ if (!spec) { rest.push(a); continue; }
112
+
113
+ let val = inlineVal;
114
+ if (spec.arg === true && val === undefined) {
115
+ val = argv[++i];
116
+ if (val === undefined || val.startsWith('-')) throw new UsageError(`${name} 需要一个值`);
117
+ }
118
+ if (spec.arg === 'optional' && val === undefined) {
119
+ const next = argv[i + 1];
120
+ if (next !== undefined && !next.startsWith('-')) val = argv[++i];
121
+ else val = '';
122
+ }
123
+ applyGlobal(globals, name, val);
124
+ }
125
+ return { globals, rest };
126
+ }
127
+
128
+ function applyGlobal(g, name, val) {
129
+ switch (name) {
130
+ case '--clients': {
131
+ // 🔴 空项要报错,不要静默丢:`--clients claude,` 多半是拼错了
132
+ const list = val.split(',').map((s) => s.trim());
133
+ if (list.some((s) => s === '')) throw new UsageError(`--clients 里有空项:${val}`);
134
+ g.clients = (g.clients ?? []).concat(list);
135
+ break;
136
+ }
137
+ case '--create-missing':
138
+ if (val !== 'all' && !/^[a-z][a-z0-9-]*$/.test(val)) {
139
+ throw new UsageError(`--create-missing 只接受 all 或一个 client 名,得到 ${val}`);
140
+ }
141
+ g.createMissing = g.createMissing === 'all' || val === 'all'
142
+ ? 'all'
143
+ : [...(Array.isArray(g.createMissing) ? g.createMissing : []), val];
144
+ break;
145
+ case '--project': g.project = val; break;
146
+ case '--shadow-global': g.shadowGlobal = true; break;
147
+ case '--snapshot': g.snapshot = uintArg(name, val); break;
148
+ case '--offline': g.offline = true; break;
149
+ case '--allow-stale': g.allowStale = true; break;
150
+ case '--allow-yanked': g.allowYanked = true; break;
151
+ case '--replace': g.replace.push(val); break;
152
+ case '--no-bundled': g.noBundled = true; break;
153
+ case '--freeze-attic': g.freezeAttic = val; break;
154
+ case '--keep-generations': g.keepGenerations = uintArg(name, val); break;
155
+ case '--json': g.json = true; break;
156
+ case '--yes': g.yes = true; break;
157
+ case '--yes-i-really-want-everything': g.yesEverything = true; break;
158
+ case '--pre': g.pre = true; break;
159
+ default: throw new UsageError(`未处理的全局 flag ${name}`);
160
+ }
161
+ }
162
+
163
+ /** 11-wire-contract.md §2:数字只允许非负整数,不允许前导零、浮点、指数、`-0`。 */
164
+ export function uintArg(name, val) {
165
+ if (!/^(0|[1-9]\d*)$/.test(val)) {
166
+ throw new UsageError(`${name} 需要一个非负整数(不允许前导零 / 浮点 / 指数),得到 ${val}`);
167
+ }
168
+ const n = Number(val);
169
+ if (!Number.isSafeInteger(n)) throw new UsageError(`${name} 超出安全整数范围:${val}`);
170
+ return n;
171
+ }
172
+
173
+ /**
174
+ * 造运行时上下文。
175
+ *
176
+ * @param {object} globals parseGlobals 的产物
177
+ * @param {object} deps 🔴 只从 `main(argv, deps)` 的第二个形参来,argv/env 到不了这里
178
+ */
179
+ export function makeContext(globals, deps = {}) {
180
+ const env = deps.env ?? process.env;
181
+ const home = deps.home ?? homedir();
182
+ const cwd = deps.cwd ?? process.cwd();
183
+ assertPlatformSupported(env, deps.platform ?? platform());
184
+
185
+ // 🔴 `--offline` 要在**任何**可能出网的代码求值之前置位。
186
+ // telemetry.offline() 读的就是这个变量,upload.flush() 一票否决靠它。
187
+ if (globals.offline) env.GEOLY_OFFLINE = '1';
188
+ const offline = globals.offline || env.GEOLY_OFFLINE === '1';
189
+
190
+ const scope = globals.project !== undefined ? 'project' : 'global';
191
+ let projectRoot = null;
192
+ if (scope === 'project') {
193
+ projectRoot = globals.project === '' ? cwd : resolvePath(cwd, globals.project);
194
+ if (!isAbsolute(projectRoot)) throw new UsageError(`--project 的路径解析不出绝对路径:${globals.project}`);
195
+ if (!existsSync(projectRoot)) throw new UsageError(`--project 指向的目录不存在:${projectRoot}`);
196
+ }
197
+
198
+ // 全局元数据目录(trust floor + metadata 锁)。埋点用的是同一个根,保持一致。
199
+ const stateDir = deps.stateDir
200
+ ?? env.GEOLY_STATE_DIR
201
+ ?? resolvePath(home, '.local/state/geoly-skills');
202
+ const cacheDir = deps.cacheDir ?? env.GEOLY_CACHE_DIR ?? resolvePath(home, '.cache/geoly-skills');
203
+
204
+ const ctx = Object.freeze({
205
+ ...globals,
206
+ offline,
207
+ scope,
208
+ projectRoot,
209
+ home,
210
+ env,
211
+ cwd,
212
+ stateDir,
213
+ cacheDir,
214
+ /** 🔴 时间只从这里取:canonical JSON 要求严格 `YYYY-MM-DDTHH:MM:SSZ`,测试要能定死它 */
215
+ now: deps.now ?? (() => new Date()),
216
+ cliVersion: deps.cliVersion ?? env.GEOLY_CLI_VERSION ?? '0.0.0-m1',
217
+ /**
218
+ * 🔴 验签器**没有逃生口**:`deps.verifier` 只有 `main(argv, deps)` 的调用方能给,
219
+ * 而生产入口 `bin/skills-hub.mjs` 一个 dep 都不传。
220
+ * 不给就用真验签器(内置信任根),`defaultVerifier` 那个会抛的桩绝不出现在这条路径上。
221
+ */
222
+ verifier: deps.verifier ?? null,
223
+ /** 注入点:测试用内存 registry,生产用 `registry.mjs` 的缓存适配器 */
224
+ registryFactory: deps.registryFactory ?? null,
225
+ /** 注入点:埋点。生产是 `telemetry.record` */
226
+ record: deps.record ?? null,
227
+ });
228
+ return ctx;
229
+ }
230
+
231
+ /** 真验签器(内置信任根)。🔴 懒加载:`list` / `--help` 不该为了解析信任根付出代价。 */
232
+ export async function realVerifier() {
233
+ const { createSigstoreVerifier, loadBuiltinTrustedRoot } = await import('../sigstore.mjs');
234
+ return createSigstoreVerifier({ trustedRoot: loadBuiltinTrustedRoot() });
235
+ }
@@ -0,0 +1,430 @@
1
+ // `install <spec>…` —— 04-install.md §5.2 的十步在命令面这一侧的接线。
2
+ //
3
+ // 十步的归属(🔴 内核明说了它**不做** 1–4,见 install.mjs 的 runTransaction 注释):
4
+ // 1 取锁 → 本文件(locks.mjs,全序 metadata → repo → target)
5
+ // 2 残留事务分流 → 本文件调 recover(target, { mode: 'auto' })
6
+ // 3 预检 → 本文件调 precheckTarget + 🔴 assertPrecheckOk
7
+ // 4 下载 / 验资产 / 解包 → 本文件调 artifact.withVerifiedArtifact(🔴 作用域版)
8
+ // 5–10 → install.runTransaction
9
+ //
10
+ // 🔴 三条不能忘的接线要求:
11
+ // · `adapters.assertPlanOk(plan)` 必须调 —— 直接消费 `plan.selected` 会绕过来源校验;
12
+ // · `target.assertPrecheckOk(result)` 必须调 —— 同上,绕过去就等于没预检;
13
+ // · `planTargets()` 的 `warnings`(duplicate-catalog)要**展示**,不得吞掉。
14
+
15
+ import { existsSync, statfsSync, statSync, readdirSync, realpathSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { mkdirChainFsync } from '../atomic-fs.mjs';
18
+ import { planTargets, assertPlanOk, getAdapter, STATE_DIR } from '../adapters/index.mjs';
19
+ import { precheckTarget, assertPrecheckOk, missingGitignorePatterns, gitignoreHint } from '../target.mjs';
20
+ import { withVerifiedArtifact } from '../artifact.mjs';
21
+ import { layout, readLedger, bootstrapLedger, nextGeneration, ensureGenerationWatermark } from '../ledger.mjs';
22
+ import { derivePlan } from '../plan.mjs';
23
+ import { runTransaction, nowUtc } from '../install.mjs';
24
+ import { recover } from '../recover.mjs';
25
+ import { mountEntryFor, assertNoSymlinkInChain } from '../safe-fs.mjs';
26
+ import { withOrderedLocks } from './locks.mjs';
27
+ import { parseSpec, resolveSpec } from './resolve.mjs';
28
+ import { annotations, annotationSuffix } from './output.mjs';
29
+ import { UsageError, ConflictError, UnsupportedError, EXIT, classify } from '../exit-codes.mjs';
30
+ import { resolveSnapshotForCommand } from './snapshot-access.mjs';
31
+ import { makeLockfileHook } from './sync-lock.mjs';
32
+
33
+ /**
34
+ * 🔴 **作用域版必须包住整个 `runTransaction`**,不能只包「解包」那一小段。
35
+ *
36
+ * `derivePlan` 把解包目录的路径记进 `plan.sources`,而 `stageTrees` 要从那里取树。
37
+ * 回调一返回 `dispose()` 就把目录删了 —— 于是「先 withVerifiedArtifact 拿到 dir,
38
+ * 再在外面 runTransaction」这种写法会在第 5 步找不到源目录。
39
+ *
40
+ * 多个制品要**嵌套**:任意一层抛错,它自己和外层的隔离目录都会被清掉。
41
+ */
42
+ function withVerifiedArtifacts(items, parent, fn, acc = []) {
43
+ if (items.length === 0) return fn(acc);
44
+ const [head, ...tail] = items;
45
+ return withVerifiedArtifact({ bytes: head.bytes, record: head.record, parent }, (art) =>
46
+ withVerifiedArtifacts(tail, parent, fn, [...acc, { ...head, art }]));
47
+ }
48
+
49
+ /**
50
+ * §5.2 第 3 步的「磁盘余量 ≥ 新制品解压后 × 2」。
51
+ *
52
+ * ⚠️ **诚实边界**:这是**一次**快照检查,不是「期间持续检查」。规格第 4 步写的是
53
+ * 「期间持续检查空间」,而真正的持续检查要在解包循环内部做 —— 那在 `untar.mjs` 里,
54
+ * 不在本块的文件边界内。这里能给的只有「动手之前先看一眼」,
55
+ * 以及解包完成之后**再看一眼**(`stage` 之前)。这条写进交付汇报。
56
+ */
57
+ export function assertDiskSpace(target, needBytes) {
58
+ let s;
59
+ try { s = statfsSync(target); } catch { return { checked: false, reason: 'statfs 不可用' }; }
60
+ const free = Number(s.bavail) * Number(s.bsize);
61
+ const want = needBytes * 2;
62
+ if (free < want) {
63
+ const e = new Error(
64
+ `磁盘余量不足:${target} 只剩 ${free} 字节,本次至少需要 ${want}`
65
+ + `(新制品解压后 ${needBytes} 字节 × 2,04-install.md §5.2 第 3 步)`,
66
+ );
67
+ e.name = 'DiskFull';
68
+ e.exitCode = EXIT.UNSUPPORTED;
69
+ throw e;
70
+ }
71
+ return { checked: true, free, want };
72
+ }
73
+
74
+ /** 递归量一棵树的字节数(只数普通文件;目录项本身不计)。 */
75
+ export function treeBytes(dir) {
76
+ let total = 0;
77
+ (function rec(d) {
78
+ for (const n of readdirSync(d, { withFileTypes: true })) {
79
+ const p = join(d, n.name);
80
+ if (n.isDirectory()) rec(p);
81
+ else if (n.isFile()) total += statSync(p).size;
82
+ }
83
+ })(dir);
84
+ return total;
85
+ }
86
+
87
+ function fstypeOf(p) {
88
+ try { return mountEntryFor(p)?.type ?? 'unknown'; } catch { return 'unknown'; }
89
+ }
90
+
91
+ /**
92
+ * §8.2 遮蔽(D4):全局已存在同名 skill 时,项目级安装**默认拒绝**,需 `--shadow-global`。
93
+ * 🔴 给了 flag 就是用户**明确接受歧义** —— 输出里必须逐条标 `shadowed`,不是装完就算。
94
+ */
95
+ export function detectShadowed(names, { client, home, env }) {
96
+ const globalTarget = getAdapter(client).root({ scope: 'global', home, env });
97
+ const hit = [];
98
+ for (const n of names) if (existsSync(join(globalTarget, n))) hit.push(n);
99
+ return { globalTarget, shadowed: hit };
100
+ }
101
+
102
+ export async function cmdInstall(ctx, argv, out) {
103
+ const specs = [];
104
+ for (const a of argv) {
105
+ if (a === '--all') {
106
+ throw new UsageError(
107
+ '`install --all` 是 M2 的能力(09-cli.md §1 的阶段列)。'
108
+ + '日常「一键装一组」的正确入口是 `install pack:<name>` —— 那也在 M2。',
109
+ );
110
+ }
111
+ if (a.startsWith('-')) throw new UsageError(`install 不认得 flag ${a}`);
112
+ specs.push(a);
113
+ }
114
+ if (specs.length === 0) throw new UsageError('用法:skills-hub install <spec>…(至少一条)');
115
+
116
+ const queries = specs.map(parseSpec);
117
+ for (const q of queries) {
118
+ if (q.kind === 'pack') {
119
+ throw new UsageError(`pack 安装是 M2 的能力(09-cli.md §1 的阶段列):${q.raw}`);
120
+ }
121
+ }
122
+
123
+ // ── 目标解析(🔴 assertPlanOk 必须调) ──────────────────────────────────
124
+ const tplan = planTargets({
125
+ clients: ctx.clients,
126
+ scope: ctx.scope,
127
+ home: ctx.home,
128
+ env: ctx.env,
129
+ projectRoot: ctx.projectRoot,
130
+ createMissing: ctx.createMissing === 'all'
131
+ ? true
132
+ : Array.isArray(ctx.createMissing) && ctx.createMissing.length > 0,
133
+ });
134
+ // 🔴 展示,不吞:同一个读者读了多个被选中的 root 时 catalog 会出现重复条目
135
+ for (const w of tplan.warnings) out.warn(w);
136
+ assertPlanOk(tplan);
137
+
138
+ // `--create-missing <client>` 点名时,只对点到的那几个 client 生效
139
+ const named = Array.isArray(ctx.createMissing) ? new Set(ctx.createMissing) : null;
140
+ const selected = tplan.selected.filter((t) => !t.willCreate || ctx.createMissing === 'all' || named?.has(t.client));
141
+ for (const t of tplan.selected) {
142
+ if (t.willCreate && !selected.includes(t)) {
143
+ out.warn(`${t.client}/${t.scope} 的目录不存在,且 --create-missing 没有点到它:跳过`);
144
+ }
145
+ }
146
+
147
+ // §6 第 0 条:`skipped: 目录不存在` / `skipped: unsupported` **算成功**
148
+ for (const s of tplan.skipped) out.note(`跳过 ${s.client}/${s.scope}:${s.reason} —— ${s.message}`);
149
+
150
+ if (selected.length === 0) {
151
+ out.line('没有可安装的 target(全部跳过)。');
152
+ for (const s of tplan.skipped) out.line(` skipped ${s.client}/${s.scope} ${s.reason}`);
153
+ return out.emit('install', {
154
+ targets: [],
155
+ skipped: tplan.skipped.map((s) => ({ client: s.client, reason: s.reason, scope: s.scope })),
156
+ }, EXIT.OK);
157
+ }
158
+
159
+ // ── 解析当前快照(metadata 锁在 trust 内核里起落,本层不碰它) ───────────
160
+ const { snapshot: snap, stale, floor, pinned, verifier } = await resolveSnapshotForCommand(ctx);
161
+ if (stale) out.warn('timestamp 已过期:本次输出全部按 stale 处理(--allow-stale 已给)');
162
+
163
+ const records = queries.map((q) => resolveSpec(snap, q, { pre: ctx.pre, allowYanked: ctx.allowYanked }));
164
+ for (const r of records) {
165
+ if (r.status === 'yanked') out.warn(`🔴 ${r.id} 已被 yank,仍按 --allow-yanked 安装(会写进账本)`);
166
+ if (r.status === 'deprecated') out.warn(`${r.id} 的状态是 deprecated`);
167
+ }
168
+
169
+ // 客户端兼容性:record.clients 声明支持哪些 client
170
+ for (const t of selected) {
171
+ const bad = records.filter((r) => r.clients.length > 0 && !r.clients.includes(t.client));
172
+ if (bad.length) {
173
+ throw new ConflictError(
174
+ `${bad.map((r) => r.id).join(', ')} 未声明支持 client=${t.client}`
175
+ + `(声明的是 ${bad[0].clients.join(', ') || '(空)'})`,
176
+ { telemetryReason: 'unsupported-client' },
177
+ );
178
+ }
179
+ }
180
+
181
+ // ── §8.2 遮蔽:项目级安装 vs 全局同名 ───────────────────────────────────
182
+ const names = records.map((r) => r.name);
183
+ const shadowInfo = new Map();
184
+ if (ctx.scope === 'project') {
185
+ for (const t of selected) {
186
+ const d = detectShadowed(names, { client: t.client, home: ctx.home, env: ctx.env });
187
+ shadowInfo.set(t.client, d);
188
+ if (d.shadowed.length && !ctx.shadowGlobal) {
189
+ throw new ConflictError(
190
+ `全局已存在同名 skill:${d.shadowed.join(', ')}(在 ${d.globalTarget})。\n`
191
+ + ' 项目级安装**默认拒绝**(04-install.md §8.2 / D4):四端的项目级 / 全局优先级未知,\n'
192
+ + ' 装下去会产生一个我们无法承诺结果的歧义。\n'
193
+ + ' 明确接受这个歧义请给 --shadow-global;此后每一次相关输出都会标 [shadowed]。',
194
+ { telemetryReason: 'version-conflict' },
195
+ );
196
+ }
197
+ }
198
+ // §3.3:项目级安装必须让 git 忽略 adapter 派生的实际路径
199
+ const missing = missingGitignorePatterns(ctx.projectRoot, selected.map((t) => t.client));
200
+ if (missing.length) out.warn(gitignoreHint(ctx.projectRoot, selected.map((t) => t.client)));
201
+ }
202
+
203
+ // ── 建 target 目录:本次运行的第一个磁盘写入 ─────────────────────────────
204
+ // 🔴 `willCreate` 说的是**客户端目录**(`.claude`)在不在,那一层由 `--create-missing`
205
+ // 把关(§2.3:目录不存在不是失败,只有点名了才创建)。
206
+ // 但 target 是它下面的 `skills/` —— 客户端目录已经在、`skills/` 还没有,
207
+ // 是完全正常的现场,那一层是**我们的**目录,本来就该由我们建。
208
+ // 早先只在 `willCreate` 时建,于是这种现场会在取锁时 stat 出 ENOENT。
209
+ // 🔴 **不得声称「失败则磁盘未变」**(11-wire-contract.md §5 的统一口径):
210
+ // 这一步建出来的目录,后面任一步失败它都会留着。
211
+ // 这是**幂等初始化**,不是半截事务 —— 下一次运行照常用它。
212
+ for (const t of selected) {
213
+ // 🔴 建目录**之前**先查从可信 base 到 target 的整条路径链。
214
+ // 这一步早于取锁、也早于第 3 步的预检 —— 父级被换成软链时,
215
+ // 一个 mkdir 就能在仓外/家目录外造出目录(Codex 第三轮 P0-3)。
216
+ if (t.base) {
217
+ const rel = t.target.startsWith(`${t.base}/`) ? t.target.slice(t.base.length + 1) : null;
218
+ if (rel === null) {
219
+ throw new UnsupportedError(`target 不在 adapter 的可信 base 之下:${t.target} 不在 ${t.base} 里`);
220
+ }
221
+ try { assertNoSymlinkInChain(t.base, rel); } catch (e) {
222
+ throw new UnsupportedError(`建 target 目录前的路径链检查不通过:${e.message}`);
223
+ }
224
+ }
225
+ mkdirChainFsync(t.target);
226
+ }
227
+
228
+ // ── 取锁:repo → target(全序;metadata 已在解析阶段起落完毕)───────────
229
+ const results = [];
230
+ const t0 = Date.now();
231
+ withOrderedLocks(
232
+ {
233
+ baseFor: (p) => selected.find((t) => t.target === p)?.base ?? null,
234
+ projectRoot: ctx.scope === 'project' ? ctx.projectRoot : null,
235
+ targets: selected.map((t) => t.target),
236
+ },
237
+ ({ targets: ordered }) => {
238
+ const byPath = new Map(selected.map((t) => [t.target, t]));
239
+ for (const o of ordered) {
240
+ const t = byPath.get(o.path);
241
+ const started = Date.now();
242
+ try {
243
+ const r = installOneTarget(ctx, t, records, { snap, floor, pinned, out, verifier });
244
+ results.push({
245
+ ...r, client: t.client, scope: t.scope, target: t.target, ok: true, ms: Date.now() - started,
246
+ });
247
+ } catch (err) {
248
+ const cls = classify(err);
249
+ results.push({
250
+ client: t.client, scope: t.scope, target: t.target, ok: false,
251
+ ms: Date.now() - started, error: err.message, exit_code: cls.code, reason: cls.reason, _err: err,
252
+ });
253
+ }
254
+ }
255
+ },
256
+ );
257
+
258
+ // ── 埋点(🔴 事务之外、收尾处;record 不抛,也不放进关键路径)───────────
259
+ emitTelemetry(ctx, results, records, snap);
260
+
261
+ // ── §7:逐 target 结果表,即使全部成功。**不允许只打一句 done。** ────────
262
+ const okCount = results.filter((r) => r.ok).length;
263
+ const failed = results.filter((r) => !r.ok);
264
+ out.line(`install 结果(快照 ${snap.snapshot}${pinned ? ',--snapshot 钉住' : ''},共 ${results.length} 个 target):`);
265
+ for (const r of results) {
266
+ const a = annotations({
267
+ stale, offline: ctx.offline,
268
+ yanked: records.some((x) => x.status === 'yanked'),
269
+ degraded: records.some((x) => x.status === 'degraded'),
270
+ shadowed: (shadowInfo.get(r.client)?.shadowed.length ?? 0) > 0,
271
+ });
272
+ out.line(` ${r.ok ? 'ok ' : 'failed '}${r.client}/${r.scope} ${r.target}${annotationSuffix(a)}`);
273
+ if (!r.ok) out.line(` ${r.error.split('\n')[0]}`);
274
+ else out.line(` 第 ${r.generation} 代,装了 ${r.installed.join(', ') || '(无变化)'}`);
275
+ r.annotations = a;
276
+ }
277
+ for (const t of selected) out.line(`提示(${t.client}):${t.adapter.postInstallHint()}`);
278
+
279
+ let exit = EXIT.OK;
280
+ if (failed.length === results.length && failed.length > 0) exit = classify(failed[0]._err).code;
281
+ else if (failed.length > 0) exit = EXIT.PARTIAL;
282
+
283
+ const body = {
284
+ duration_ms: Date.now() - t0,
285
+ installed: records.map((r) => ({ artifact: r.id, name: r.name, status: r.status, version: r.version })),
286
+ pinned_snapshot: pinned ? snap.snapshot : undefined,
287
+ skipped: tplan.skipped.map((s) => ({ client: s.client, reason: s.reason, scope: s.scope })),
288
+ snapshot: snap.snapshot,
289
+ targets: results.map((r) => ({
290
+ annotations: r.annotations,
291
+ client: r.client,
292
+ error: r.ok ? undefined : r.error,
293
+ exit_code: r.ok ? 0 : r.exit_code,
294
+ generation: r.ok ? r.generation : undefined,
295
+ installed: r.ok ? r.installed : undefined,
296
+ ok: r.ok,
297
+ scope: r.scope,
298
+ target: r.target,
299
+ })),
300
+ };
301
+ if (exit !== EXIT.OK && exit !== EXIT.PARTIAL) {
302
+ // 全部失败:照最严重的那条单项错误报,并且**仍然**给出完整的逐 target 表
303
+ return out.emitError('install', classify(failed[0]._err), failed[0]._err, body);
304
+ }
305
+ return out.emit('install', body, exit);
306
+ }
307
+
308
+ /** 单个 target 的第 2–10 步。 */
309
+ function installOneTarget(ctx, t, records, { snap, floor, out, verifier }) {
310
+ const target = t.target;
311
+ const P0 = layout(target);
312
+ const onLedgerChanged = makeLockfileHook(ctx, { snap, verifier });
313
+
314
+ // ── 第 2 步:残留事务分流(2a → 2b-1 → 2b-2 → 2c,内核已按顺序编排)────
315
+ const rec = recover(target, { mode: 'auto', onLedgerChanged, keepGenerations: ctx.keepGenerations });
316
+ if (rec.outcome !== 'nothing') out.note(`${t.client}:入口分流 —— ${rec.outcome}`);
317
+
318
+ // ── 第 3 步:预检(🔴 assertPrecheckOk 必须调)─────────────────────────
319
+ const pre = precheckTarget(target, { base: t.base, targetSet: [target] });
320
+ assertPrecheckOk(pre);
321
+
322
+ // ── 第 4 步:取字节 → 验签/验资产/解包/manifest 绑定 ────────────────────
323
+ const items = records.map((r) => ({ record: r, bytes: ctx.registry.fetchAsset(r) }));
324
+
325
+ // 🔴 作用域版包住**整个**事务:plan.sources 指向解包目录,回调一返回它就没了
326
+ return withVerifiedArtifacts(items, join(target, STATE_DIR), (verified) => {
327
+ const need = verified.reduce((n, v) => n + treeBytes(v.art.dir), 0);
328
+ assertDiskSpace(target, need);
329
+
330
+ // 🔴 §4.1 的降级语义:水位缺失时**不静默扫描猜一个**。
331
+ // 必须在 bootstrap 账本**之前**调 —— 账本一旦写出,`hasHubContent()` 就为真,
332
+ // 于是「首次安装」会被误判成「本地历史被重置」而拒绝。
333
+ ensureGenerationWatermark(P0);
334
+ const ledgerExisted = existsSync(P0.ledger);
335
+ const targetMeta = {
336
+ client: t.client,
337
+ scope: t.scope,
338
+ path: target,
339
+ realpath: realpathSync(target),
340
+ fstype: fstypeOf(target),
341
+ };
342
+ const L = ledgerExisted ? readLedger(P0.ledger) : bootstrapLedger(P0, targetMeta);
343
+ const generation = nextGeneration(P0);
344
+ const at = nowUtc(ctx.now());
345
+
346
+ // 🔴 未被账本认领的同名目录:`derivePlan` 会抛 `Corrupt`(→ 退出码 5),
347
+ // 但这在语义上是**冲突未解决**(§6 第 3 条)。在这里先判、抛 ConflictError,
348
+ // 让退出码落对格 —— 不靠对内核的错误文案做正则。
349
+ const replace = new Set(ctx.replace);
350
+ for (const r of records) {
351
+ const dir = join(target, r.name);
352
+ if (!existsSync(dir)) continue;
353
+ if (Object.hasOwn(L.entries, r.name)) continue;
354
+ if (replace.has(r.name)) continue;
355
+ throw new ConflictError(
356
+ `${target}/${r.name} 是一个**未被账本认领**的同名目录:默认阻断(04-install.md §4.2)。\n`
357
+ + ` 出路只有 --replace ${r.name},或自行把它移走。🔴 没有泛化的 --force。`,
358
+ { telemetryReason: 'version-conflict' },
359
+ );
360
+ }
361
+
362
+ const roots = {};
363
+ const installReqs = [];
364
+ for (const v of verified) {
365
+ const r = v.record;
366
+ const rootKey = `direct:${r.id}`;
367
+ const intent = { no_bundled: ctx.noBundled, pre: ctx.pre };
368
+ if (ctx.allowYanked) intent.allow_yanked = true; // 账本如实记录本机历史
369
+ roots[rootKey] = {
370
+ artifact: r.id,
371
+ intent,
372
+ kind: 'direct',
373
+ requested_at: at,
374
+ snapshot: snap.snapshot,
375
+ tree_digest: r.tree_digest,
376
+ };
377
+ installReqs.push({
378
+ artifact: r.id,
379
+ installed_at: at,
380
+ name: r.name,
381
+ requested_by: [rootKey],
382
+ snapshot: snap.snapshot,
383
+ srcDir: v.art.dir,
384
+ tree_digest: r.tree_digest,
385
+ });
386
+ }
387
+
388
+ const plan = derivePlan({
389
+ generation,
390
+ install: installReqs,
391
+ ledger: L,
392
+ ledgerExisted,
393
+ replace,
394
+ roots,
395
+ target,
396
+ });
397
+
398
+ // 🔴 R-9:提交点之前的最后一刻复验 trust floor。`floor` **必须显式给**;
399
+ // 没有 registry 出处时显式写 null(这里永远有出处,但 floor 可能尚未 bootstrap)。
400
+ runTransaction(target, plan, {
401
+ floor: floor === null ? null : { expected: floor, stateDir: ctx.stateDir },
402
+ keepGenerations: ctx.keepGenerations,
403
+ now: at,
404
+ onLedgerChanged,
405
+ });
406
+
407
+ return { generation, installed: installReqs.map((r) => r.name) };
408
+ });
409
+ }
410
+
411
+ function emitTelemetry(ctx, results, records, snap) {
412
+ const rec = ctx.record;
413
+ if (!rec) return;
414
+ for (const r of results) {
415
+ for (const a of records) {
416
+ // 🔴 `reason` 只能来自 REASONS 有限代码表;`record()` 自己不抛,但传错值会被它内部
417
+ // 的 assertValidEvent 拒掉并静默丢事件 —— 所以 reason 由 classify() 产出。
418
+ rec({
419
+ artifact: a.id,
420
+ client: r.client,
421
+ kind: 'install',
422
+ ms: r.ms,
423
+ reason: r.ok ? undefined : r.reason,
424
+ result: r.ok ? 'ok' : 'failed',
425
+ scope: r.scope,
426
+ version: a.version,
427
+ });
428
+ }
429
+ }
430
+ }