@geoly-ai/skills-hub 0.3.6 → 0.3.7

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,6 +29,12 @@ 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
40
  telemetry <status|flush> 埋点/上报开关与队列
@@ -83,6 +89,7 @@ const COMMANDS = {
83
89
  remove: () => import('./commands/remove.mjs').then((m) => m.cmdRemove),
84
90
  update: () => import('./commands/update.mjs').then((m) => m.cmdUpdate),
85
91
  vendor: () => import('./commands/vendor.mjs').then((m) => m.cmdVendor),
92
+ publish: () => import('./commands/publish.mjs').then((m) => m.cmdPublish),
86
93
  };
87
94
 
88
95
  /**