@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
package/src/upload.mjs
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// 上报通道。🔴 硬约束:
|
|
2
|
+
// 1. 只发已过 assertValidEvent 的事件,出网前**再校验一遍**(纵深防御)
|
|
3
|
+
// 2. 端点必须是 https,且必须由 GEOLY_TELEMETRY_ENDPOINT 显式给出 —— 没配就是纯本地
|
|
4
|
+
// 3. 失败不影响主命令:任何异常都吞掉,事件留在本地等下次
|
|
5
|
+
// 4. `--offline` 一票否决,连请求都不构造
|
|
6
|
+
import {
|
|
7
|
+
existsSync, readFileSync, appendFileSync, openSync, closeSync,
|
|
8
|
+
mkdirSync, renameSync, unlinkSync, linkSync, statSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
stateDir,
|
|
13
|
+
readAll,
|
|
14
|
+
uploadEnabled,
|
|
15
|
+
offline,
|
|
16
|
+
assertValidEvent,
|
|
17
|
+
isValidEvent,
|
|
18
|
+
serializeEvent,
|
|
19
|
+
lockPath,
|
|
20
|
+
appendDurable,
|
|
21
|
+
} from './telemetry.mjs';
|
|
22
|
+
import { acquire, LockBusyError } from './lock.mjs';
|
|
23
|
+
// parseStrict 而非内建 parse:重复 key 会被静默取最后一个
|
|
24
|
+
import { writeAtomic } from './atomic-fs.mjs';
|
|
25
|
+
import { parseStrict } from './canonical-json.mjs';
|
|
26
|
+
|
|
27
|
+
const sendingPath = () => join(stateDir(), 'telemetry', 'sending.ndjson');
|
|
28
|
+
const queuePath = () => join(stateDir(), 'telemetry', 'queue.ndjson');
|
|
29
|
+
const prevQueuePath = () => join(stateDir(), 'telemetry', 'queue.1.ndjson');
|
|
30
|
+
/** 已发出、等下一轮再删的墓碑;readAll 也读它,晚到的 append 不会丢 */
|
|
31
|
+
const tombPath = () => join(stateDir(), 'telemetry', 'sending.tomb.ndjson');
|
|
32
|
+
/** 墓碑在 retire 结束时的字节长度。之后再长出来的都是晚到的、没发过的。 */
|
|
33
|
+
const tombMarkPath = () => join(stateDir(), 'telemetry', 'sending.tomb.mark');
|
|
34
|
+
|
|
35
|
+
export function endpoint() {
|
|
36
|
+
const raw = process.env.GEOLY_TELEMETRY_ENDPOINT;
|
|
37
|
+
if (!raw) return null;
|
|
38
|
+
let u;
|
|
39
|
+
try { u = new URL(raw); } catch { throw new Error('telemetry: 上报端点不是合法 URL'); }
|
|
40
|
+
// 用 URL 解析而不是 startsWith:`https:/\evil` 之类的写法能骗过前缀判断
|
|
41
|
+
if (u.protocol !== 'https:') throw new Error('telemetry: 上报端点必须是 https');
|
|
42
|
+
// URL 里的凭据会随重定向和日志一起泄漏
|
|
43
|
+
if (u.username || u.password) throw new Error('telemetry: 上报端点不得内嵌凭据');
|
|
44
|
+
return u.toString();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 把一代队列提升为 sending。**只有一次 rename,没有复制、没有 unlink。**
|
|
49
|
+
*
|
|
50
|
+
* 🔴 早先这里是「rename 成 *.staged → 读出来 → append 进 sending → unlink staged」。
|
|
51
|
+
* Codex 2026-08-26 第三轮指出它有两个 P0:
|
|
52
|
+
* · 旧 queue fd 在我们读完 `.staged` 之后才 append,随后的 unlink 把那行
|
|
53
|
+
* 连同 inode 一起带走 —— 既不在 sending 也不在任何队列文件里
|
|
54
|
+
* · 残留的 `queue.1.ndjson.staged` 与新生成的 `queue.1.ndjson` 同时存在时,
|
|
55
|
+
* 下一轮 rename 会**原子覆盖**掉旧的 `.staged`,直接丢掉整批旧事件
|
|
56
|
+
*
|
|
57
|
+
* 两个问题都源自那个中间文件。去掉它:直接 rename 成 sending。
|
|
58
|
+
* rename 不会让任何 inode 失去目录项,晚到的 append 落进 sending,
|
|
59
|
+
* retire 按 eid 判就能把它退回队列。
|
|
60
|
+
*
|
|
61
|
+
* 代价:同一时刻只允许一个批次在途。sending 还在(上一轮没发成功)时,
|
|
62
|
+
* 这一轮就只发 sending,不再往里并新数据 —— 更简单,也更容易讲清楚。
|
|
63
|
+
*/
|
|
64
|
+
function stage() {
|
|
65
|
+
// 旧墓碑收割不掉就不能往下走:后面的 rename(sending → tomb) 会覆盖它。
|
|
66
|
+
// 宁可这一轮不发,也不要覆盖一个还没收割干净的文件。
|
|
67
|
+
if (!reapTomb()) return false;
|
|
68
|
+
|
|
69
|
+
if (existsSync(sendingPath())) return true; // 上一轮的残留,先把它发完
|
|
70
|
+
// 先老后新:queue.1 是上一代
|
|
71
|
+
for (const p of [prevQueuePath(), queuePath()]) {
|
|
72
|
+
if (!existsSync(p)) continue;
|
|
73
|
+
// 🔴 用 link + unlink 而不是 rename:rename 会**静默覆盖**已存在的 sending,
|
|
74
|
+
// 一旦覆盖就是整批消失。link 在目标已存在时报 EEXIST,是原子的 no-replace。
|
|
75
|
+
// 崩在 link 与 unlink 之间只会让同一批同时出现在 queue 和 sending,
|
|
76
|
+
// 下一轮按 eid 去重即可 —— 又是「宁可重复,不可丢失」。
|
|
77
|
+
try {
|
|
78
|
+
linkSync(p, sendingPath());
|
|
79
|
+
} catch { continue; }
|
|
80
|
+
try { unlinkSync(p); } catch { /* 下一轮 readAll 会按 eid 去重 */ }
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 读某个队列文件里合规的事件 */
|
|
87
|
+
function readEvents(path) {
|
|
88
|
+
let text;
|
|
89
|
+
try { text = readFileSync(path, 'utf8'); } catch { return []; }
|
|
90
|
+
const out = [];
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
for (const line of text.split('\n')) {
|
|
93
|
+
if (!line) continue;
|
|
94
|
+
let ev;
|
|
95
|
+
try { ev = parseStrict(line); } catch { continue; }
|
|
96
|
+
if (!isValidEvent(ev) || seen.has(ev.eid)) continue;
|
|
97
|
+
seen.add(ev.eid);
|
|
98
|
+
out.push(ev);
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 发成功后清理 sending。
|
|
105
|
+
*
|
|
106
|
+
* 🔴 不能无脑 unlink:可能有 record 拿着 stage 之前打开的 fd,在我们读完之后
|
|
107
|
+
* 才把一行追加进这个 inode。所以重读一遍,**只丢已发出去的那些 eid**,
|
|
108
|
+
* 剩下的追加回 queue,最后才删 sending。
|
|
109
|
+
*/
|
|
110
|
+
function retire(sentEids) {
|
|
111
|
+
// 顺序要紧:**先**把未发出的 append 回 queue,**再**动 sending。
|
|
112
|
+
// 崩在中间只会留下重复(下轮按 eid 去重),倒过来则会丢。
|
|
113
|
+
sweepBackToQueue(sendingPath(), sentEids);
|
|
114
|
+
|
|
115
|
+
// 🔴 **不 unlink**,改成改名成墓碑,下一轮 flush 开头才真删。
|
|
116
|
+
//
|
|
117
|
+
// 为什么:`record` 的 nlink 检查是 TOCTOU 的 —— 它能发现「已经被删了」,
|
|
118
|
+
// 但发现不了「fstat 之后、close 之前才被删」(Codex 2026-08-26 第四轮指出):
|
|
119
|
+
// retire: 读完 sending
|
|
120
|
+
// record: 往 stage 之前打开的旧 fd 追加(nlink 仍是 1)
|
|
121
|
+
// record: fstat 看到 nlink = 1,认为安全
|
|
122
|
+
// retire: unlink(sending) ← 那一行随 inode 一起消失
|
|
123
|
+
//
|
|
124
|
+
// rename 不会让 inode 失去目录项,所以晚到的 append 仍落在墓碑里。
|
|
125
|
+
let renamed = false;
|
|
126
|
+
try { renameSync(sendingPath(), tombPath()); renamed = true; } catch { /* 没了就算了 */ }
|
|
127
|
+
|
|
128
|
+
// 改名之后再扫一次:读 sending 与改名之间落进来的那些,现在能在墓碑里看到。
|
|
129
|
+
// 这一遍把窗口从「每次 flush 都有的微秒级」压到「fd 要跨越一整个 flush 周期」。
|
|
130
|
+
// ⚠️ 是**压缩**不是消灭 —— 见规格 §6 的 T-15。
|
|
131
|
+
if (renamed) {
|
|
132
|
+
sweepBackToQueue(tombPath(), sentEids);
|
|
133
|
+
// 记下此刻的长度:之后再长出来的都是晚到的,由下一轮的 reapTomb 捞回
|
|
134
|
+
try { writeAtomic(tombMarkPath(), String(statSync(tombPath()).size) + '\n'); } catch { /* 下轮从 0 起算 */ }
|
|
135
|
+
}
|
|
136
|
+
return renamed;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 处理上一轮的墓碑,然后删掉它。
|
|
141
|
+
*
|
|
142
|
+
* 🔴 **不能直接 unlink。** 墓碑在 retire 扫完之后还可能长出新行(旧 fd 晚到的
|
|
143
|
+
* append),直接删就把它们带走了 —— 这和早先 `*.staged` 被同名 rename 覆盖
|
|
144
|
+
* 是同一类错误:**任何要删除的文件,先问一句「它自这次记账以来长过吗」。**
|
|
145
|
+
*
|
|
146
|
+
* 靠 `sending.tomb.mark` 里记的字节长度区分:mark 之前的是已发出的,
|
|
147
|
+
* 之后的是晚到的。墓碑是**只追加、从不重写**的,所以字节偏移在这里是稳定的
|
|
148
|
+
* ——「位置游标不可靠」那条教训针对的是会被重写的文件,不适用于此。
|
|
149
|
+
*/
|
|
150
|
+
function reapTomb() {
|
|
151
|
+
let text;
|
|
152
|
+
try {
|
|
153
|
+
text = readFileSync(tombPath(), 'utf8');
|
|
154
|
+
} catch (err) {
|
|
155
|
+
// 🔴 「读不了」不等于「不存在」。只有 ENOENT 才是真的没有墓碑;
|
|
156
|
+
// 其它错误(EISDIR、EACCES…)说明那里有个我们收割不了的东西 —— fail-closed。
|
|
157
|
+
return err?.code === 'ENOENT';
|
|
158
|
+
}
|
|
159
|
+
let mark = 0;
|
|
160
|
+
try { mark = Number(readFileSync(tombMarkPath(), 'utf8').trim()) || 0; } catch { /* 当作 0 */ }
|
|
161
|
+
// mark 读不到就从 0 起算:整批重发(服务端按 eid 去重),比漏掉晚到的安全
|
|
162
|
+
const late = Buffer.from(text, 'utf8').subarray(mark).toString('utf8');
|
|
163
|
+
const events = [];
|
|
164
|
+
for (const line of late.split('\n')) {
|
|
165
|
+
if (!line) continue;
|
|
166
|
+
let ev;
|
|
167
|
+
try { ev = parseStrict(line); } catch { continue; }
|
|
168
|
+
if (isValidEvent(ev)) events.push(ev);
|
|
169
|
+
}
|
|
170
|
+
if (events.length) appendToQueue(events);
|
|
171
|
+
// 删不掉就 fail-closed:调用方会因此跳过这一轮 retire。
|
|
172
|
+
// 否则后面的 rename(sending → tomb) 会覆盖掉这个还没收割干净的墓碑。
|
|
173
|
+
try { unlinkSync(tombPath()); } catch { return false; }
|
|
174
|
+
try { unlinkSync(tombMarkPath()); } catch { /* mark 残留无害:下轮从 0 起算 */ }
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 🔴 必须走 `appendDurable`,不能裸 `openSync` + append。
|
|
180
|
+
*
|
|
181
|
+
* `record()` 的追加一直有 nlink 守卫(写完查 inode 是否已被删、中招就换新 fd 重写),
|
|
182
|
+
* 而这条「把 leftover 扫回队列」的补偿路径原先没有 ——
|
|
183
|
+
* **同一个不变量,两条路径两种强度**。
|
|
184
|
+
*
|
|
185
|
+
* 它偏偏跑在 flush 的收尾阶段:此时另一个进程的 record 完全可能正在触发换代,
|
|
186
|
+
* 把队列 inode 改名或删掉,于是扫回去的事件写进一个没有目录项的 inode,**静默消失**。
|
|
187
|
+
*
|
|
188
|
+
* CI 的 Linux runner 在压力测试里抓到过一条丢失(本机 macOS 六轮不复现)。
|
|
189
|
+
* ⚠️ 判据:**同一个不变量的所有写入路径必须用同一种强度的保证**,
|
|
190
|
+
* 不能主路径有守卫、补偿路径没有 —— 补偿路径往往正好跑在最危险的时刻。
|
|
191
|
+
*/
|
|
192
|
+
function appendToQueue(events) {
|
|
193
|
+
appendDurable(queuePath(), events.map((e) => serializeEvent(e) + '\n').join(''));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 把 path 里不属于 sentEids 的事件追加回 queue */
|
|
197
|
+
function sweepBackToQueue(path, sentEids) {
|
|
198
|
+
const leftover = readEvents(path).filter((e) => !sentEids.has(e.eid));
|
|
199
|
+
if (leftover.length) appendToQueue(leftover);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 返回 {sent, skipped, reason?};绝不抛错到调用方之外。
|
|
204
|
+
*
|
|
205
|
+
* 交付语义是 **at-least-once**:POST 成功、retire 之前崩溃会重发,
|
|
206
|
+
* 服务端按 eid 去重。反过来(先清 sending 再发)会**丢事件**,那更糟。
|
|
207
|
+
*/
|
|
208
|
+
export async function flush({ fetchImpl = globalThis.fetch, timeoutMs = 3000 } = {}) {
|
|
209
|
+
let release = null;
|
|
210
|
+
try {
|
|
211
|
+
if (offline()) return { sent: 0, skipped: true, reason: 'offline' };
|
|
212
|
+
if (!uploadEnabled()) return { sent: 0, skipped: true, reason: 'upload-disabled' };
|
|
213
|
+
const url = endpoint();
|
|
214
|
+
if (!url) return { sent: 0, skipped: true, reason: 'no-endpoint' };
|
|
215
|
+
|
|
216
|
+
// 🔴 stage 与 retire 必须在同一把锁下,否则两个 flush 会各 stage 一半、重复上报。
|
|
217
|
+
// 锁是内核释放的,进程猝死也不留死锁。
|
|
218
|
+
mkdirSync(join(stateDir(), 'telemetry'), { recursive: true });
|
|
219
|
+
try {
|
|
220
|
+
release = acquire(lockPath());
|
|
221
|
+
} catch (e) {
|
|
222
|
+
if (e instanceof LockBusyError) return { sent: 0, skipped: true, reason: 'busy' };
|
|
223
|
+
throw e;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!stage()) return { sent: 0, skipped: true, reason: 'empty' };
|
|
227
|
+
const pending = readEvents(sendingPath());
|
|
228
|
+
if (!pending.length) {
|
|
229
|
+
try { unlinkSync(sendingPath()); } catch { /* 空文件,删掉就好 */ }
|
|
230
|
+
return { sent: 0, skipped: true, reason: 'empty' };
|
|
231
|
+
}
|
|
232
|
+
// 纵深防御:readSending 已经滤过一遍,这里在真正出网前再确认一次
|
|
233
|
+
for (const e of pending) assertValidEvent(e);
|
|
234
|
+
|
|
235
|
+
const ac = new AbortController();
|
|
236
|
+
const t = setTimeout(() => ac.abort(), timeoutMs);
|
|
237
|
+
let res;
|
|
238
|
+
try {
|
|
239
|
+
res = await fetchImpl(url, {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
signal: ac.signal,
|
|
242
|
+
// 🔴 默认的 redirect:'follow' 会让恶意 https 端点用 307/308 把带事件的
|
|
243
|
+
// POST 转到 http:// —— 那就绕过了「只允许 https」。禁止重定向。
|
|
244
|
+
redirect: 'error',
|
|
245
|
+
headers: { 'content-type': 'application/json' },
|
|
246
|
+
// 手写序列化,理由同 serializeEvent:不给 toJSON 污染留口子
|
|
247
|
+
body: `{"schema":"geoly.skills.telemetry-batch/1","events":[${pending.map(serializeEvent).join(',')}]}`,
|
|
248
|
+
});
|
|
249
|
+
} finally { clearTimeout(t); }
|
|
250
|
+
|
|
251
|
+
// 发失败:sending 原样留着,下一轮接着发,一条都不丢
|
|
252
|
+
if (res.redirected) return { sent: 0, skipped: true, reason: 'redirect-refused' };
|
|
253
|
+
if (!res.ok) return { sent: 0, skipped: true, reason: `http-${res.status}` };
|
|
254
|
+
|
|
255
|
+
// 清理失败要说出来:sending 删不掉会导致同一批被无限重发
|
|
256
|
+
const cleaned = retire(new Set(pending.map((e) => e.eid)));
|
|
257
|
+
return cleaned
|
|
258
|
+
? { sent: pending.length, skipped: false }
|
|
259
|
+
: { sent: pending.length, skipped: false, reason: 'sending-not-cleaned' };
|
|
260
|
+
} catch (err) {
|
|
261
|
+
return { sent: 0, skipped: true, reason: `error:${err?.name ?? 'unknown'}` };
|
|
262
|
+
} finally {
|
|
263
|
+
try { release?.(); } catch { /* 释放失败不该盖掉上面的返回值 */ }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** 诊断用:还有多少条没发出去 */
|
|
268
|
+
export const pendingCount = () => readAll().length;
|