@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,147 @@
|
|
|
1
|
+
// canonical JSON —— 规范见 docs/m0/11-wire-contract.md §3
|
|
2
|
+
// CLI 与 CI 共用同一份实现(§11 要求)。
|
|
3
|
+
|
|
4
|
+
const ESC = { '"': '\\"', '\\': '\\\\', '\b': '\\b', '\f': '\\f', '\n': '\\n', '\r': '\\r', '\t': '\\t' };
|
|
5
|
+
|
|
6
|
+
/** 字符串按 §3.4:非 ASCII 一律 \uXXXX 小写 hex;BMP 外用代理对;未配对代理 → 抛错 */
|
|
7
|
+
export function encodeString(str) {
|
|
8
|
+
let out = '"';
|
|
9
|
+
for (let i = 0; i < str.length; i++) {
|
|
10
|
+
const ch = str[i];
|
|
11
|
+
const code = str.charCodeAt(i);
|
|
12
|
+
if (ESC[ch]) { out += ESC[ch]; continue; }
|
|
13
|
+
if (code < 0x20) { out += '\\u' + code.toString(16).padStart(4, '0'); continue; }
|
|
14
|
+
if (code < 0x7f) { out += ch; continue; }
|
|
15
|
+
// 代理对完整性
|
|
16
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
17
|
+
const next = str.charCodeAt(i + 1);
|
|
18
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) {
|
|
19
|
+
throw new Error(`canonical-json: 未配对的高位代理 at ${i}`);
|
|
20
|
+
}
|
|
21
|
+
out += '\\u' + code.toString(16).padStart(4, '0');
|
|
22
|
+
out += '\\u' + next.toString(16).padStart(4, '0');
|
|
23
|
+
i++; continue;
|
|
24
|
+
}
|
|
25
|
+
if (code >= 0xdc00 && code <= 0xdfff) {
|
|
26
|
+
throw new Error(`canonical-json: 未配对的低位代理 at ${i}`);
|
|
27
|
+
}
|
|
28
|
+
out += '\\u' + code.toString(16).padStart(4, '0');
|
|
29
|
+
}
|
|
30
|
+
return out + '"';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function keyOrder(a, b) {
|
|
34
|
+
// §3.1:schema 强制置首,其余按 UTF-8 字节序
|
|
35
|
+
if (a === 'schema') return -1;
|
|
36
|
+
if (b === 'schema') return 1;
|
|
37
|
+
const ba = Buffer.from(a, 'utf8'), bb = Buffer.from(b, 'utf8');
|
|
38
|
+
return Buffer.compare(ba, bb);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function enc(value, indent, path) {
|
|
42
|
+
const pad = ' '.repeat(indent), padIn = ' '.repeat(indent + 1);
|
|
43
|
+
if (value === null) return 'null';
|
|
44
|
+
const t = typeof value;
|
|
45
|
+
if (t === 'boolean') return value ? 'true' : 'false';
|
|
46
|
+
if (t === 'number') {
|
|
47
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
48
|
+
throw new Error(`canonical-json: ${path} 只允许 [0, 2^53-1] 的非负整数,得到 ${value}`);
|
|
49
|
+
}
|
|
50
|
+
return String(value);
|
|
51
|
+
}
|
|
52
|
+
if (t === 'string') return encodeString(value);
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
if (value.length === 0) return '[]';
|
|
55
|
+
const items = value.map((v, i) => padIn + enc(v, indent + 1, `${path}[${i}]`));
|
|
56
|
+
return '[\n' + items.join(',\n') + '\n' + pad + ']';
|
|
57
|
+
}
|
|
58
|
+
if (t === 'object') {
|
|
59
|
+
const keys = Object.keys(value).sort(keyOrder);
|
|
60
|
+
if (keys.length === 0) return '{}';
|
|
61
|
+
const items = keys.map(k => padIn + encodeString(k) + ': ' + enc(value[k], indent + 1, `${path}.${k}`));
|
|
62
|
+
return '{\n' + items.join(',\n') + '\n' + pad + '}';
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`canonical-json: ${path} 不支持的类型 ${t}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** canonical 序列化,结尾恰好一个 \n */
|
|
68
|
+
export function stringify(value) { return enc(value, 0, '$') + '\n'; }
|
|
69
|
+
|
|
70
|
+
/** canonical 字节 */
|
|
71
|
+
export function toBytes(value) { return Buffer.from(stringify(value), 'utf8'); }
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 严格解析:拒绝重复 key。
|
|
75
|
+
* 标准 JSON.parse 会静默取最后一个 —— §3.2 要求报错。
|
|
76
|
+
*/
|
|
77
|
+
export function parseStrict(text) {
|
|
78
|
+
const result = JSON.parse(text); // 先让它做语法校验
|
|
79
|
+
detectDuplicateKeys(text);
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 🔴 把 JSON 字符串字面量解码成实际的 key。
|
|
85
|
+
*
|
|
86
|
+
* 重复 key 的判据必须是**解码后**的值:`{"a":1,"\u0061":2}` 里两个 key
|
|
87
|
+
* 都是 `a`,是重复的,但它们的原文完全不同。早先直接比原文,于是这种写法
|
|
88
|
+
* 被静默放行、JSON.parse 取最后一个 —— 一个可以用来绕过任何「按 key 校验」的口子。
|
|
89
|
+
*
|
|
90
|
+
* ⚠️ 这与埋点那次是同一类错误:**比较未归一化的形式**。
|
|
91
|
+
* 凡是「同一个东西有多种写法」的地方,比之前先归一。
|
|
92
|
+
*/
|
|
93
|
+
function decodeJsonString(raw) {
|
|
94
|
+
let out = '';
|
|
95
|
+
for (let i = 0; i < raw.length; i++) {
|
|
96
|
+
if (raw[i] !== '\\') { out += raw[i]; continue; }
|
|
97
|
+
const c = raw[++i];
|
|
98
|
+
switch (c) {
|
|
99
|
+
case '"': out += '"'; break;
|
|
100
|
+
case '\\': out += '\\'; break;
|
|
101
|
+
case '/': out += '/'; break;
|
|
102
|
+
case 'b': out += '\b'; break;
|
|
103
|
+
case 'f': out += '\f'; break;
|
|
104
|
+
case 'n': out += '\n'; break;
|
|
105
|
+
case 'r': out += '\r'; break;
|
|
106
|
+
case 't': out += '\t'; break;
|
|
107
|
+
case 'u':
|
|
108
|
+
out += String.fromCharCode(parseInt(raw.slice(i + 1, i + 5), 16));
|
|
109
|
+
i += 4;
|
|
110
|
+
break;
|
|
111
|
+
default: out += c; // JSON.parse 已经验过语法,走不到这里
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 独立扫描原文,发现同一对象内的重复 key(按**解码后**的 key 比对) */
|
|
118
|
+
export function detectDuplicateKeys(text) {
|
|
119
|
+
const stack = [];
|
|
120
|
+
let i = 0;
|
|
121
|
+
const n = text.length;
|
|
122
|
+
while (i < n) {
|
|
123
|
+
const c = text[i];
|
|
124
|
+
if (c === '"') {
|
|
125
|
+
let j = i + 1, buf = '';
|
|
126
|
+
while (j < n) {
|
|
127
|
+
if (text[j] === '\\') { buf += text[j] + text[j + 1]; j += 2; continue; }
|
|
128
|
+
if (text[j] === '"') break;
|
|
129
|
+
buf += text[j]; j++;
|
|
130
|
+
}
|
|
131
|
+
i = j + 1;
|
|
132
|
+
// 跳过空白,看是不是 key(后面跟冒号)
|
|
133
|
+
let k = i; while (k < n && /\s/.test(text[k])) k++;
|
|
134
|
+
if (text[k] === ':' && stack.length && stack[stack.length - 1].type === 'obj') {
|
|
135
|
+
const top = stack[stack.length - 1];
|
|
136
|
+
const key = decodeJsonString(buf);
|
|
137
|
+
if (top.keys.has(key)) throw new Error(`canonical-json: 重复 key ${JSON.stringify(key)}`);
|
|
138
|
+
top.keys.add(key);
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (c === '{') { stack.push({ type: 'obj', keys: new Set() }); i++; continue; }
|
|
143
|
+
if (c === '[') { stack.push({ type: 'arr' }); i++; continue; }
|
|
144
|
+
if (c === '}' || c === ']') { stack.pop(); i++; continue; }
|
|
145
|
+
i++;
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// 命令分发与顶层错误处理 —— 09-cli.md §1 / §2 / §6 / §7。
|
|
2
|
+
//
|
|
3
|
+
// 🔴 §7 的输出契约在这里兜底,不在各命令里各写一遍:
|
|
4
|
+
// `--json` 时 stdout **只有一个 JSON 对象** —— 成功、用法错误、解析失败、
|
|
5
|
+
// 锁被占用、内部错误,**每一条路径都是**。只在成功路径上给对象,等于让脚本
|
|
6
|
+
// 在失败时读到空 stdout 然后自己去猜。
|
|
7
|
+
//
|
|
8
|
+
// 🔴 `--offline` 在 `makeContext()` 里就置位(它要早于任何可能出网的代码求值),
|
|
9
|
+
// 埋点上报继承这条边界(`telemetry.offline()` 读的是同一个变量)。
|
|
10
|
+
|
|
11
|
+
import { acquire } from './lock.mjs';
|
|
12
|
+
import { parseGlobals, makeContext } from './commands/context.mjs';
|
|
13
|
+
import { Output } from './commands/output.mjs';
|
|
14
|
+
import { createCacheRegistry } from './commands/registry.mjs';
|
|
15
|
+
import { EXIT, classify, UsageError } from './exit-codes.mjs';
|
|
16
|
+
|
|
17
|
+
const HELP = `skills-hub —— geoly skill 分发(M1)
|
|
18
|
+
|
|
19
|
+
命令:
|
|
20
|
+
install <spec>… 装制品(direct;pack 与 --all 在 M2)
|
|
21
|
+
list [--packs|--installed|--outdated]
|
|
22
|
+
search <kw>…
|
|
23
|
+
check 两阶段校验(字节 / 现在还该不该用)
|
|
24
|
+
why <name> 谁请求装的
|
|
25
|
+
sync-lock 在 repo 锁下重算 geoly-skills.lock.json
|
|
26
|
+
recover [--continue|--rollback|--reinstall] [--from-generation N [--only x]…]
|
|
27
|
+
[--reset-generation N] [--resume-cleanup]
|
|
28
|
+
|
|
29
|
+
stats [--json] [--export <file>] 本地埋点报表
|
|
30
|
+
telemetry <status|flush> 埋点/上报开关与队列
|
|
31
|
+
|
|
32
|
+
全局 flag(09-cli.md §2):
|
|
33
|
+
--clients <list> 默认 = 本机已存在的全部;含未安装 client 是硬错误
|
|
34
|
+
--create-missing <client|all>
|
|
35
|
+
--project [path] 装到仓库内,维护 geoly-skills.lock.json
|
|
36
|
+
--shadow-global 全局已有同名时,项目级安装才允许继续
|
|
37
|
+
--snapshot <N> 钉快照复现
|
|
38
|
+
--offline 只用缓存;未命中即失败。禁止一切网络出口
|
|
39
|
+
--allow-stale timestamp 过期时才允许继续,输出持续标注 stale
|
|
40
|
+
--allow-yanked 仅取证;大声告警并写进账本(不放行 degraded)
|
|
41
|
+
--replace <name> 点名替换未被账本认领的同名目录
|
|
42
|
+
--no-bundled / --pre / --json / --yes
|
|
43
|
+
--keep-generations <N> attic 保留代数,默认 3
|
|
44
|
+
|
|
45
|
+
🔴 没有 --no-verify、--insecure、--force、--force-unlock、--assume-idle。
|
|
46
|
+
验签与摘要校验不可关闭;替换必须点名;陈旧/yank/全量各有独立开关。
|
|
47
|
+
|
|
48
|
+
退出码(§6):0 成功 · 1 用法 · 2 完整性 · 3 冲突 · 4 部分失败 · 5 需 recover/锁忙
|
|
49
|
+
6 网络 · 7 认证 · 8 陈旧 · 9 平台/文件系统 · 10 不可写 · 11 CLI 版本过低
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
/** 需要完整运行时上下文(快照、锁、target)的命令。 */
|
|
53
|
+
const COMMANDS = {
|
|
54
|
+
install: () => import('./commands/install.mjs').then((m) => m.cmdInstall),
|
|
55
|
+
list: () => import('./commands/query.mjs').then((m) => m.cmdList),
|
|
56
|
+
search: () => import('./commands/query.mjs').then((m) => m.cmdSearch),
|
|
57
|
+
why: () => import('./commands/query.mjs').then((m) => m.cmdWhy),
|
|
58
|
+
check: () => import('./commands/check.mjs').then((m) => m.cmdCheck),
|
|
59
|
+
'sync-lock': () => import('./commands/sync-lock.mjs').then((m) => m.cmdSyncLock),
|
|
60
|
+
recover: () => import('./commands/recover.mjs').then((m) => m.cmdRecover),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string[]} argv
|
|
65
|
+
* @param {object} [deps] 🔴 **只有进程内的调用方给得了** —— argv 与环境变量都到不了这里。
|
|
66
|
+
* 生产入口 `bin/skills-hub.mjs` 一个 dep 都不传,所以生产路径上
|
|
67
|
+
* 验签器一定是真的、registry 一定是缓存适配器。
|
|
68
|
+
*/
|
|
69
|
+
export async function main(argv, deps = {}) {
|
|
70
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
71
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
72
|
+
|
|
73
|
+
// ── 内部探针(测试用;不进 --help) ──────────────────────────────────────
|
|
74
|
+
if (argv[0] === '--warn-probe') return warnProbe(stdout);
|
|
75
|
+
if (argv[0] === '--fault-probe') return faultProbe(stdout);
|
|
76
|
+
|
|
77
|
+
// 🔴 全局 flag 要**先**解析:`--json` 决定失败时怎么输出。
|
|
78
|
+
// 解析本身失败时只能按非 JSON 报 —— 那时我们还不知道用户要不要 JSON。
|
|
79
|
+
// 🔴 解析**本身**失败时也必须给 JSON —— 那时 `parseGlobals` 还没跑完,
|
|
80
|
+
// 所以先做一次廉价预扫。少了这一步,`--json` 下的用法错误会让 stdout 全空,
|
|
81
|
+
// 脚本读到 0 字节只能去猜。
|
|
82
|
+
let parsed;
|
|
83
|
+
const preJson = argv.includes('--json') || argv.includes('--json=true');
|
|
84
|
+
try {
|
|
85
|
+
parsed = parseGlobals(argv);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
const plain = new Output({ json: preJson, stdout, stderr });
|
|
88
|
+
return plain.emitError(argv[0] ?? 'skills-hub', classify(err), err);
|
|
89
|
+
}
|
|
90
|
+
const { globals, rest } = parsed;
|
|
91
|
+
// 🔴 `--offline` 必须在**任何**可能出网的分流之前置位。
|
|
92
|
+
// 早先它只在 `makeContext()` 里置位,而 `telemetry flush` 走的是下面那条
|
|
93
|
+
// 不经过 makeContext 的捷径 —— 于是 `skills-hub --offline telemetry flush`
|
|
94
|
+
// 会照常 POST 出去(upload.flush 的一票否决读的正是这个变量)。
|
|
95
|
+
// 这是 §2「离线模式禁止任何网络出口」的**直接违反**,不是边角情况。
|
|
96
|
+
if (globals.offline) {
|
|
97
|
+
// 🔴 两个都要设,缺一不可:
|
|
98
|
+
// · `deps.env` —— ctx 与 adapter 读的是它;
|
|
99
|
+
// · `process.env` —— **telemetry / upload 读的是它**(telemetry.offline())。
|
|
100
|
+
// 只设注入的那个,`--offline` 就管不住上报 —— 而 `--offline` 承诺的是
|
|
101
|
+
// 「整个 CLI 不得有任何网络出口」,那是一条**进程级**保证。
|
|
102
|
+
// ⚠️ 这是**进程级且粘住**的:一个 CLI 进程只跑一条命令,
|
|
103
|
+
// 「置位之后不再撤销」正是「整个 CLI 不得有任何网络出口」该有的形状。
|
|
104
|
+
process.env.GEOLY_OFFLINE = '1';
|
|
105
|
+
if (deps.env && deps.env !== process.env) deps.env.GEOLY_OFFLINE = '1';
|
|
106
|
+
}
|
|
107
|
+
const out = new Output({ json: globals.json, stdout, stderr });
|
|
108
|
+
const cmd = rest[0];
|
|
109
|
+
|
|
110
|
+
if (cmd === undefined || cmd === '-h' || cmd === '--help' || cmd === 'help') {
|
|
111
|
+
if (globals.json) return out.emit('help', { help: HELP }, EXIT.OK);
|
|
112
|
+
stdout.write(HELP);
|
|
113
|
+
return EXIT.OK;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 埋点两个子命令是纯本地的,不需要快照/锁/target
|
|
117
|
+
if (cmd === 'stats') return cmdStats(rest.slice(1), globals, stdout, stderr);
|
|
118
|
+
if (cmd === 'telemetry') return cmdTelemetry(rest.slice(1), stdout, stderr);
|
|
119
|
+
|
|
120
|
+
const load = COMMANDS[cmd];
|
|
121
|
+
if (!load) {
|
|
122
|
+
const err = new UsageError(`未知命令:${cmd}\n可用:${Object.keys(COMMANDS).sort().join(' / ')}`);
|
|
123
|
+
return out.emitError(cmd, classify(err), err);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const base = makeContext(globals, deps);
|
|
128
|
+
const registry = base.registryFactory
|
|
129
|
+
? base.registryFactory({ cacheDir: base.cacheDir, offline: base.offline })
|
|
130
|
+
: createCacheRegistry({ cacheDir: base.cacheDir, offline: base.offline });
|
|
131
|
+
const record = base.record ?? (await import('./telemetry.mjs')).record;
|
|
132
|
+
const ctx = Object.freeze({ ...base, record, registry });
|
|
133
|
+
const run = await load();
|
|
134
|
+
return await run(ctx, rest.slice(1), out);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
const cls = classify(err);
|
|
137
|
+
return out.emitError(cmd, cls, err);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── 内部探针 ─────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
async function warnProbe(stdout) {
|
|
144
|
+
const { mkdtempSync } = await import('node:fs');
|
|
145
|
+
const { tmpdir } = await import('node:os');
|
|
146
|
+
const { join } = await import('node:path');
|
|
147
|
+
acquire(join(mkdtempSync(join(tmpdir(), 'probe-')), 'l.db'))();
|
|
148
|
+
stdout.write('PROBE_OK\n');
|
|
149
|
+
return EXIT.OK;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function faultProbe(stdout) {
|
|
153
|
+
const fi = await import('./fault-inject.mjs');
|
|
154
|
+
const leaked = Object.keys(process.env).filter((k) => k.startsWith('GEOLY_FAULT'));
|
|
155
|
+
const s = fi.state();
|
|
156
|
+
stdout.write(`leaked=${leaked.join(',')} locked=${s.LOCKED} armed=${s.ARMED} active=${s.ACTIVE}\n`);
|
|
157
|
+
return EXIT.OK;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── 埋点子命令(M0 命令面之外,保留原有行为) ───────────────────────────────
|
|
161
|
+
|
|
162
|
+
async function cmdStats(args, globals, stdout, stderr) {
|
|
163
|
+
const { stats, textReport } = await import('./stats.mjs');
|
|
164
|
+
const { exportJson } = await import('./telemetry.mjs');
|
|
165
|
+
const { agg, events } = stats();
|
|
166
|
+
|
|
167
|
+
const exportIdx = args.indexOf('--export');
|
|
168
|
+
if (exportIdx !== -1) {
|
|
169
|
+
const dest = args[exportIdx + 1];
|
|
170
|
+
if (!dest) { stderr.write('--export 需要一个文件路径\n'); return EXIT.USAGE; }
|
|
171
|
+
const { writeAtomic } = await import('./atomic-fs.mjs');
|
|
172
|
+
writeAtomic(dest, exportJson(events));
|
|
173
|
+
stderr.write(`已导出 ${events.length} 条事件到 ${dest}\n`);
|
|
174
|
+
return EXIT.OK;
|
|
175
|
+
}
|
|
176
|
+
if (globals.json) { stdout.write(`${JSON.stringify(agg, null, 2)}\n`); return EXIT.OK; }
|
|
177
|
+
stdout.write(textReport(agg));
|
|
178
|
+
return EXIT.OK;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function cmdTelemetry(args, stdout, stderr) {
|
|
182
|
+
const tm = await import('./telemetry.mjs');
|
|
183
|
+
const up = await import('./upload.mjs');
|
|
184
|
+
switch (args[0]) {
|
|
185
|
+
case 'status': {
|
|
186
|
+
let ep;
|
|
187
|
+
try { ep = up.endpoint(); } catch (e) { ep = `无效(${e.message})`; }
|
|
188
|
+
const uploadOff = !tm.enabled() ? '关(埋点整体已关)'
|
|
189
|
+
: tm.offline() ? '关(--offline)'
|
|
190
|
+
: '关(GEOLY_TELEMETRY_UPLOAD=0)';
|
|
191
|
+
stdout.write(`埋点 ${tm.enabled() ? '开' : '关(GEOLY_TELEMETRY=0)'}\n`);
|
|
192
|
+
stdout.write(`上报 ${tm.uploadEnabled() ? '开' : uploadOff}\n`);
|
|
193
|
+
stdout.write(`端点 ${ep ?? '未配置 —— 纯本地'}\n`);
|
|
194
|
+
stdout.write(`待上报 ${tm.readAll().length} 条\n`);
|
|
195
|
+
stdout.write(`报表历史 ${tm.readHistory().length} 条(上报不消费它)\n`);
|
|
196
|
+
stdout.write(`状态目录 ${tm.stateDir()}\n`);
|
|
197
|
+
return EXIT.OK;
|
|
198
|
+
}
|
|
199
|
+
case 'flush': {
|
|
200
|
+
const r = await up.flush();
|
|
201
|
+
stdout.write(r.skipped ? `未上报:${r.reason}\n` : `已上报 ${r.sent} 条\n`);
|
|
202
|
+
return r.skipped && r.reason?.startsWith('error:') ? EXIT.USAGE : EXIT.OK;
|
|
203
|
+
}
|
|
204
|
+
default:
|
|
205
|
+
stderr.write('用法:skills-hub telemetry <status|flush>\n');
|
|
206
|
+
return EXIT.USAGE;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// `check` —— 09-cli.md §4「两阶段」。
|
|
2
|
+
//
|
|
3
|
+
// | 问题 | 依据 |
|
|
4
|
+
// |---|---|
|
|
5
|
+
// | 磁盘上的字节对不对 | 账本记录的**安装时快照**(按 02-registry.md §6.1 的历史读取路径取回并验签),重算树摘要比对 |
|
|
6
|
+
// | 这东西现在还该不该用 | **当前 timestamp 指向的快照**里的 `status` / `yanked` / advisory |
|
|
7
|
+
//
|
|
8
|
+
// 🔴 `--offline` 时第二问**答不了** → 输出标注
|
|
9
|
+
// `状态未知(离线,最后验证于 <时间>)`,**不得默认为「正常」**。
|
|
10
|
+
// 这不是「降级成乐观」,是「明说没查」。两者在自动化里差别巨大:
|
|
11
|
+
// 前者会让 CI 绿灯放行一个已经被 yank 的制品。
|
|
12
|
+
//
|
|
13
|
+
// 🔴 还必须如实报告项目级/全局并存,**不声称哪份生效**(04-install.md §8.2)。
|
|
14
|
+
//
|
|
15
|
+
// 🔴 check **只读**:不取 repo 锁、不取 target 锁(§5.1 的取锁表)。
|
|
16
|
+
// 因此它看到的是一个**可能正在被别人改**的现场 —— 报告里如实带上这一点。
|
|
17
|
+
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { treeDigest } from '../tree-digest.mjs';
|
|
21
|
+
import { layout } from '../ledger.mjs';
|
|
22
|
+
import { listJournalGenerations, readJournal } from '../journal.mjs';
|
|
23
|
+
import { readLockfile, projectLockfile } from '../lockfile.mjs';
|
|
24
|
+
import { stringify } from '../canonical-json.mjs';
|
|
25
|
+
import { REPO } from '../trust.mjs';
|
|
26
|
+
import { UsageError, EXIT } from '../exit-codes.mjs';
|
|
27
|
+
import { getVerifier, historicalReader, resolveSnapshotForCommand, isDegradable } from './snapshot-access.mjs';
|
|
28
|
+
import { annotations, annotationSuffix } from './output.mjs';
|
|
29
|
+
import { targetsFor, ledgerOf, shadowMap } from './query.mjs';
|
|
30
|
+
import { LOCKFILE_NAME } from './sync-lock.mjs';
|
|
31
|
+
|
|
32
|
+
/** 第二问答不上来时的**固定**措辞。测试直接断言这个串。 */
|
|
33
|
+
export const UNKNOWN_STATUS = (lastVerifiedAt) =>
|
|
34
|
+
`状态未知(离线,最后验证于 ${lastVerifiedAt ?? '从未验证'})`;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 第一问:磁盘字节 vs 账本记录的**安装时快照**。
|
|
38
|
+
*
|
|
39
|
+
* 🔴 「重算树摘要比对」不是「和账本里的 tree_digest 比」就完了 ——
|
|
40
|
+
* 账本是本机文件,能改账本的人也能改目录。判据必须回到**已验签的历史快照**:
|
|
41
|
+
* 磁盘实测摘要 == 历史快照 record 的 tree_digest,
|
|
42
|
+
* 并且账本记的那一份也要等于它(三方一致),否则就是账本被改过。
|
|
43
|
+
*/
|
|
44
|
+
export function checkBytes(target, name, entry, readHistorical) {
|
|
45
|
+
const dir = join(target, name);
|
|
46
|
+
if (!existsSync(dir)) {
|
|
47
|
+
return { ok: false, name, why: 'missing', message: `${dir} 不存在,但账本记着它` };
|
|
48
|
+
}
|
|
49
|
+
let actual;
|
|
50
|
+
try { actual = treeDigest(dir); } catch (e) {
|
|
51
|
+
return { ok: false, name, why: 'unreadable', message: `${dir} 算不出树摘要:${e.message}` };
|
|
52
|
+
}
|
|
53
|
+
let expected = null;
|
|
54
|
+
let source = 'ledger';
|
|
55
|
+
try {
|
|
56
|
+
const snap = readHistorical(entry.snapshot);
|
|
57
|
+
const rec = snap.artifacts.find((r) => r.id === entry.artifact);
|
|
58
|
+
if (rec) { expected = rec.tree_digest; source = `snapshot:${entry.snapshot}`; }
|
|
59
|
+
} catch (e) {
|
|
60
|
+
// 取不回安装时快照 → **不能**退回去只信账本然后报「一切正常」。
|
|
61
|
+
// 如实报「未证明」,并且不算通过。
|
|
62
|
+
return {
|
|
63
|
+
actual,
|
|
64
|
+
ok: false,
|
|
65
|
+
name,
|
|
66
|
+
why: 'unproven',
|
|
67
|
+
message: `取不回安装时快照 ${entry.snapshot}(${e.message.split('\n')[0]})——`
|
|
68
|
+
+ '字节对不对**无法证明**,不按通过处理',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (expected === null) {
|
|
72
|
+
return {
|
|
73
|
+
actual,
|
|
74
|
+
ok: false,
|
|
75
|
+
name,
|
|
76
|
+
why: 'unproven',
|
|
77
|
+
message: `安装时快照 ${entry.snapshot} 里没有 ${entry.artifact} 的 record —— 无法证明`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (entry.tree_digest !== expected) {
|
|
81
|
+
return {
|
|
82
|
+
actual, expected, ok: false, name, why: 'ledger-mismatch',
|
|
83
|
+
message: `账本记的 tree_digest 与已验签快照里的不符(账本 ${entry.tree_digest} != 快照 ${expected})`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (actual !== expected) {
|
|
87
|
+
return {
|
|
88
|
+
actual, expected, ok: false, name, why: 'digest-mismatch',
|
|
89
|
+
message: `磁盘实测 ${actual} != 已验签快照记录的 ${expected}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { actual, expected, ok: true, name, source, why: null };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 第二问:当前快照里的 `status` / `yanked` / advisory。 */
|
|
96
|
+
export function checkStatus(entry, current) {
|
|
97
|
+
if (current === null) return { answered: false, status: null, advisory: null };
|
|
98
|
+
const rec = current.artifacts.find((r) => r.id === entry.artifact);
|
|
99
|
+
if (!rec) {
|
|
100
|
+
return {
|
|
101
|
+
answered: true,
|
|
102
|
+
status: 'absent',
|
|
103
|
+
advisory: null,
|
|
104
|
+
message: `${entry.artifact} 不在当前快照里(可能已被移除)`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const y = current.yanked.find((x) => x.id === rec.id);
|
|
108
|
+
return {
|
|
109
|
+
advisory: y?.advisory ?? null,
|
|
110
|
+
answered: true,
|
|
111
|
+
message: y ? `已被 yank:${y.reason}` : null,
|
|
112
|
+
status: rec.status,
|
|
113
|
+
superseded_by: y?.superseded_by ?? null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function cmdCheck(ctx, argv, out) {
|
|
118
|
+
for (const a of argv) throw new UsageError(`check 不认得参数 ${a}`);
|
|
119
|
+
const tplan = targetsFor(ctx, out);
|
|
120
|
+
const shadowed = shadowMap(ctx, tplan);
|
|
121
|
+
const verifier = await getVerifier(ctx);
|
|
122
|
+
const readHistorical = historicalReader(ctx, verifier);
|
|
123
|
+
|
|
124
|
+
// 当前快照 —— 第二问的**唯一**依据。取不到就答不了,绝不用历史快照冒充。
|
|
125
|
+
let current = null;
|
|
126
|
+
let stale = false;
|
|
127
|
+
let lastVerifiedAt = null;
|
|
128
|
+
let currentError = null;
|
|
129
|
+
try {
|
|
130
|
+
const r = await resolveSnapshotForCommand(ctx);
|
|
131
|
+
current = r.snapshot;
|
|
132
|
+
stale = r.stale;
|
|
133
|
+
lastVerifiedAt = r.floor?.last_verified_at ?? null;
|
|
134
|
+
} catch (e) {
|
|
135
|
+
// 🔴 同 list:只有「取不到」可以降级成「第二问答不了」。
|
|
136
|
+
// stale / 完整性 / min-cli 各有自己的退出码,吞掉它们就等于伪造了一次通过。
|
|
137
|
+
if (!isDegradable(e)) throw e;
|
|
138
|
+
currentError = e;
|
|
139
|
+
try {
|
|
140
|
+
const { readTrustFloor } = await import('../trust.mjs');
|
|
141
|
+
lastVerifiedAt = readTrustFloor(ctx.stateDir)?.last_verified_at ?? null;
|
|
142
|
+
} catch { lastVerifiedAt = null; }
|
|
143
|
+
out.warn(`第二问答不了:${e.message.split('\n')[0]}`);
|
|
144
|
+
}
|
|
145
|
+
if (stale) out.warn('timestamp 已过期:以下每一行都按 stale 处理');
|
|
146
|
+
|
|
147
|
+
const rows = [];
|
|
148
|
+
const targetsOut = [];
|
|
149
|
+
for (const t of tplan.selected) {
|
|
150
|
+
const L = ledgerOf(t.target);
|
|
151
|
+
// 未恢复事务:check 要报出来(它会让第一问的比对结果没有意义)
|
|
152
|
+
const P = layout(t.target);
|
|
153
|
+
const unfinished = [];
|
|
154
|
+
if (existsSync(P.state)) {
|
|
155
|
+
for (const g of listJournalGenerations(P.journalDir)) {
|
|
156
|
+
const J = readJournal(layout(t.target, g).journal);
|
|
157
|
+
if (J.phase !== 'completed') unfinished.push({ generation: g, phase: J.phase });
|
|
158
|
+
}
|
|
159
|
+
if (existsSync(P.repairIntent)) unfinished.push({ generation: null, phase: 'repair-intent' });
|
|
160
|
+
}
|
|
161
|
+
targetsOut.push({
|
|
162
|
+
// 🔴 §7:标注挂在**每一个** target / entry 对象上,不是只挂 entry 那一层
|
|
163
|
+
annotations: annotations({ offline: ctx.offline, stale }),
|
|
164
|
+
client: t.client,
|
|
165
|
+
entries: L ? Object.keys(L.entries).length : 0,
|
|
166
|
+
has_ledger: L !== null,
|
|
167
|
+
scope: t.scope,
|
|
168
|
+
target: t.target,
|
|
169
|
+
unfinished_transactions: unfinished,
|
|
170
|
+
});
|
|
171
|
+
if (!L) continue;
|
|
172
|
+
for (const [name, e] of Object.entries(L.entries)) {
|
|
173
|
+
const bytes = checkBytes(t.target, name, e, readHistorical);
|
|
174
|
+
const status = checkStatus(e, current);
|
|
175
|
+
rows.push({
|
|
176
|
+
annotations: annotations({
|
|
177
|
+
stale,
|
|
178
|
+
offline: ctx.offline || currentError !== null,
|
|
179
|
+
yanked: status.status === 'yanked',
|
|
180
|
+
degraded: status.status === 'degraded',
|
|
181
|
+
shadowed: shadowed.has(name),
|
|
182
|
+
}),
|
|
183
|
+
artifact: e.artifact,
|
|
184
|
+
bytes,
|
|
185
|
+
client: t.client,
|
|
186
|
+
name,
|
|
187
|
+
scope: t.scope,
|
|
188
|
+
// 🔴 答不了就是答不了 —— **不得默认为「正常」**
|
|
189
|
+
status: status.answered ? status.status : null,
|
|
190
|
+
status_advisory: status.advisory ?? undefined,
|
|
191
|
+
status_message: status.answered
|
|
192
|
+
? (status.message ?? undefined)
|
|
193
|
+
: UNKNOWN_STATUS(lastVerifiedAt),
|
|
194
|
+
status_known: status.answered,
|
|
195
|
+
target: t.target,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// lockfile 过时(§8.1:`check` 发现不符则报「lockfile 过时」,提示 sync-lock)
|
|
201
|
+
let lockfileState = null;
|
|
202
|
+
if (ctx.scope === 'project') lockfileState = checkLockfile(ctx, tplan, readHistorical, out);
|
|
203
|
+
|
|
204
|
+
// ── 输出 ────────────────────────────────────────────────────────────────
|
|
205
|
+
out.line(`check(${rows.length} 条 entry):`);
|
|
206
|
+
for (const r of rows) {
|
|
207
|
+
const first = r.bytes.ok ? 'bytes-ok ' : `bytes-BAD `;
|
|
208
|
+
const second = r.status_known ? `status=${r.status}` : r.status_message;
|
|
209
|
+
out.line(` ${first}${r.client}/${r.scope} ${r.name} ${second}${annotationSuffix(r.annotations)}`);
|
|
210
|
+
if (!r.bytes.ok) out.line(` ${r.bytes.message}`);
|
|
211
|
+
if (r.status_advisory) out.line(` advisory: ${r.status_advisory}`);
|
|
212
|
+
}
|
|
213
|
+
for (const t of targetsOut) {
|
|
214
|
+
for (const u of t.unfinished_transactions) {
|
|
215
|
+
out.line(` [!] ${t.client}/${t.scope} 有未完成事务(${u.phase})——`
|
|
216
|
+
+ '请先 `skills-hub recover`;在那之前上面的比对结果只反映一个中间态');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const [name, hits] of shadowed) {
|
|
220
|
+
out.line(` [!] ${name} 项目级与全局并存(全局在 ${hits[0].globalTarget})——`
|
|
221
|
+
+ '生效者取决于客户端,本工具不做判断(04-install.md §8.2)');
|
|
222
|
+
}
|
|
223
|
+
if (lockfileState && !lockfileState.ok) {
|
|
224
|
+
out.line(` [!] ${lockfileState.message}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const badBytes = rows.filter((r) => !r.bytes.ok);
|
|
228
|
+
const unfinishedAny = targetsOut.some((t) => t.unfinished_transactions.length > 0);
|
|
229
|
+
let exit = EXIT.OK;
|
|
230
|
+
// 🔴 优先级:有未完成事务 → 5(先去 recover,其它结论都只反映中间态);
|
|
231
|
+
// 否则字节不符 → 2(完整性失败)。
|
|
232
|
+
if (unfinishedAny) exit = EXIT.NEEDS_RECOVER;
|
|
233
|
+
else if (badBytes.length) exit = EXIT.INTEGRITY;
|
|
234
|
+
|
|
235
|
+
if (ctx.record) {
|
|
236
|
+
ctx.record({
|
|
237
|
+
kind: 'check',
|
|
238
|
+
reason: exit === EXIT.OK ? undefined : (exit === EXIT.INTEGRITY ? 'digest-mismatch' : 'journal-corrupt'),
|
|
239
|
+
result: exit === EXIT.OK ? 'ok' : 'failed',
|
|
240
|
+
scope: ctx.scope,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return out.emit('check', {
|
|
245
|
+
entries: rows,
|
|
246
|
+
lockfile: lockfileState ?? undefined,
|
|
247
|
+
second_question_answered: current !== null,
|
|
248
|
+
second_question_unknown_reason: current === null ? UNKNOWN_STATUS(lastVerifiedAt) : undefined,
|
|
249
|
+
shadowed: [...shadowed.keys()].sort(),
|
|
250
|
+
snapshot: current ? current.snapshot : undefined,
|
|
251
|
+
targets: targetsOut,
|
|
252
|
+
}, exit);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** §8.1:lockfile 与账本投影不符 → 报「过时」,提示 `sync-lock`。**只报不写**。 */
|
|
256
|
+
function checkLockfile(ctx, tplan, readHistorical, out) {
|
|
257
|
+
const path = join(ctx.projectRoot, LOCKFILE_NAME);
|
|
258
|
+
if (!existsSync(path)) {
|
|
259
|
+
return { message: `${path} 不存在(项目级安装应当维护它)—— 跑 \`skills-hub sync-lock\` 生成`, ok: false, path };
|
|
260
|
+
}
|
|
261
|
+
let onDisk;
|
|
262
|
+
try { onDisk = readLockfile(path); } catch (e) {
|
|
263
|
+
return { message: `${path} 解析失败:${e.message}`, ok: false, path };
|
|
264
|
+
}
|
|
265
|
+
// 重算一份**在内存里**的投影去比 —— 🔴 check 不写盘
|
|
266
|
+
const targets = [];
|
|
267
|
+
for (const t of tplan.selected) {
|
|
268
|
+
const L = ledgerOf(t.target);
|
|
269
|
+
if (!L) continue;
|
|
270
|
+
if (Object.keys(L.entries).length === 0 && Object.keys(L.roots).length === 0) continue;
|
|
271
|
+
const assetSha256 = {};
|
|
272
|
+
let missing = false;
|
|
273
|
+
for (const [name, e] of Object.entries(L.entries)) {
|
|
274
|
+
try {
|
|
275
|
+
const snap = readHistorical(e.snapshot);
|
|
276
|
+
const rec = snap.artifacts.find((r) => r.id === e.artifact);
|
|
277
|
+
if (!rec) { missing = true; break; }
|
|
278
|
+
assetSha256[name] = rec.asset.sha256;
|
|
279
|
+
} catch { missing = true; break; }
|
|
280
|
+
}
|
|
281
|
+
if (missing) {
|
|
282
|
+
out.warn('取不回某些安装时快照,lockfile 的「是否过时」无法证明 —— 如实报未证明,不报「一致」');
|
|
283
|
+
return { message: 'lockfile 是否过时**未证明**(取不回安装时快照里的 asset_sha256)', ok: false, path, unproven: true };
|
|
284
|
+
}
|
|
285
|
+
targets.push({ assetSha256, client: t.client, ledger: L, path: `${t.adapter.dirName}/skills`, scope: 'project' });
|
|
286
|
+
}
|
|
287
|
+
let want;
|
|
288
|
+
try { want = projectLockfile({ registry: REPO, targets }); } catch (e) {
|
|
289
|
+
return { message: `账本投影算不出来:${e.message}`, ok: false, path };
|
|
290
|
+
}
|
|
291
|
+
if (stringify(want) !== stringify(onDisk)) {
|
|
292
|
+
return { message: `${path} **已过时**(与账本投影不符)—— 跑 \`skills-hub sync-lock\``, ok: false, path };
|
|
293
|
+
}
|
|
294
|
+
return { ok: true, path };
|
|
295
|
+
}
|