@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.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/bin/skills-hub.mjs +26 -0
- package/package.json +44 -0
- package/src/adapters/index.mjs +832 -0
- package/src/artifact.mjs +376 -0
- package/src/atomic-fs.mjs +166 -0
- package/src/attestation.mjs +136 -0
- package/src/canonical-json.mjs +147 -0
- package/src/cli.mjs +208 -0
- package/src/commands/check.mjs +295 -0
- package/src/commands/context.mjs +235 -0
- package/src/commands/install.mjs +430 -0
- package/src/commands/locks.mjs +197 -0
- package/src/commands/output.mjs +127 -0
- package/src/commands/query.mjs +266 -0
- package/src/commands/recover.mjs +438 -0
- package/src/commands/registry.mjs +123 -0
- package/src/commands/resolve.mjs +171 -0
- package/src/commands/snapshot-access.mjs +91 -0
- package/src/commands/sync-lock.mjs +189 -0
- package/src/crc32c.mjs +27 -0
- package/src/exit-codes.mjs +265 -0
- package/src/fault-inject.mjs +379 -0
- package/src/install.mjs +732 -0
- package/src/journal.mjs +435 -0
- package/src/ledger.mjs +671 -0
- package/src/lock.mjs +98 -0
- package/src/lockfile.mjs +0 -0
- package/src/pack.mjs +792 -0
- package/src/packer.mjs +351 -0
- package/src/plan.mjs +519 -0
- package/src/recover.mjs +1345 -0
- package/src/safe-fs.mjs +252 -0
- package/src/sigstore.mjs +480 -0
- package/src/snapshot.mjs +528 -0
- package/src/stats.mjs +59 -0
- package/src/target.mjs +738 -0
- package/src/telemetry.mjs +393 -0
- package/src/tree-digest.mjs +103 -0
- package/src/trust-roots/README.md +31 -0
- package/src/trust-roots/sigstore-public-good.json +126 -0
- package/src/trust.mjs +563 -0
- package/src/untar.mjs +570 -0
- package/src/upload.mjs +268 -0
- package/src/vendor.mjs +465 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// 加锁 —— 04-install.md §5.1「加锁顺序(全序,防死锁)」。
|
|
2
|
+
//
|
|
3
|
+
// metadata 锁(仅验证与推进 trust floor,用完立即释放)
|
|
4
|
+
// → repo 锁(仅项目级;保护 lockfile 重算与写入)
|
|
5
|
+
// → target 锁(多个时按 (st_dev, st_ino) 字节序升序)
|
|
6
|
+
//
|
|
7
|
+
// 🔴 **不得存在任何「先 target 后 repo」的路径。**
|
|
8
|
+
//
|
|
9
|
+
// 🔴 metadata 锁**不由本模块取**。`trust.advanceTrustFloor()` 自己 acquire/release
|
|
10
|
+
// (src/trust.mjs),而 `src/lock.mjs` **禁止重入** —— 命令面再包一层就会在
|
|
11
|
+
// 第二次 acquire 时直接抛「本进程已持有」。因此正确的形状是:
|
|
12
|
+
// ① 先跑完解析阶段(`snapshot.resolveCurrent()`,metadata 锁在它内部起落);
|
|
13
|
+
// ② 解析返回之后,再按 repo → target 取事务锁。
|
|
14
|
+
// 全序仍然成立:metadata 的持有区间整个位于 repo/target 之前。
|
|
15
|
+
//
|
|
16
|
+
// 🔴 后续取锁失败时,对已持有的锁**逐一 ROLLBACK + close 再退出**,
|
|
17
|
+
// 不得带着半套锁做任何事。
|
|
18
|
+
|
|
19
|
+
import { statSync, lstatSync } from 'node:fs';
|
|
20
|
+
import { join, sep } from 'node:path';
|
|
21
|
+
import { acquire } from '../lock.mjs';
|
|
22
|
+
import { mkdirChainFsync } from '../atomic-fs.mjs';
|
|
23
|
+
import { STATE_DIR } from '../adapters/index.mjs';
|
|
24
|
+
import { assertNoSymlinkInChain } from '../safe-fs.mjs';
|
|
25
|
+
import { EXIT, UnsupportedError } from '../exit-codes.mjs';
|
|
26
|
+
|
|
27
|
+
/** repo 锁的位置(§8.1)。 */
|
|
28
|
+
export const repoLockPath = (projectRoot) => join(projectRoot, '.geoly-skills.lock.db');
|
|
29
|
+
|
|
30
|
+
/** target 锁的位置(§5.1.1):**target 自身的一部分**,别名路径打开同一个 inode。 */
|
|
31
|
+
export const targetLockPath = (target) => join(target, STATE_DIR, 'lock.db');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* (st_dev, st_ino) 的定宽排序键。
|
|
35
|
+
*
|
|
36
|
+
* 🔴 **不是按 realpath 排序**:bind alias 下 realpath 不稳定,
|
|
37
|
+
* 两个进程可能算出相反顺序而互相卡死(§5.1 明确禁止)。
|
|
38
|
+
* 🔴 定宽 hex 才等价于「字节序」:`String(dev)` 会让 9 排在 10 后面。
|
|
39
|
+
*/
|
|
40
|
+
function physicalKey(dev, ino) {
|
|
41
|
+
const h = (n) => BigInt(n).toString(16).padStart(20, '0');
|
|
42
|
+
return `${h(dev)}:${h(ino)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 按 §5.1 对 target 集合**去重 + 排序**。
|
|
47
|
+
*
|
|
48
|
+
* @param {string[]} targets 绝对路径。**必须已经存在** —— `--create-missing` 的建目录
|
|
49
|
+
* 动作发生在这之前(见 `install.mjs` 的注释:它是本次运行的第一个磁盘写入,
|
|
50
|
+
* 失败时留下的是一个空的客户端目录,而不是半个事务)。
|
|
51
|
+
* @returns {{path:string, key:string, dev:number, ino:number, aliases:string[]}[]}
|
|
52
|
+
*/
|
|
53
|
+
export function orderTargets(targets) {
|
|
54
|
+
const byKey = new Map();
|
|
55
|
+
for (const t of targets) {
|
|
56
|
+
const st = statSync(t); // target 用 stat:它可以是被 realpath 过的正常目录
|
|
57
|
+
const key = physicalKey(st.dev, st.ino);
|
|
58
|
+
const cur = byKey.get(key);
|
|
59
|
+
if (cur) { cur.aliases.push(t); continue; } // 🔴 bind-alias 去重
|
|
60
|
+
byKey.set(key, { path: t, key, dev: st.dev, ino: st.ino, aliases: [] });
|
|
61
|
+
}
|
|
62
|
+
return [...byKey.values()].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 🔴 **取锁之前**的窄门:`.geoly/` 与三个锁文件都不许是 symlink。
|
|
67
|
+
*
|
|
68
|
+
* 为什么不能等到 §5.2 第 3 步的预检:锁**就在 `.geoly/` 里面**(§5.1.1),
|
|
69
|
+
* 而取锁是第 1 步。`new DatabaseSync(path)` 会**跟随** symlink ——
|
|
70
|
+
* 把 `.geoly` 或 `lock.db` 换成指向别处的软链,我们就会在 target 之外
|
|
71
|
+
* 创建并写入 holder 表,而内核那道 `assertStatePathsNoSymlink()` 要到第 2 步才跑。
|
|
72
|
+
* 那不只是「错误码落错格」,是一个**写入路径穿越**。
|
|
73
|
+
*
|
|
74
|
+
* ⚠️ **这是窄门,不是完整的 §3.4 检查**:它只覆盖取锁这一步真正会打开的那几个路径,
|
|
75
|
+
* 而且与 R-1 / R-2 同样是 TOCTOU 的(Node 不暴露 `openat`)。
|
|
76
|
+
* 完整的路径链检查仍然在第 3 步的 `precheckTarget()` 里。
|
|
77
|
+
*/
|
|
78
|
+
function assertLockPathsNotSymlink(target, base = null) {
|
|
79
|
+
const reject = (msg) => {
|
|
80
|
+
// 🔴 这是 §3.4 那一类(路径安全),按 §6 第 9 条落 **9**,不是 5。
|
|
81
|
+
// 早先它被命名成 Corrupt 因而落 5 —— 那会把「路径穿越」报成「需要 recover」。
|
|
82
|
+
throw new UnsupportedError(msg, { telemetryReason: 'unknown' });
|
|
83
|
+
};
|
|
84
|
+
// ① 整条路径链:从可信 base 一路查到 target 自己。
|
|
85
|
+
// 只查最后一个节点是不够的 —— 父级被换成软链同样能把状态写到 target 之外。
|
|
86
|
+
if (base) {
|
|
87
|
+
const rel = target.startsWith(base + sep) ? target.slice(base.length + 1) : null;
|
|
88
|
+
if (rel === null) reject(`target 不在可信 base 之下:${target} 不在 ${base} 里`);
|
|
89
|
+
try { assertNoSymlinkInChain(base, rel); } catch (e) { reject(`取锁前的路径链检查不通过:${e.message}`); }
|
|
90
|
+
}
|
|
91
|
+
// ② 取锁这一步真正会 open 的那几个路径,逐个 lstat 无跟随
|
|
92
|
+
const state = join(target, STATE_DIR);
|
|
93
|
+
for (const p of [state, join(state, 'lock.db'), join(state, 'lock.db-wal'), join(state, 'lock.db-shm')]) {
|
|
94
|
+
let st;
|
|
95
|
+
try { st = lstatSync(p); } catch (e) {
|
|
96
|
+
if (e?.code === 'ENOENT') continue; // 还没有 —— 稍后由我们自己建
|
|
97
|
+
reject(`取锁前无法 lstat ${p}(${e.code})—— 看不见就不能声称它是安全的`);
|
|
98
|
+
}
|
|
99
|
+
if (st.isSymbolicLink()) {
|
|
100
|
+
reject(`拒绝取锁:${p} 是 symlink(04-install.md §3.4)。`
|
|
101
|
+
+ '锁文件由 SQLite 直接打开,会跟随软链把状态写到 target 之外。');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const st = lstatSync(target);
|
|
105
|
+
if (!st.isDirectory()) reject(`拒绝取锁:${target} 不是普通目录(lstat 无跟随判定)`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** repo 锁也要过同一道门 —— 它同样是被 SQLite 直接 open 的。 */
|
|
109
|
+
function assertRepoLockNotSymlink(projectRoot) {
|
|
110
|
+
const base = repoLockPath(projectRoot);
|
|
111
|
+
for (const p of [base, `${base}-wal`, `${base}-shm`]) {
|
|
112
|
+
let st;
|
|
113
|
+
try { st = lstatSync(p); } catch (e) {
|
|
114
|
+
if (e?.code === 'ENOENT') continue;
|
|
115
|
+
throw new UnsupportedError(`取 repo 锁前无法 lstat ${p}(${e.code})`, { telemetryReason: 'unknown' });
|
|
116
|
+
}
|
|
117
|
+
if (st.isSymbolicLink()) {
|
|
118
|
+
throw new UnsupportedError(
|
|
119
|
+
`拒绝取 repo 锁:${p} 是 symlink(04-install.md §3.4)`, { telemetryReason: 'unknown' },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 按全序取 repo → target 锁,跑 `fn`,然后**逆序**释放。
|
|
127
|
+
*
|
|
128
|
+
* @param {object} o
|
|
129
|
+
* @param {string|null} o.projectRoot 非 null 时取 repo 锁
|
|
130
|
+
* @param {string[]} o.targets 物理 target 目录(已存在)
|
|
131
|
+
* @param {(held:{targets:object[], repo:string|null}) => any} fn
|
|
132
|
+
*
|
|
133
|
+
* 🔴 `fn` 抛错时锁按 `commit: true` 释放。理由:holder 行是**诊断信息**,
|
|
134
|
+
* 不是事务数据;ROLLBACK 掉它只会让下一个人看到更旧的 pid。
|
|
135
|
+
* 真正需要 ROLLBACK 的是**取锁过程中途失败**那条路径(见下面的 catch)——
|
|
136
|
+
* 那时半套锁一个都不该留下痕迹。
|
|
137
|
+
*/
|
|
138
|
+
export function withOrderedLocks({ projectRoot = null, targets = [], baseFor = () => null }, fn) {
|
|
139
|
+
const ordered = orderTargets(targets);
|
|
140
|
+
const held = [];
|
|
141
|
+
try {
|
|
142
|
+
// ① repo 锁(仅项目级)
|
|
143
|
+
if (projectRoot !== null) {
|
|
144
|
+
assertRepoLockNotSymlink(projectRoot);
|
|
145
|
+
held.push(acquire(repoLockPath(projectRoot)));
|
|
146
|
+
}
|
|
147
|
+
// ② target 锁,去重后按 (st_dev, st_ino) 升序
|
|
148
|
+
for (const t of ordered) {
|
|
149
|
+
// 🔴 `<target>/.geoly/` 由取锁这一步建出来(锁就在它里面,§5.1.1)。
|
|
150
|
+
// 这是**规范自身的形状**,不是我们多建的:崩在这里会留下一个空的
|
|
151
|
+
// `.geoly/lock.db`,下一次运行照常取锁、照常继续 —— 幂等,不是半截事务。
|
|
152
|
+
// 🔴 建不出 `.geoly/` 就是 §6 第 10 条那一格(「target 不可写(无法创建
|
|
153
|
+
// `<target>/.geoly/`)」)—— 那正是这条退出码的字面定义。
|
|
154
|
+
// 不映射的话它会以一条裸 EACCES 的身份落到「认不出来的错」里去。
|
|
155
|
+
// ⚠️ 这道判定发生在**第 1 步**(取锁),比 §5.2 第 3 步的预检更早 ——
|
|
156
|
+
// 因为锁就在 `.geoly/` 里面,建不出来就根本进不到预检。
|
|
157
|
+
assertLockPathsNotSymlink(t.path, baseFor(t.path));
|
|
158
|
+
try {
|
|
159
|
+
mkdirChainFsync(join(t.path, STATE_DIR));
|
|
160
|
+
} catch (e) {
|
|
161
|
+
// 🔴 并发建目录:`mkdirChainFsync` 是 existsSync → mkdirSync,不是原子的。
|
|
162
|
+
// 两个进程同时启动时输家会拿到裸 EEXIST —— 那不是错误,目录已经在了。
|
|
163
|
+
if (e?.code === 'EEXIST') {
|
|
164
|
+
// 🔴 EEXIST 只说明「那个名字被占了」,**不说明占它的是一个普通目录**。
|
|
165
|
+
// 一律放行的话,一个抢在我们前面建出来的 symlink / 普通文件就穿过了窄门。
|
|
166
|
+
const st = lstatSync(join(t.path, STATE_DIR));
|
|
167
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
168
|
+
throw new UnsupportedError(
|
|
169
|
+
`${join(t.path, STATE_DIR)} 被一个非普通目录占用(并发竞态或有人塞了东西)`,
|
|
170
|
+
{ telemetryReason: 'unknown' },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
else if (e?.code === 'EACCES' || e?.code === 'EPERM' || e?.code === 'EROFS') {
|
|
175
|
+
const err = new Error(
|
|
176
|
+
`target 不可写:建不出 ${join(t.path, STATE_DIR)}(${e.code})。`
|
|
177
|
+
+ '锁与全部事务状态都在这个目录里,建不出来就无从开始(04-install.md §3.6 / §5.1.1)。',
|
|
178
|
+
);
|
|
179
|
+
err.name = 'TargetNotWritable';
|
|
180
|
+
err.exitCode = EXIT.NOT_WRITABLE;
|
|
181
|
+
err.telemetryReason = 'target-not-writable';
|
|
182
|
+
throw err;
|
|
183
|
+
} else throw e;
|
|
184
|
+
}
|
|
185
|
+
held.push(acquire(targetLockPath(t.path)));
|
|
186
|
+
}
|
|
187
|
+
} catch (e) {
|
|
188
|
+
// 🔴 半套锁:逐一 ROLLBACK + close 再把错抛出去
|
|
189
|
+
for (const r of held.reverse()) { try { r({ commit: false }); } catch { /* 已经坏了 */ } }
|
|
190
|
+
throw e;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
return fn({ targets: ordered, repo: projectRoot === null ? null : repoLockPath(projectRoot) });
|
|
194
|
+
} finally {
|
|
195
|
+
for (const r of held.reverse()) { try { r(); } catch { /* 释放尽力而为 */ } }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// 输出契约 —— 09-cli.md §7。
|
|
2
|
+
//
|
|
3
|
+
// · 人类输出 stdout;进度与告警 stderr。
|
|
4
|
+
// · `--json` 时 stdout **只有**一个 JSON 对象 —— 🔴 **成功、用法错误、解析失败、
|
|
5
|
+
// 锁被占用、内部错误,每一条路径都是**。只在成功路径上给一个 JSON 对象,
|
|
6
|
+
// 等于让脚本在失败时读到空 stdout 然后自己去猜。
|
|
7
|
+
// · 每次 `install` 结尾必须打印逐 target 结果表,即使全部成功。**不允许只打一句 done。**
|
|
8
|
+
// · stale / offline / yanked / degraded / shadowed 必须在**每一次**相关输出里重复标注。
|
|
9
|
+
// 🔴 因此这些标注挂在**每一个** target / artifact 结果对象上,不是只挂顶层一份 ——
|
|
10
|
+
// 顶层一份的话,一份逐行表格里没有一行看得出自己是 stale 的。
|
|
11
|
+
//
|
|
12
|
+
// 🔴 **JSON 的 schema 名不是 wire contract 的一部分。**
|
|
13
|
+
// 11-wire-contract.md §1 的适用对象清单里**没有** CLI 的 `--json` 输出。
|
|
14
|
+
// 这里复用 §3 的 canonical 生成规则(同一个 `stringify`,schema 首位、字节序、
|
|
15
|
+
// 2 空格、结尾一个 \n、非 ASCII 小写 hex 转义),但 `geoly.skills.cli.<cmd>/1`
|
|
16
|
+
// 这个名字与它的字段表**尚未登记进规范**。这条写进交付汇报,由规格侧决定是否收编。
|
|
17
|
+
|
|
18
|
+
import { stringify } from '../canonical-json.mjs';
|
|
19
|
+
|
|
20
|
+
/** 每个命令一个 schema 名。🔴 见上:**未登记**,不得对外宣称是 wire contract。 */
|
|
21
|
+
export const CLI_SCHEMA = (cmd) => `geoly.skills.cli.${cmd}/1`;
|
|
22
|
+
|
|
23
|
+
/** 🔴 canonical JSON 不接受 `undefined`(`enc` 会抛「不支持的类型」)。递归剔除。 */
|
|
24
|
+
export function pruneUndefined(v) {
|
|
25
|
+
if (Array.isArray(v)) return v.map(pruneUndefined);
|
|
26
|
+
if (v !== null && typeof v === 'object') {
|
|
27
|
+
const out = {};
|
|
28
|
+
for (const [k, val] of Object.entries(v)) if (val !== undefined) out[k] = pruneUndefined(val);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
return v;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 标注集合。🔴 挂在**每一个**结果对象上,不是只挂顶层。
|
|
36
|
+
* 值一律是布尔,`false` 也写出来 —— 「字段缺席」在这里会被误读成「查过了,没事」。
|
|
37
|
+
*/
|
|
38
|
+
export function annotations({ stale = false, offline = false, yanked = false, degraded = false, shadowed = false } = {}) {
|
|
39
|
+
return { degraded, offline, shadowed, stale, yanked };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 人类可读的标注后缀,例如 ` [stale] [offline] [yanked]`。空则返回空串。 */
|
|
43
|
+
export function annotationSuffix(a) {
|
|
44
|
+
const on = [];
|
|
45
|
+
if (a.stale) on.push('stale');
|
|
46
|
+
if (a.offline) on.push('offline');
|
|
47
|
+
if (a.yanked) on.push('yanked');
|
|
48
|
+
if (a.degraded) on.push('degraded');
|
|
49
|
+
if (a.shadowed) on.push('shadowed');
|
|
50
|
+
return on.length ? ` ${on.map((s) => `[${s}]`).join(' ')}` : '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class Output {
|
|
54
|
+
constructor({ json = false, stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
55
|
+
this.json = json;
|
|
56
|
+
this._out = stdout;
|
|
57
|
+
this._err = stderr;
|
|
58
|
+
this._warnings = [];
|
|
59
|
+
this._emitted = false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 进度 —— 永远 stderr。`--json` 下也照常输出(stdout 才是被要求干净的那个)。 */
|
|
63
|
+
note(msg) { this._err.write(`${msg}\n`); }
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 告警 —— stderr,**并且**进 JSON 的 `warnings`。
|
|
67
|
+
* `planTargets()` 的 `duplicate-catalog` 走这条:09-cli.md 要求展示给用户,不得吞掉。
|
|
68
|
+
*/
|
|
69
|
+
warn(obj) {
|
|
70
|
+
const w = typeof obj === 'string' ? { kind: 'general', message: obj } : obj;
|
|
71
|
+
this._warnings.push(w);
|
|
72
|
+
this._err.write(`⚠️ ${w.message}\n`);
|
|
73
|
+
return w;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
warnings() { return this._warnings.slice(); }
|
|
77
|
+
|
|
78
|
+
/** 人类行 —— stdout。`--json` 下**一个字节都不写**(stdout 只能有那一个对象)。 */
|
|
79
|
+
line(msg = '') { if (!this.json) this._out.write(`${msg}\n`); }
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 收尾。`--json` 时写出**唯一**那个对象;否则什么都不写(人类输出已经逐行打过了)。
|
|
83
|
+
* 🔴 只允许调一次 —— 调两次就破了「stdout 只有一个 JSON 对象」。
|
|
84
|
+
*/
|
|
85
|
+
emit(cmd, body, exitCode) {
|
|
86
|
+
if (this._emitted) throw new Error('输出契约:一次运行只能 emit 一个 JSON 对象');
|
|
87
|
+
this._emitted = true;
|
|
88
|
+
if (!this.json) return exitCode;
|
|
89
|
+
const doc = pruneUndefined({
|
|
90
|
+
schema: CLI_SCHEMA(cmd),
|
|
91
|
+
command: cmd,
|
|
92
|
+
exit_code: exitCode,
|
|
93
|
+
ok: exitCode === 0,
|
|
94
|
+
warnings: this._warnings,
|
|
95
|
+
...body,
|
|
96
|
+
});
|
|
97
|
+
this._out.write(stringify(doc));
|
|
98
|
+
return exitCode;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 失败收尾。`--json` 下同样只有一个对象。
|
|
103
|
+
* @param {string} cmd
|
|
104
|
+
* @param {{code:number, unclassified:boolean}} cls `exit-codes.classify()` 的产物
|
|
105
|
+
*/
|
|
106
|
+
emitError(cmd, cls, err, extra = {}) {
|
|
107
|
+
const message = err?.message ?? String(err);
|
|
108
|
+
if (!this.json) {
|
|
109
|
+
this._err.write(`${cls.unclassified ? '内部错误(CLI 自身的 bug,不是制品有问题):' : ''}${message}\n`);
|
|
110
|
+
}
|
|
111
|
+
return this.emit(cmd, {
|
|
112
|
+
error: pruneUndefined({
|
|
113
|
+
exit_code: cls.code,
|
|
114
|
+
// 机器可读的错误名:内核错误用它自己的 name,我们自己的用类名
|
|
115
|
+
name: err?.name ?? 'Error',
|
|
116
|
+
message,
|
|
117
|
+
unclassified: cls.unclassified,
|
|
118
|
+
// 预检聚合错带全部违规项 —— 🔴 JSON 里**始终保留全部**,不只报优先级最高那条
|
|
119
|
+
violations: Array.isArray(err?.violations)
|
|
120
|
+
? err.violations.map((v) => ({ code: v.code, message: v.message, path: v.path }))
|
|
121
|
+
: undefined,
|
|
122
|
+
candidates: Array.isArray(err?.candidates) ? err.candidates : undefined,
|
|
123
|
+
}),
|
|
124
|
+
...extra,
|
|
125
|
+
}, cls.code);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// `list` / `search` / `why` —— 09-cli.md §1。
|
|
2
|
+
//
|
|
3
|
+
// 取锁表(§5.1):这三个命令 **只取 metadata 锁,且仅在需要验签时** ——
|
|
4
|
+
// 而 metadata 锁在 `trust.advanceTrustFloor()` 内部起落,命令面不碰。
|
|
5
|
+
// **不取 repo 锁、不取 target 锁**:它们只读。
|
|
6
|
+
//
|
|
7
|
+
// 🔴 只读也要如实标注:stale / offline / yanked / degraded / shadowed
|
|
8
|
+
// 在**每一次**相关输出里重复出现(§7),因此挂在每一行上,不是只挂顶层。
|
|
9
|
+
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { layout, readLedger } from '../ledger.mjs';
|
|
12
|
+
import { planTargets, assertPlanOk, getAdapter } from '../adapters/index.mjs';
|
|
13
|
+
import { UsageError, EXIT } from '../exit-codes.mjs';
|
|
14
|
+
import { resolveSnapshotForCommand, isDegradable } from './snapshot-access.mjs';
|
|
15
|
+
import { annotations, annotationSuffix } from './output.mjs';
|
|
16
|
+
|
|
17
|
+
/** 枚举本次命令覆盖到的 target(不要求存在 .geoly)。 */
|
|
18
|
+
export function targetsFor(ctx, out) {
|
|
19
|
+
const tplan = planTargets({
|
|
20
|
+
clients: ctx.clients,
|
|
21
|
+
scope: ctx.scope,
|
|
22
|
+
home: ctx.home,
|
|
23
|
+
env: ctx.env,
|
|
24
|
+
projectRoot: ctx.projectRoot,
|
|
25
|
+
});
|
|
26
|
+
for (const w of tplan.warnings) out.warn(w);
|
|
27
|
+
assertPlanOk(tplan);
|
|
28
|
+
return tplan;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 读一个 target 的账本;没有就返回 null(不是错)。 */
|
|
32
|
+
export function ledgerOf(target) {
|
|
33
|
+
const P = layout(target);
|
|
34
|
+
if (!existsSync(P.ledger)) return null;
|
|
35
|
+
return readLedger(P.ledger);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 已装清单:`[{ client, scope, target, name, entry }]` */
|
|
39
|
+
export function installedEntries(tplan) {
|
|
40
|
+
const rows = [];
|
|
41
|
+
for (const t of tplan.selected) {
|
|
42
|
+
const L = ledgerOf(t.target);
|
|
43
|
+
if (!L) continue;
|
|
44
|
+
for (const [name, e] of Object.entries(L.entries)) {
|
|
45
|
+
rows.push({ client: t.client, scope: t.scope, target: t.target, name, entry: e });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return rows;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** §8.2:项目级/全局并存 —— 如实报告,🔴 **不声称哪份生效**。 */
|
|
52
|
+
export function shadowMap(ctx, tplan) {
|
|
53
|
+
const m = new Map();
|
|
54
|
+
if (ctx.scope !== 'project') return m;
|
|
55
|
+
for (const t of tplan.selected) {
|
|
56
|
+
const g = getAdapter(t.client).root({ scope: 'global', home: ctx.home, env: ctx.env });
|
|
57
|
+
const L = ledgerOf(g);
|
|
58
|
+
if (!L) continue;
|
|
59
|
+
for (const name of Object.keys(L.entries)) {
|
|
60
|
+
if (!m.has(name)) m.set(name, []);
|
|
61
|
+
m.get(name).push({ client: t.client, globalTarget: g, artifact: L.entries[name].artifact });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return m;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function cmdList(ctx, argv, out) {
|
|
68
|
+
const o = { packs: false, installed: false, outdated: false };
|
|
69
|
+
for (const a of argv) {
|
|
70
|
+
if (a === '--packs') o.packs = true;
|
|
71
|
+
else if (a === '--installed') o.installed = true;
|
|
72
|
+
else if (a === '--outdated') o.outdated = true;
|
|
73
|
+
else throw new UsageError(`list 不认得 ${a}`);
|
|
74
|
+
}
|
|
75
|
+
const tplan = targetsFor(ctx, out);
|
|
76
|
+
const installed = installedEntries(tplan);
|
|
77
|
+
const shadowed = shadowMap(ctx, tplan);
|
|
78
|
+
|
|
79
|
+
// 🔴 `--installed` 是纯本地的:**不解析快照**,因此离线也一定能跑。
|
|
80
|
+
// `--outdated` 与默认列表要拿当前快照对照。
|
|
81
|
+
let snap = null;
|
|
82
|
+
let stale = false;
|
|
83
|
+
let snapError = null;
|
|
84
|
+
if (!o.installed || o.outdated) {
|
|
85
|
+
try {
|
|
86
|
+
const r = await resolveSnapshotForCommand(ctx);
|
|
87
|
+
snap = r.snapshot;
|
|
88
|
+
stale = r.stale;
|
|
89
|
+
} catch (e) {
|
|
90
|
+
// 🔴 只有「取不到」(退出码 6)可以降级。stale(8)、完整性(2)、
|
|
91
|
+
// min-cli(11)必须原样抛出去 —— 吞掉它们等于让 `list` 在一张过期或
|
|
92
|
+
// 被篡改的信任根上照常绿灯。
|
|
93
|
+
if (!isDegradable(e)) throw e;
|
|
94
|
+
// 离线 / 缓存未命中不该让 `list` 整个失败,但**绝不能**默默当成
|
|
95
|
+
// 「没有更新」。如实降级:只列本地,并把每一行标成 offline。
|
|
96
|
+
snapError = e;
|
|
97
|
+
out.warn(`取不到当前快照(${e.message.split('\n')[0]}):只列本地已装,远端信息标为未知`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (stale) out.warn('timestamp 已过期:以下每一行都按 stale 处理');
|
|
101
|
+
|
|
102
|
+
const rows = [];
|
|
103
|
+
if (o.installed || snap === null) {
|
|
104
|
+
for (const r of installed) {
|
|
105
|
+
rows.push({
|
|
106
|
+
annotations: annotations({
|
|
107
|
+
stale,
|
|
108
|
+
offline: ctx.offline || snapError !== null,
|
|
109
|
+
shadowed: shadowed.has(r.name),
|
|
110
|
+
}),
|
|
111
|
+
artifact: r.entry.artifact,
|
|
112
|
+
client: r.client,
|
|
113
|
+
installed: true,
|
|
114
|
+
latest: null,
|
|
115
|
+
name: r.name,
|
|
116
|
+
scope: r.scope,
|
|
117
|
+
snapshot: r.entry.snapshot,
|
|
118
|
+
target: r.target,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
const kind = o.packs ? 'pack' : 'skill';
|
|
123
|
+
const byName = new Map(installed.map((r) => [`${r.client} ${r.name}`, r]));
|
|
124
|
+
for (const rec of snap.artifacts) {
|
|
125
|
+
if (rec.kind !== kind) continue;
|
|
126
|
+
// 每个 name 只列它的 latest 那一行(latest 投影已由 parseSnapshot 校验自洽)
|
|
127
|
+
if (snap.latest[`${rec.kind}:${rec.namespace}/${rec.name}`] !== rec.version) continue;
|
|
128
|
+
for (const t of tplan.selected) {
|
|
129
|
+
const hit = byName.get(`${t.client} ${rec.name}`);
|
|
130
|
+
const isOutdated = hit ? hit.entry.artifact !== rec.id : false;
|
|
131
|
+
if (o.outdated && !isOutdated) continue;
|
|
132
|
+
rows.push({
|
|
133
|
+
annotations: annotations({
|
|
134
|
+
stale,
|
|
135
|
+
offline: ctx.offline,
|
|
136
|
+
yanked: rec.status === 'yanked',
|
|
137
|
+
degraded: rec.status === 'degraded',
|
|
138
|
+
shadowed: shadowed.has(rec.name),
|
|
139
|
+
}),
|
|
140
|
+
artifact: hit ? hit.entry.artifact : null,
|
|
141
|
+
client: t.client,
|
|
142
|
+
installed: Boolean(hit),
|
|
143
|
+
latest: rec.id,
|
|
144
|
+
name: rec.name,
|
|
145
|
+
scope: t.scope,
|
|
146
|
+
snapshot: hit ? hit.entry.snapshot : null,
|
|
147
|
+
target: t.target,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
out.line(`list(${rows.length} 行${snap ? `,快照 ${snap.snapshot}` : ',仅本地'}):`);
|
|
154
|
+
for (const r of rows) {
|
|
155
|
+
const state = r.installed
|
|
156
|
+
? (r.latest && r.latest !== r.artifact ? 'outdated' : 'installed')
|
|
157
|
+
: 'available';
|
|
158
|
+
out.line(` ${state.padEnd(10)}${r.client}/${r.scope} ${r.name} `
|
|
159
|
+
+ `${r.artifact ?? r.latest}${annotationSuffix(r.annotations)}`);
|
|
160
|
+
}
|
|
161
|
+
// §8.2:如实并列,**不声称哪份生效**
|
|
162
|
+
for (const [name, hits] of shadowed) {
|
|
163
|
+
out.line(` [!] ${name} 项目级与全局并存(全局在 ${hits[0].globalTarget})——`
|
|
164
|
+
+ '生效者取决于客户端,本工具不做判断(04-install.md §8.2)');
|
|
165
|
+
}
|
|
166
|
+
return out.emit('list', {
|
|
167
|
+
rows,
|
|
168
|
+
shadowed: [...shadowed.keys()].sort(),
|
|
169
|
+
snapshot: snap ? snap.snapshot : undefined,
|
|
170
|
+
snapshot_unavailable: snapError ? snapError.message : undefined,
|
|
171
|
+
}, EXIT.OK);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function cmdSearch(ctx, argv, out) {
|
|
175
|
+
for (const a of argv) if (a.startsWith('-')) throw new UsageError(`search 不认得 ${a}`);
|
|
176
|
+
const kws = argv.filter((a) => !a.startsWith('-'));
|
|
177
|
+
if (kws.length === 0) throw new UsageError('用法:skills-hub search <kw>');
|
|
178
|
+
const { snapshot: snap, stale } = await resolveSnapshotForCommand(ctx);
|
|
179
|
+
if (stale) out.warn('timestamp 已过期:以下每一行都按 stale 处理');
|
|
180
|
+
|
|
181
|
+
// 🔴 规格说搜 name / description,但 `description` 在**载荷 manifest** 里,
|
|
182
|
+
// 快照 record 一个字段都没有它(见 snapshot.mjs 的 RECORD_KEYS)。
|
|
183
|
+
// 因此这里只搜 name 与 id,并**如实说明**,不假装搜过 description。
|
|
184
|
+
const needle = kws.map((k) => k.toLowerCase());
|
|
185
|
+
const hits = snap.artifacts.filter(
|
|
186
|
+
(r) => needle.every((k) => r.name.includes(k) || r.id.toLowerCase().includes(k)),
|
|
187
|
+
);
|
|
188
|
+
out.warn('只搜了 name/id:description 在制品的载荷 manifest 里,快照 record 不携带它。');
|
|
189
|
+
out.line(`search(${hits.length} 命中,快照 ${snap.snapshot}):`);
|
|
190
|
+
const mk = (r) => annotations({
|
|
191
|
+
stale,
|
|
192
|
+
offline: ctx.offline,
|
|
193
|
+
yanked: r.status === 'yanked',
|
|
194
|
+
degraded: r.status === 'degraded',
|
|
195
|
+
});
|
|
196
|
+
for (const r of hits) {
|
|
197
|
+
out.line(` ${r.id} status=${r.status} `
|
|
198
|
+
+ `clients=${r.clients.join(',') || '(未声明)'}${annotationSuffix(mk(r))}`);
|
|
199
|
+
}
|
|
200
|
+
return out.emit('search', {
|
|
201
|
+
hits: hits.map((r) => ({
|
|
202
|
+
annotations: mk(r),
|
|
203
|
+
artifact: r.id,
|
|
204
|
+
clients: r.clients,
|
|
205
|
+
name: r.name,
|
|
206
|
+
status: r.status,
|
|
207
|
+
})),
|
|
208
|
+
searched_fields: ['id', 'name'],
|
|
209
|
+
snapshot: snap.snapshot,
|
|
210
|
+
}, EXIT.OK);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** `why <name>` —— 谁请求装的(读账本 `roots` + `requested_by`)。纯本地,不取快照。 */
|
|
214
|
+
export async function cmdWhy(ctx, argv, out) {
|
|
215
|
+
for (const a of argv) if (a.startsWith('-')) throw new UsageError(`why 不认得 ${a}`);
|
|
216
|
+
const names = argv.filter((a) => !a.startsWith('-'));
|
|
217
|
+
if (names.length !== 1) throw new UsageError('用法:skills-hub why <name>(恰好一个)');
|
|
218
|
+
const name = names[0];
|
|
219
|
+
const tplan = targetsFor(ctx, out);
|
|
220
|
+
const shadowed = shadowMap(ctx, tplan);
|
|
221
|
+
|
|
222
|
+
const found = [];
|
|
223
|
+
for (const t of tplan.selected) {
|
|
224
|
+
const L = ledgerOf(t.target);
|
|
225
|
+
if (!L) continue;
|
|
226
|
+
const e = L.entries[name];
|
|
227
|
+
if (!e) continue;
|
|
228
|
+
found.push({
|
|
229
|
+
annotations: annotations({ offline: ctx.offline, shadowed: shadowed.has(name) }),
|
|
230
|
+
artifact: e.artifact,
|
|
231
|
+
client: t.client,
|
|
232
|
+
generation: e.generation,
|
|
233
|
+
installed_at: e.installed_at,
|
|
234
|
+
requested_by: e.requested_by.map((rk) => ({
|
|
235
|
+
// root 可能已经被删(refcount 归零前后的中间态),如实报告成悬挂边
|
|
236
|
+
record: L.roots[rk] ?? null,
|
|
237
|
+
root: rk,
|
|
238
|
+
})),
|
|
239
|
+
scope: t.scope,
|
|
240
|
+
snapshot: e.snapshot,
|
|
241
|
+
state: e.state,
|
|
242
|
+
target: t.target,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (found.length === 0) {
|
|
247
|
+
out.line(`${name} 不在任何被检查的 target 的账本里。`);
|
|
248
|
+
return out.emit('why', { entries: [], name }, EXIT.OK);
|
|
249
|
+
}
|
|
250
|
+
out.line(`why ${name}:`);
|
|
251
|
+
for (const f of found) {
|
|
252
|
+
out.line(` ${f.client}/${f.scope} ${f.target}${annotationSuffix(f.annotations)}`);
|
|
253
|
+
out.line(` artifact=${f.artifact} snapshot=${f.snapshot} `
|
|
254
|
+
+ `第 ${f.generation} 代 state=${f.state} installed_at=${f.installed_at}`);
|
|
255
|
+
for (const r of f.requested_by) {
|
|
256
|
+
out.line(` <- ${r.root}${r.record ? '' : '(该 root 已不在账本里 —— 悬挂边)'}`);
|
|
257
|
+
if (r.record) {
|
|
258
|
+
out.line(` kind=${r.record.kind} snapshot=${r.record.snapshot} `
|
|
259
|
+
+ `intent={no_bundled:${r.record.intent.no_bundled}, pre:${r.record.intent.pre}`
|
|
260
|
+
+ `${r.record.intent.allow_yanked ? ', allow_yanked:true' : ''}} `
|
|
261
|
+
+ `requested_at=${r.record.requested_at}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return out.emit('why', { entries: found, name }, EXIT.OK);
|
|
266
|
+
}
|