@geoly-ai/skills-hub 0.3.6 → 0.3.8

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.
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/env node
2
+ // 投稿的结构门(§6 那张表里**尚无实现**的那几条)—— 06-submission.md §6。
3
+ //
4
+ // 🔴 **只写还不存在的那几条。** §6 的表里大半已经有实现了,本模块不重写它们:
5
+ // · 载荷规则(文件类型 / mode / 上限 / 无 PAX 扩展头)→ `packer.collectTree` + `packEntries`
6
+ // · 六(七)项绑定 → `artifact.assertManifestBinding`
7
+ // · 路径 grammar → `untar` / `safe-fs` 的 parseSafeRelPath
8
+ // · pack lock / pack 兼容性 → `pack.resolvePackInstall` / `checkPackCompat`
9
+ // · namespace 所有权 → `promote/build-inputs.resolveOwner`
10
+ // 同一个不变量有两个校验器,就是 R-11 反复出现的形状。
11
+ //
12
+ // 本模块新写四条:
13
+ // ① 保留 **namespace**(`registry/reserved.json`;05-lifecycle.md §1.1)
14
+ // ② 归一化重名(NFKC + 小写 + 去连字符,**同 namespace 内**)
15
+ // ③ 版本未占用(**含已 yank** —— 制品不可变,yank 不释放版本号)
16
+ // ④ capability 一致性(声明 `none` 却含 `0755` / 脚本 → 拒;外部 URL → **只告警**)
17
+
18
+ import { readFileSync, existsSync } from 'node:fs';
19
+
20
+ import { parseStrict } from '../../src/canonical-json.mjs';
21
+
22
+ export const RESERVED_SCHEMA = 'geoly.skills.reserved/1';
23
+
24
+ class GateError extends Error {
25
+ constructor(code, msg) { super(msg); this.name = 'GateError'; this.code = code; }
26
+ }
27
+ const bad = (code, msg) => { throw new GateError(code, msg); };
28
+
29
+ // ── ② 归一化重名 ───────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * §6:「NFKC + 小写 + 去连字符后不与**同 namespace 内**已有 name 撞」。
33
+ *
34
+ * ⚠️ **诚实边界:今天只有「去连字符」这一步真的会触发。**
35
+ * name 的 grammar 是 `[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?`(01-artifacts.md §3)
36
+ * —— 全小写 ASCII。于是对任何**已经过 grammar 门**的名字:
37
+ * · NFKC 是恒等变换(没有可归一化的兼容字符);
38
+ * · `toLowerCase()` 也是恒等变换。
39
+ * 三步照写是为了**grammar 将来放宽时这道门仍然正确**,不是因为它们现在有用。
40
+ * `test/submission-gates.test.mjs` 里有一条断言钉住这个事实 ——
41
+ * 哪天 grammar 放宽了,那条断言会变,改的人就会看到这段说明。
42
+ */
43
+ export function normalizeName(name) {
44
+ return name.normalize('NFKC').toLowerCase().split('-').join('');
45
+ }
46
+
47
+ /**
48
+ * @param {object} a
49
+ * @param {string} a.name 本次投稿的 name
50
+ * @param {string[]} a.existing **同 namespace 内**已有的 name(含已 yank 的)
51
+ */
52
+ export function assertNoNormalizedCollision({ name, existing }) {
53
+ const target = normalizeName(name);
54
+ const hit = existing.filter((e) => e !== name && normalizeName(e) === target);
55
+ if (hit.length) {
56
+ bad('E_NAME_COLLIDE',
57
+ `${name} 归一化后是 ${JSON.stringify(target)},与同 namespace 内已有的 ${hit.join(', ')} 相撞。\n`
58
+ + ' §6:归一化重名一律拒绝 —— 两个只差连字符的名字,agent 的路由判定分不开,'
59
+ + '而人也看不出来。');
60
+ }
61
+ return true;
62
+ }
63
+
64
+ // ── ① 保留 namespace ───────────────────────────────────────────────────────
65
+
66
+ // ── 同形折叠(confusable fold)—— **只服务于保留清单比对** ──────────────────
67
+ //
68
+ // 🔴 **为什么只在这一层。** `normalizeName` 那套(NFKC + 小写 + 去连字符)挡得住
69
+ // `GEOLY` / `geoly` / `g-e-o-l-y`,却**挡不住数字替字母**:
70
+ // `ge0ly`、`geo1y`、`anthrop1c` 原来全部放行 —— 而防仿冒正是保留
71
+ // `anthropic` / `claude` / `openai` / `geoly` 的**全部理由**。
72
+ //
73
+ // 🔴 **攻击面是可枚举的,不是无底洞。** namespace 的 grammar 是
74
+ // `[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?`(01-artifacts.md §3)—— 攻击者手里
75
+ // **只有** a–z、0–9、连字符。连字符已被 `normalizeName` 去掉,非 ASCII 已被
76
+ // NFKC + grammar 拦在门外。于是残余的同形手段**只剩两类**:
77
+ // ① 数字冒充字母(`0`/`o`、`1`/`l`/`i`、`5`/`s` …)
78
+ // ② 小写 ASCII 字母冒充另一个字母或字母序列(`rn`/`m`、`vv`/`w`、`cl`/`d`)
79
+ // 本模块把这两类都折掉,然后**全串相等**比对。
80
+ //
81
+ // ⚠️ **明确不覆盖的层(不要以为这道门是「防仿冒」的完备实现):**
82
+ // · **子串 / 前后缀包含**:`geoly-ai`、`my-claude-helper`、`claudes` 一律放行。
83
+ // 这是**有意的** —— `geoly-ai` / `prompts-map` / `plaud-theme` 已在
84
+ // `registry/owners.json` 里且有线上制品,包含式判定会当场把它们杀掉。
85
+ // · **编辑距离 / 模糊相似**:`anthropi`、`anthropicc`、`caludе`(转置)不管。
86
+ // · **语音相似**:`fasebook` 这一路不管。
87
+ // · 🔴 **`l`/`i` 之外的字母↔字母替换**:`qeoly`、`beoly`、`qithub`、`bithub`、
88
+ // `reqistry`、`githug` **全部放行**。这不是漏了,是**算过账之后不做**:
89
+ // 要挡住它们就得把 `b`/`g`/`q` 并成一个字母等价类,而那立刻推出 `b ≡ g` ——
90
+ // 正常名字 `hug` 当场被判成冒充 `hub`(Codex 2026-09-03 实测给的)。
91
+ // **数字**之所以能按歧义处理(`hu6` 拦、`hug` 放行),是因为「名字里出现数字」
92
+ // 本身就是可用的信号;两个都是普通字母时没有这个信号,只能二选一。
93
+ // `l`/`i` 是唯一一对强到值得付这个代价的 —— 无衬线字体里它们**完全同形**。
94
+ // · **`li`→`h`、`ci`→`a`、`nn` 之外的更弱多字符对**:见下面 MULTI 的注释,
95
+ // 逐条写了为什么不做。
96
+ // 真正兜住这些的是 §8 的人工门,不是这道结构门。
97
+
98
+ /** 小写 ASCII 里真正的**字母↔字母**同形对。`l` 与 `i` 归一到 `i`。 */
99
+ const LETTER_CLASS = { l: 'i' };
100
+
101
+ /**
102
+ * 数字 → 它能冒充的字母。**只有一个候选的**在折叠时就地替换掉。
103
+ * 🔴 `6` 与 `9` 有**两个**候选(`6` 既像 `b` 又像 `g`,`9` 既像 `g` 又像 `q`),
104
+ * **不能**把它们并进同一个等价类 —— 那会让 `b ≡ g`,于是 `hug` 被判成
105
+ * 冒充 `hub`(Codex 2026-09-03 给的误伤例)。它们**原样保留**,
106
+ * 在 `confusableEquals` 里按「候选集合有交集」逐位判 —— 那个关系
107
+ * **故意不是传递的**:`b ~ 6 ~ g` 但 `b ≁ g`。
108
+ * 因为比对永远是「候选 vs 保留名」的单向两两比,不传递不构成问题。
109
+ */
110
+ const DIGIT_SOLE = { 0: 'o', 1: 'i', 2: 'z', 3: 'e', 4: 'a', 5: 's', 7: 't', 8: 'b' };
111
+ const DIGIT_AMBIG = { 6: ['b', 'g'], 9: ['g', 'q'] };
112
+
113
+ /**
114
+ * 多字符同形对。**在单字符折叠之后**施加,且模式写的是**折叠后的字母表**。
115
+ *
116
+ * 🔴 顺序是判据的一部分,不是实现细节。Codex 2026-09-03 指出:若按
117
+ * 「先多字符、后单字符」,`cl`→`d` 只认字面 `cl`,而 `l` 又会折成 `i` ——
118
+ * 于是 `claude`→`daude` 但 `c1aude`→`ciaude`,**等价关系不闭包,`c1aude` 直接绕过**。
119
+ * 改成「先单字符、后多字符 + 模式用折叠后字母」就由构造保证闭包:
120
+ * `cl` / `c1` / `ci` 都先变成 `ci`,再统一折成 `d`。
121
+ *
122
+ * 🔴 四条规则的**首字符两两不同**(r / n / v / c),所以同一位置最多命中一条 ——
123
+ * 不存在「贪心顺序依赖」这种可被利用的非合流。输出字符(m / w / d)也都不是
124
+ * 任何规则的首字符或次字符,所以**单趟左到右扫描就是不动点**。有测试钉住这两条。
125
+ *
126
+ * ⚠️ 没做的:`li`→`h`(会把 `liub` 判成 `hub`,而那可能是正常名字)、
127
+ * `ci`→`a`(与这里的 `ci`→`d` 直接冲突,二选一)、`vv` 之外的 `w` 变体。
128
+ */
129
+ const MULTI = [['rn', 'm'], ['nn', 'm'], ['vv', 'w'], ['ci', 'd']];
130
+
131
+ /**
132
+ * 把名字折成「同形规范形」。结果里可能仍留有 `6` / `9` —— 它们是**歧义占位符**,
133
+ * 由 `confusableEquals` 处理,见 `DIGIT_AMBIG`。
134
+ */
135
+ export function foldConfusables(name) {
136
+ let s = '';
137
+ for (const ch of normalizeName(name)) s += LETTER_CLASS[ch] ?? DIGIT_SOLE[ch] ?? ch;
138
+ let out = '';
139
+ for (let i = 0; i < s.length;) {
140
+ const rule = MULTI.find(([pat]) => s.startsWith(pat, i));
141
+ if (rule) { out += rule[1]; i += rule[0].length; } else { out += s[i]; i += 1; }
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /** 一个折叠后字符能代表的字母集合。 */
147
+ const charAlts = (c) => DIGIT_AMBIG[c] ?? [c];
148
+
149
+ /** 两个**已折叠**的名字是否同形。⚠️ 故意不传递,见 `DIGIT_AMBIG`。 */
150
+ export function confusableEquals(a, b) {
151
+ if (a.length !== b.length) return false;
152
+ for (let i = 0; i < a.length; i += 1) {
153
+ if (!charAlts(a[i]).some((x) => charAlts(b[i]).includes(x))) return false;
154
+ }
155
+ return true;
156
+ }
157
+
158
+ /**
159
+ * 🔴 **保留清单管的是 `namespace`,不是制品 `name`。**
160
+ * 05-lifecycle.md §1.1:「以下 **namespace** 只能由 hub 维护者使用:
161
+ * `geoly`、`hub`、`official`、`skills`、`registry`、`anthropic`、`claude`、
162
+ * `cursor`、`codex`、`openai`、`github`、`npm`、`system`、`admin`、`security`、
163
+ * `test`、`example`、`local`」。
164
+ *
165
+ * ⚠️ 第一版我按 `name` 查(Codex 2026-08-31 指出)—— 那道门等于没设:
166
+ * 一个 `namespace: geoly` 的投稿会**直接放行**,而那正是它要拦的东西。
167
+ *
168
+ * 🔴 而且它**不是**一刀切禁用:维护者可以用。所以判据是
169
+ * 「保留 namespace **且** 投稿者不是维护者」。「是不是维护者」是 PR 侧的事实,
170
+ * 由调用方传进来 —— 同 `promote/build-inputs.mjs` 的分工。
171
+ */
172
+ export function readReserved(path) {
173
+ if (!existsSync(path)) return { schema: RESERVED_SCHEMA, namespaces: [] };
174
+ // 🔴 用 `parseStrict` 不用 `JSON.parse`:11-wire-contract.md §1 把
175
+ // `registry/reserved.json` 列进了适用对象,而 §2 的解析规则要求**拒绝重复 key**。
176
+ // `JSON.parse` 对重复 key 静默取最后一个 —— `{"namespaces":[…],"namespaces":[]}`
177
+ // 就能把整张保留清单清空,而文件看起来完全正常(Codex 2026-08-31)。
178
+ const doc = parseStrict(readFileSync(path, 'utf8'));
179
+ if (doc.schema !== RESERVED_SCHEMA) {
180
+ bad('E_RESERVED_SCHEMA', `reserved.json 的 schema 应为 ${RESERVED_SCHEMA},得到 ${JSON.stringify(doc.schema)}`);
181
+ }
182
+ if (!Array.isArray(doc.namespaces) || doc.namespaces.some((n) => typeof n !== 'string')) {
183
+ bad('E_RESERVED_SCHEMA', 'reserved.json.namespaces 必须是字符串数组');
184
+ }
185
+ // 🔴 **同形折叠必须在保留清单内部保持单射 —— 否则 fail-closed**(Codex 2026-09-03)。
186
+ // 这是给「折叠表将来被放宽」准备的告警器:哪天有人往 `LETTER_CLASS` / `MULTI` 里
187
+ // 加一条,把两个**本来互不相同的真实单词**折到一起了,这里会当场红 ——
188
+ // 而不是等到某个正常投稿被莫名其妙判成仿冒才发现。
189
+ const folds = doc.namespaces.map((n) => ({ n, f: foldConfusables(n) }));
190
+ for (let i = 0; i < folds.length; i += 1) {
191
+ for (let j = i + 1; j < folds.length; j += 1) {
192
+ if (confusableEquals(folds[i].f, folds[j].f)) {
193
+ bad('E_RESERVED_AMBIGUOUS',
194
+ `保留清单内部自撞:${folds[i].n} 与 ${folds[j].n} 同形折叠后无法区分`
195
+ + `(${JSON.stringify(folds[i].f)} / ${JSON.stringify(folds[j].f)})。\n`
196
+ + ' 三种可能:① 其中一条是笔误;② 其中一条是**冗余别名**'
197
+ + '(如 `openai` 之外再列 `open-ai` —— 归一化后本就相同,直接删掉那条即可);'
198
+ + '③ structural-gates.mjs 的折叠表放得太宽 —— 宽到能把两个正常单词合并,'
199
+ + '就一定会误伤正常投稿。');
200
+ }
201
+ }
202
+ }
203
+ return doc;
204
+ }
205
+
206
+ /**
207
+ * @param {object} a
208
+ * @param {string} a.namespace
209
+ * @param {object} a.reserved
210
+ * @param {boolean} a.byMaintainer 投稿者是不是 hub 维护者(PR 侧的事实)
211
+ */
212
+ export function assertReservedNamespaceAllowed({ namespace, reserved, byMaintainer = false }) {
213
+ const target = normalizeName(namespace);
214
+ const hit = reserved.namespaces.filter((r) => normalizeName(r) === target);
215
+ // 🔴 归一化相等之外,再查一层**同形折叠**(`ge0ly` / `geo1y` / `anthrop1c`)——
216
+ // 见上面那段「攻击面是可枚举的」。折叠只用在**这一层**(与 18 条固定清单
217
+ // 比全串相等),**不用在 `assertNoNormalizedCollision`**:那边比的是
218
+ // 同 namespace 内的**开放集合**,折叠会让两个正常名字互撞,
219
+ // 且撞的对数随注册量二次增长,而它挡的不是仿冒、是「人和 agent 分不开」。
220
+ const folded = foldConfusables(namespace);
221
+ const lookalike = reserved.namespaces.filter(
222
+ (r) => !hit.includes(r) && confusableEquals(foldConfusables(r), folded),
223
+ );
224
+ if (hit.length === 0 && lookalike.length === 0) return true;
225
+ if (byMaintainer) return true; // §1.1:维护者可以用
226
+ if (hit.length) {
227
+ bad('E_RESERVED',
228
+ `namespace ${namespace} 命中保留清单(${hit.join(', ')},归一化后相同)——`
229
+ + '05-lifecycle.md §1.1:这些 namespace 只能由 hub 维护者使用。');
230
+ }
231
+ bad('E_RESERVED',
232
+ `namespace ${namespace} 与保留 namespace ${lookalike.join(', ')} **同形**`
233
+ + `(数字/字母同形折叠后都是 ${JSON.stringify(folded)})——`
234
+ + '05-lifecycle.md §1.1:这些 namespace 只能由 hub 维护者使用,'
235
+ + '而「数字替字母」正是保留它们要防的那种仿冒。\n'
236
+ + ' ⚠️ 这道门只比**全串**:名字里带数字本身没问题(`web3`、`s3`、`i18n` 都放行),'
237
+ + '包含保留词也没问题(`geoly-ai` 放行)—— 只有整个名字折叠后与保留名相同才拒。'
238
+ + '要是你确实不是在仿冒,换一个不会折叠到保留名上的写法即可。');
239
+ }
240
+
241
+ // ── ③ 版本未占用 ───────────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * §6:「semver 合法、**未占用**、无 `+build`」。§3 又特别写明「版本号在该
245
+ * `<ns>/<name>` 下**从未被使用过(含已 yank)**」。
246
+ *
247
+ * 🔴 **yank 不释放版本号。** 制品不可变(01-artifacts.md §1):yank 只是标记
248
+ * 「别再用了」,文件仍在。放行重用等于让同一个 ArtifactId 指向两份不同的字节 ——
249
+ * 而 ArtifactId 是整个信任链的主键。
250
+ *
251
+ * @param {string} a.id 本次投稿的 ArtifactId
252
+ * @param {string[]} a.existingIds 已有的全部 ArtifactId(**含已 yank**)
253
+ */
254
+ export function assertVersionUnused({ id, existingIds }) {
255
+ if (existingIds.includes(id)) {
256
+ bad('E_VERSION_TAKEN',
257
+ `${id} 已经存在 —— 版本号不可重用,**yank 也不释放它**(06-submission.md §3、01-artifacts.md §1)。\n`
258
+ + ' 要改内容就发新版本。');
259
+ }
260
+ return true;
261
+ }
262
+
263
+ // ── ④ capability 一致性 ────────────────────────────────────────────────────
264
+
265
+ /** 看起来像脚本的扩展名。**不是白名单,是启发式** —— 见下面的诚实边界。 */
266
+ export const SCRIPT_EXT = /\.(sh|bash|zsh|py|rb|pl|js|mjs|cjs|ts|ps1)$/i;
267
+
268
+ /**
269
+ * 载荷里**看得见的**可执行迹象:可执行位、脚本扩展名、shebang。
270
+ * 🔴 单独导出,因为 `tier-gate.mjs` 也要用它 —— 那里判的是
271
+ * 「审查等级能不能只信投稿者自己填的 capabilities」。
272
+ * 两处用同一条判据,不会分叉。
273
+ */
274
+ export function executableEvidence(entries) {
275
+ const out = [];
276
+ for (const e of entries) {
277
+ if ((e.mode & 0o111) !== 0) { out.push(`${e.path}:mode 0${e.mode.toString(8)} 带可执行位`); continue; }
278
+ if (SCRIPT_EXT.test(e.path)) { out.push(`${e.path}:看起来是脚本(按扩展名)`); continue; }
279
+ if (e.data.toString('utf8').startsWith('#!')) out.push(`${e.path}:以 shebang 开头`);
280
+ }
281
+ return out;
282
+ }
283
+ /** `http://` / `https://`;不含 `https://` 出现在纯文本说明里的情况 —— 分不开,见下。 */
284
+ const RE_EXTERNAL_URL = /\bhttps?:\/\/[^\s)"'<>]+/i;
285
+
286
+ /**
287
+ * §6:「声明 `none` 却含 `0755` / 外部 URL / 脚本 → 拒绝」。
288
+ *
289
+ * ⚠️ **这条门天生是启发式的,不要把它当成安全保证。**
290
+ * · `0755` 是**确定**信号:可执行位就是可执行位;
291
+ * · 「脚本」按扩展名 + shebang 判 —— 一个没扩展名、没 shebang 的脚本躲得过去;
292
+ * · 「外部 URL」最粗:一条指向文档的链接与一条「去这里取指令」在字节上没有区别。
293
+ * §8 的人工门第 6 条(间接指令)才是真正处理它的地方,本门只能拦住最直白的。
294
+ * 🔴 **外部 URL 这一项只告警、不拒绝**,理由见下面 `warnings` 那段 ——
295
+ * 它是本模块唯一一处**偏离 §6 字面表述**的地方,已记进交付汇报。
296
+ * 漏报的代价由 §8 的人来兜。
297
+ *
298
+ * @param {string[]} a.capabilities
299
+ * @param {Array<{path:string, mode:number, data:Buffer}>} a.entries packer.collectTree 的产物
300
+ */
301
+ export function assertCapabilityConsistency({ capabilities, entries }) {
302
+ // 🔴 **先去重再比。** 早先写的是 `length === 1 && [0] === 'none'`,于是
303
+ // `['none','none']` 会让 `declaredNone` 为假、整道门被跳过 —— 一个纯粹靠
304
+ // 写两遍就能绕过的门(Codex 2026-08-31)。schema 那边没有 uniqueItems。
305
+ const uniqCaps = Array.isArray(capabilities) ? [...new Set(capabilities)] : null;
306
+ const declaredNone = uniqCaps !== null && uniqCaps.length === 1 && uniqCaps[0] === 'none';
307
+ if (!declaredNone) return { checked: false, reason: '未声明 none,本门不适用' };
308
+
309
+ const problems = executableEvidence(entries);
310
+ const warnings = [];
311
+ for (const e of entries) {
312
+ const m = RE_EXTERNAL_URL.exec(e.data.toString('utf8'));
313
+ if (m) warnings.push(`${e.path}:含外部 URL ${m[0].slice(0, 60)}`);
314
+ }
315
+ if (warnings.length) {
316
+ // 🔴 **外部 URL 只告警,不拒绝** —— 这一条**偏离** §6 的字面表述,理由写在这里。
317
+ // §6 把「外部 URL」与「0755 / 脚本」并列成拒绝条件,但两者性质不同:
318
+ // `SKILL.md` 里放一条参考文档的链接**极其常见**,而「引用外部资料」
319
+ // 不等于「运行时访问外部网络」。按字面实现的话,几乎每个声明 `none` 的
320
+ // 正常 skill 都会被拒 —— 而逼人去掉链接、或改声明 `network`(那会错误地
321
+ // 抬高审查 Tier 与安装确认要求),两条出路都是错的(Codex 2026-08-31)。
322
+ // ⚠️ 一道几乎总在报红的门,两周之内就会被关掉 —— 那时它一条都拦不住了。
323
+ // 真正处理它的是 §8 人工门第 6 条(间接指令:「引用外部文档,按那里说的做」)。
324
+ // 这条偏离已记进交付汇报,等规格侧确认。
325
+ for (const w of warnings) process.stderr.write(`⚠️ capability 一致性(仅告警):${w}\n`);
326
+ }
327
+ if (problems.length) {
328
+ bad('E_CAPABILITY_INCONSISTENT',
329
+ `声明了 capabilities: ["none"],但载荷里有:\n`
330
+ + problems.map((p) => ` · ${p}`).join('\n')
331
+ + '\n §6:声明不实视为恶意投稿(§7 末段)。要么去掉,要么如实声明对应的 capability。');
332
+ }
333
+ return { checked: true, problems: [], warnings };
334
+ }
335
+
336
+ export { GateError };
package/src/cli.mjs CHANGED
@@ -29,9 +29,18 @@ const HELP = `skills-hub —— geoly skill 分发(M1 + M2 的命令面)
29
29
  把 pack 与全部成员物化成一棵目录树(不走安装账本)
30
30
  update [<spec>…] | --all 重解析账本里的 root:展示 diff、确认后在一个事务里应用
31
31
  remove <name> 减引用;🔴 **引用归零才删目录**(why <name> 看谁在要它)
32
+ publish [path] [--dry-run] 投稿:本地过一遍服务端那批门 → 建 fork → 开 PR
33
+ 🔴 用你**已有**的 GitHub token(GEOLY_GITHUB_TOKEN /
34
+ GH_TOKEN / GITHUB_TOKEN / \`gh auth token\`),CLI
35
+ **不持有、不存储**它,也没有 login / logout。
36
+ 动手前会把 token 的权限面摆给你看并要求确认 ——
37
+ \`--yes\` 只表示**确认了那个风险**,不跳过任何校验。
32
38
 
33
39
  stats [--json] [--export <file>] 本地埋点报表
34
- telemetry <status|flush> 埋点/上报开关与队列
40
+ telemetry <status|flush|on|off|delete>
41
+ 埋点开关、队列、身份三项的退出;delete 先向服务端申请删除
42
+ 已发出的身份记录(--offline / GEOLY_TELEMETRY=0 /
43
+ GEOLY_TELEMETRY_UPLOAD=0 下不出网),再清本机
35
44
 
36
45
  全局 flag(09-cli.md §2):
37
46
  --clients <list> 默认 = 本机已存在的全部;含未安装 client 是硬错误
@@ -59,7 +68,7 @@ const HELP = `skills-hub —— geoly skill 分发(M1 + M2 的命令面)
59
68
 
60
69
  埋点(docs/telemetry/00-spec.md):
61
70
  🔴 上报**默认开**(有内置默认端点)。**install 成功收尾后**会静默发一次
62
- (24 小时最多一次,超时 1 秒,失败不影响安装);别的命令只写本地,
71
+ (24 小时最多一次,超时 3 秒,失败后 1 小时可再试,不影响安装);别的命令只写本地,
63
72
  也可以随时 \`telemetry flush\` 手动发。
64
73
  采集面是穷举白名单 —— 不含路径、目录清单、文件内容、用户名。
65
74
  GEOLY_TELEMETRY=0 完全关闭:本地一个字节都不写
@@ -83,6 +92,7 @@ const COMMANDS = {
83
92
  remove: () => import('./commands/remove.mjs').then((m) => m.cmdRemove),
84
93
  update: () => import('./commands/update.mjs').then((m) => m.cmdUpdate),
85
94
  vendor: () => import('./commands/vendor.mjs').then((m) => m.cmdVendor),
95
+ publish: () => import('./commands/publish.mjs').then((m) => m.cmdPublish),
86
96
  };
87
97
 
88
98
  /**
@@ -149,9 +159,19 @@ export async function main(argv, deps = {}) {
149
159
  return EXIT.OK;
150
160
  }
151
161
 
152
- // 埋点两个子命令是纯本地的,不需要快照/锁/target
162
+ // 埋点两个子命令是纯本地的,不需要快照/锁/target。
163
+ // 🔴 **但它们照样要守输出契约**:早先 telemetry 那条在顶层 try/catch 之外,
164
+ // 于是 `telemetry delete --json` 在状态目录不存在时抛 ERR_SQLITE_ERROR,
165
+ // stdout / stderr 双双为空 —— 调用方拿到一个非零退出码和零个字节
166
+ // (Codex 2026-09-09 实测复现)。这里补一层:异常按 §7 的错误形状出。
153
167
  if (cmd === 'stats') return cmdStats(rest.slice(1), globals, stdout, stderr);
154
- if (cmd === 'telemetry') return cmdTelemetry(rest.slice(1), stdout, stderr);
168
+ if (cmd === 'telemetry') {
169
+ try {
170
+ return await cmdTelemetry(rest.slice(1), globals, out, stdout, stderr);
171
+ } catch (err) {
172
+ return out.emitError('telemetry', classify(err), err);
173
+ }
174
+ }
155
175
 
156
176
  const load = COMMANDS[cmd];
157
177
  if (!load) {
@@ -180,9 +200,9 @@ export async function main(argv, deps = {}) {
180
200
  // 不是靠记得写 if,而是靠控制流。
181
201
  //
182
202
  // 🔴 这里 `await`,不是发了就不管:进程一退,在途的 POST 就没了。
183
- // ⚠️ 代价是**这一次 install 会等它**。网络那一段有 1 秒上界
203
+ // ⚠️ 代价是**这一次 install 会等它**。网络那一段有 3 秒上界
184
204
  // (AUTO_UPLOAD_TIMEOUT_MS),但**整段没有**:认领戳的 fsync、上报锁、
185
- // stage/reap 都是超时之外的同步 I/O。别把「最多多 1 秒」说成整体保证
205
+ // stage/reap 都是超时之外的同步 I/O。别把「最多多 3 秒」说成整体保证
186
206
  // (规格 §5.1.1 已按实话改写)。
187
207
  //
188
208
  // 🔴 返回值**故意丢弃**:不打印、不改退出码。一次成功的 install 绝不能
@@ -228,6 +248,9 @@ async function noticeOnce(stderr) {
228
248
  let url = null;
229
249
  try { url = endpoint(); } catch { return; } // 端点配坏了 = 不会出网
230
250
  tm.maybeNoticeUpload((s) => stderr.write(s), url);
251
+ // 🔴 身份告知是**另一段**:只有在有人显式打开身份采集时才打。
252
+ // 它同时是 identityEnabled() 的硬前置 —— 没打过就一项都不采。
253
+ tm.maybeNoticeIdentity((s) => stderr.write(s), url);
231
254
  } catch { /* 告知不是主命令的一部分(T-5) */ }
232
255
  }
233
256
 
@@ -271,13 +294,35 @@ async function cmdStats(args, globals, stdout, stderr) {
271
294
  return EXIT.OK;
272
295
  }
273
296
 
274
- async function cmdTelemetry(args, stdout, stderr) {
297
+ // `--json` 是**全局** flag,解析后放在 globals 里、已经从 args 里摘走了 ——
298
+ // 所以这里读 globals.json,不是 args.includes('--json')。
299
+ // ⚠️ 我第一版就是写成后者,跑出来一点反应都没有:flag 根本不在 args 里。
300
+ async function cmdTelemetry(args, globals, out, stdout, stderr) {
275
301
  const tm = await import('./telemetry.mjs');
276
302
  const up = await import('./upload.mjs');
277
303
  switch (args[0]) {
278
304
  case 'status': {
279
305
  let ep;
280
306
  try { ep = up.endpoint(); } catch (e) { ep = `无效(${e.message})`; }
307
+ // 🔴 身份与匿名计数分两行显示。合成一行「上报:开」的话,
308
+ // 关掉身份的用户看不出「我关的那个到底关了没有」。
309
+ const identity = tm.identityEnabled() ? '开'
310
+ : tm.enabled() ? '关(身份三项不发,匿名计数照发)' : '关(埋点整体已关)';
311
+ if (globals.json) {
312
+ stdout.write(`${JSON.stringify({
313
+ schema: 'geoly.skills.telemetry-status/1',
314
+ telemetry: tm.enabled(),
315
+ upload: tm.uploadEnabled(),
316
+ identity: tm.identityEnabled(),
317
+ endpoint: ep ?? null,
318
+ endpointIsDefault: ep ? up.isDefaultEndpoint() : null,
319
+ permWarning: tm.permWarning(),
320
+ queued: tm.readAll().length,
321
+ history: tm.readHistory().length,
322
+ stateDir: tm.stateDir(),
323
+ })}\n`);
324
+ return EXIT.OK;
325
+ }
281
326
  const uploadOff = !tm.enabled() ? '关(埋点整体已关)'
282
327
  : tm.offline() ? '关(--offline)'
283
328
  : '关(GEOLY_TELEMETRY_UPLOAD=0)';
@@ -286,13 +331,23 @@ async function cmdTelemetry(args, stdout, stderr) {
286
331
  // 🔴 端点默认开(2026-09-01 拍板),所以「这是内置默认值」必须写在脸上:
287
332
  // 用户有权知道数据默认发去哪,而不是去翻源码才发现有个默认端点。
288
333
  stdout.write(`端点 ${ep ?? '无'}${ep && up.isDefaultEndpoint() ? '(内置默认值 —— 未配 GEOLY_TELEMETRY_ENDPOINT)' : ''}\n`);
334
+ stdout.write(`身份三项 ${identity}\n`);
289
335
  stdout.write(`待上报 ${tm.readAll().length} 条\n`);
290
336
  stdout.write(`报表历史 ${tm.readHistory().length} 条(上报不消费它)\n`);
291
337
  stdout.write(`状态目录 ${tm.stateDir()}\n`);
338
+ // 🔴 权限收不紧要**说出来**:悄悄地继续写,意味着没人知道这台机器上的
339
+ // 埋点目录是所有人可读的 —— 而里面可能有登录名与主机名。
340
+ const pw = tm.permWarning();
341
+ if (pw) stdout.write(`⚠️ 权限 ${pw}\n`);
292
342
  return EXIT.OK;
293
343
  }
294
344
  case 'flush': {
295
345
  const r = await up.flush();
346
+ if (globals.json) {
347
+ const bad = r.skipped && (r.reason === 'bad-endpoint' || r.reason?.startsWith('error:'));
348
+ return out.emit('telemetry', { action: 'flush', sent: r.sent ?? 0, skipped: !!r.skipped, reason: r.reason ?? null },
349
+ bad ? EXIT.USAGE : EXIT.OK);
350
+ }
296
351
  stdout.write(r.skipped
297
352
  ? `未上报:${r.reason}${r.detail ? `(${r.detail})` : ''}\n`
298
353
  : `已上报 ${r.sent} 条\n`);
@@ -302,8 +357,135 @@ async function cmdTelemetry(args, stdout, stderr) {
302
357
  const bad = r.skipped && (r.reason === 'bad-endpoint' || r.reason?.startsWith('error:'));
303
358
  return bad ? EXIT.USAGE : EXIT.OK;
304
359
  }
360
+ case 'off': {
361
+ const all = args.includes('--all');
362
+ // 🔴 **`--all` 做不到它字面的意思,所以它不许假装做到了。**
363
+ // 「什么都不发」等价于 GEOLY_TELEMETRY=0,而那个开关承诺的是
364
+ // 「本地一个字节都不写」—— 一个要先写标记文件才能生效的持久开关,
365
+ // 与那句承诺自相矛盾。所以这里只关身份,并**如实说**全关要走环境变量。
366
+ // 早先这条的文案写着「什么都不发」而实现只关了身份(Codex 指出)。
367
+ // 🔴 埋点整体已关时**什么都不写**:那种状态下建目录、落标记,
368
+ // 本身就违反了那句承诺。
369
+ if (!tm.enabled()) {
370
+ const msg = '埋点整体已经是关的(GEOLY_TELEMETRY=0),没有可关的东西,也不写任何文件。';
371
+ if (globals.json) return out.emit('telemetry', { action: 'off', changed: false, identity: false }, EXIT.OK);
372
+ stdout.write(`${msg}\n`);
373
+ return EXIT.OK;
374
+ }
375
+ tm.identityOff();
376
+ if (globals.json) {
377
+ return out.emit('telemetry', {
378
+ action: 'off', all, changed: true, identity: tm.identityEnabled(),
379
+ note: all ? '全关需要环境变量 GEOLY_TELEMETRY=0' : undefined,
380
+ }, EXIT.OK);
381
+ }
382
+ stdout.write('已关闭身份三项(登录名 / 主机名 / 来源 IP)。\n');
383
+ stdout.write('匿名计数照发 —— 装了什么、成不成功、耗时。\n');
384
+ if (all) {
385
+ stdout.write('\n⚠️ `--all` 只关掉了身份三项。要连匿名计数一起停,请设\n');
386
+ stdout.write(' GEOLY_TELEMETRY=0 —— 它必须是环境变量:那个开关承诺的是\n');
387
+ stdout.write(' 「本地一个字节都不写」,而一个要先写标记文件才生效的持久开关\n');
388
+ stdout.write(' 与这句承诺自相矛盾。\n');
389
+ }
390
+ stdout.write('已经发出去的身份记录不会自动删除:skills-hub telemetry delete\n');
391
+ return EXIT.OK;
392
+ }
393
+ case 'on': {
394
+ if (!tm.enabled()) {
395
+ if (globals.json) return out.emit('telemetry', { action: 'on', changed: false }, EXIT.OK);
396
+ stdout.write('埋点整体已关(GEOLY_TELEMETRY=0),不写任何文件。\n');
397
+ return EXIT.OK;
398
+ }
399
+ tm.identityOn();
400
+ if (globals.json) {
401
+ return out.emit('telemetry', { action: 'on', changed: true, identity: tm.identityEnabled() }, EXIT.OK);
402
+ }
403
+ stdout.write('已撤销「关闭身份」的标记。\n');
404
+ // 🔴 说清楚它**没有**打开身份采集:还要过环境变量与告知两道门。
405
+ // 不写这句的话,用户会以为 `telemetry on` 就是开了。
406
+ stdout.write(`当前身份三项:${tm.identityEnabled() ? '开' : '仍然是关的'}\n`);
407
+ if (!tm.identityEnabled()) {
408
+ stdout.write('要真正打开还需要 GEOLY_TELEMETRY_IDENTITY=on,并看过一次身份告知。\n');
409
+ }
410
+ return EXIT.OK;
411
+ }
412
+ case 'delete': {
413
+ // 🔴 **先远程、后本机。** 本机那一步会删掉私钥,而私钥是远程删除唯一的所有权证明
414
+ // (规格 §4.4)。倒过来做,服务端那批数据就再也证明不了是谁的。
415
+ const key = tm.deleteKey();
416
+ // 私钥只在身份开启时生成,身份开启又要求告知过。所以「没私钥、也没告知过」=
417
+ // 这台机器从没发过带身份的数据,服务端没有可按所有权删的东西。
418
+ // 反过来「告知过却没私钥」= 私钥丢了或坏了,**不能**说服务端没有数据(Codex 2026-09-13 P1)。
419
+ const identityEver = key !== null || tm.identityNoticeShown();
420
+ const remote = key ? await up.remoteDelete({ key }) : null;
421
+ const r = tm.purgeLocal({ keepDeleteKey: key !== null && !remote.ok });
422
+ const vetoed = remote !== null && (remote.reason === 'offline' || remote.reason === 'upload-disabled');
423
+
424
+ /** @type {'deleted'|'deleted-key-left'|'vetoed'|'remote-failed'|'unprovable'|'nothing-remote'} */
425
+ let status;
426
+ let code;
427
+ if (remote?.ok) {
428
+ status = r.deleteKeyRemoved ? 'deleted' : 'deleted-key-left';
429
+ code = r.deleteKeyRemoved ? EXIT.OK : EXIT.PARTIAL;
430
+ } else if (vetoed) {
431
+ status = 'vetoed'; code = EXIT.PARTIAL;
432
+ } else if (remote) {
433
+ status = 'remote-failed'; code = EXIT.NETWORK;
434
+ } else if (identityEver) {
435
+ status = 'unprovable'; code = EXIT.PARTIAL;
436
+ } else {
437
+ status = 'nothing-remote'; code = EXIT.OK;
438
+ }
439
+ // 🔴 本机删不干净也要反映到退出码上:「已清空」和「清了一部分」是两件事
440
+ const leftovers = r.remaining.filter((n) => !n.startsWith('upload.lock'));
441
+ if (leftovers.length > 0 && code === EXIT.OK) code = EXIT.PARTIAL;
442
+
443
+ if (globals.json) {
444
+ return out.emit('telemetry', {
445
+ action: 'delete', remote: status, reason: remote?.ok ? null : (remote?.reason ?? null),
446
+ removed: r.removed, remaining: leftovers, kept: r.kept, deleteKeyRemoved: r.deleteKeyRemoved,
447
+ }, code);
448
+ }
449
+ switch (status) {
450
+ case 'deleted':
451
+ stdout.write('服务端已删除这台机器发出的身份记录(登录名 / 主机名 / 来源 IP),\n');
452
+ stdout.write('并剥掉了那些事件上的 install_id。匿名计数保留到到期。\n');
453
+ break;
454
+ case 'deleted-key-left':
455
+ stdout.write('服务端已删除身份记录,但本机的删除私钥没删掉。\n');
456
+ stdout.write('⚠️ 在删掉它之前,这台机器之后发出的身份记录会被服务端当作「已申请删除」而丢弃。\n');
457
+ break;
458
+ case 'vetoed':
459
+ stdout.write('⚠️ 没有向服务端申请删除:--offline / GEOLY_TELEMETRY=0 / GEOLY_TELEMETRY_UPLOAD=0\n');
460
+ stdout.write(' 都承诺过不发网络请求,删除请求也是网络请求。\n');
461
+ stdout.write(' 删除私钥已保留 —— 临时解除这些开关后再跑一次 `skills-hub telemetry delete`。\n');
462
+ break;
463
+ case 'remote-failed':
464
+ stdout.write(`⚠️ 服务端删除没有成功(${remote.reason}${remote.detail ? `:${remote.detail}` : ''})。\n`);
465
+ stdout.write(' 删除私钥已保留 —— 稍后再跑一次 `skills-hub telemetry delete`。\n');
466
+ break;
467
+ case 'unprovable':
468
+ stdout.write('⚠️ 这台机器看过身份告知,但找不到可用的删除私钥(丢失或损坏),\n');
469
+ stdout.write(' 无法向服务端证明那些身份记录是你的。它们会在 90 天后到期删除。\n');
470
+ break;
471
+ default:
472
+ stdout.write('这台机器从没发出过带身份的记录,服务端没有需要按所有权删除的东西。\n');
473
+ }
474
+ stdout.write(`本机埋点数据已清空(删了 ${r.removed} 个文件,含 install-id 与上报墓碑)。\n`);
475
+ if (r.kept.includes('identity-off')) stdout.write('「关闭身份」的设置保留着。\n');
476
+ if (leftovers.length > 0) stdout.write(`⚠️ 这些没删掉:${leftovers.join('、')}\n`);
477
+ // 删除的是「过去的」;身份采集如果仍开着,之后的新事件照样会带身份
478
+ if (tm.identityEnabled()) {
479
+ stdout.write('ℹ️ 身份采集仍然开着,之后的事件会带一把新的删除密钥重新采集 —— 要停请 `skills-hub telemetry off`。\n');
480
+ }
481
+ return code;
482
+ }
305
483
  default:
306
- stderr.write('用法:skills-hub telemetry <status|flush>\n');
484
+ if (globals.json) {
485
+ return out.emitError('telemetry', classify(new UsageError('x')),
486
+ new Error('用法:skills-hub telemetry <status|flush|on|off [--all]|delete>'));
487
+ }
488
+ stderr.write('用法:skills-hub telemetry <status|flush|on|off [--all]|delete>\n');
307
489
  return EXIT.USAGE;
308
490
  }
309
491
  }
@@ -17,6 +17,7 @@ import { existsSync, readFileSync } from 'node:fs';
17
17
  import { isAbsolute, resolve as resolvePath } from 'node:path';
18
18
  import { UsageError, UnsupportedError } from '../exit-codes.mjs';
19
19
  import { SCAN_CEILINGS } from '../target.mjs';
20
+ import { ownVersion } from '../version.mjs';
20
21
 
21
22
  /** §2 那张表。`arg: true` = 后面跟一个值。 */
22
23
  const GLOBAL_FLAGS = Object.freeze({
@@ -226,18 +227,9 @@ export function uintArg(name, val) {
226
227
  * 🔴 读不到就**抛**,不要退回一个假版本号:一个编出来的版本号会让
227
228
  * 版本门做出错误判定,而那正是这道门要防的事。
228
229
  */
229
- let cachedVersion;
230
- export function ownVersion() {
231
- if (cachedVersion === undefined) {
232
- const p = new URL('../../package.json', import.meta.url);
233
- const v = JSON.parse(readFileSync(p, 'utf8')).version;
234
- if (typeof v !== 'string' || v === '') {
235
- throw new Error('读不出本 CLI 的版本号(package.json 的 version 不是非空字符串)');
236
- }
237
- cachedVersion = v;
238
- }
239
- return cachedVersion;
240
- }
230
+ // 实现搬到 src/version.mjs:埋点的 `cli` 字段要取同一个值(见那个文件的注释)。
231
+ // 这里保留导出,调用方与测试不用改。
232
+ export { ownVersion };
241
233
 
242
234
  export function makeContext(globals, deps = {}) {
243
235
  const env = deps.env ?? process.env;