@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,244 @@
1
+ // `publish` 的 token 来源解析 —— 06-submission.md §9 的**偏离版**
2
+ // (10-open-questions.md Q4,2026-09-05 用户拍板)。
3
+ //
4
+ // ── 🔴 这个模块的**唯一**产品承诺:CLI 不持有 token ──────────────────────
5
+ //
6
+ // 我们不注册 OAuth App / GitHub App,不做 device flow,不做 login / logout,
7
+ // **也不存任何东西**。token 只在一次 `publish` 的进程生命周期里存在于内存,
8
+ // 用完即随进程消失。`src/auth.mjs` 的 keychain 存储在这个模型下**不再被使用** ——
9
+ // 它没有被删掉(别的东西可能还引用它),但 publish 这条路径一个字节都不写。
10
+ //
11
+ // ⚠️ **不要把它描述成「token 权限很小」。** 恰恰相反:用户已有的 token
12
+ // 很可能是 classic `repo` PAT,权限面比 `public_repo` 还大,而**我们无法收窄它**。
13
+ // 「CLI 不持有」与「权限很小」是两件完全不同的事,只有前者是我们能保证的。
14
+ // 权限面必须在任何写操作之前如实披露 —— 那是 `commands/publish.mjs` 的事。
15
+ //
16
+ // ── 🔴 token 的六条禁令(每一条都有测试) ────────────────────────────────
17
+ // ① 不落盘 ② 不进 argv ③ 不进子进程环境
18
+ // ④ 不进日志 ⑤ 不进错误文案 ⑥ 不进 telemetry
19
+ // 前三条靠**控制流**保证(本模块从不 write、从不把 token 放进 args/env);
20
+ // 后三条靠 `scrub()` 兜底 —— 它是最后一道,不是第一道。
21
+
22
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
23
+
24
+ /**
25
+ * 来源顺序。**顺序本身是判据的一部分**,不是实现细节:
26
+ * 用户看到的身份必须与实际投稿身份一致,所以「用了哪一个」要能说得出来。
27
+ *
28
+ * ⚠️ `GITHUB_TOKEN` 在 GitHub Actions 里**会自动存在**(每个 job 都注入)。
29
+ * 它排在最后,但仍然排在 `gh` 前面 —— 于是在 CI 里,一个以为自己在用
30
+ * `gh` 身份的用户实际会用上 Actions 的 job token。这不是可以静默处理的事:
31
+ * 披露里必须把「用的是哪一个变量」写在脸上(见 `describeSource`)。
32
+ */
33
+ export const TOKEN_ENV_ORDER = Object.freeze([
34
+ 'GEOLY_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN',
35
+ ]);
36
+
37
+ /**
38
+ * 跑 `gh` 之前要从继承环境里**删掉**的变量。
39
+ *
40
+ * 🔴 `GH_HOST` 必须删 + 显式覆盖成 `github.com`:留着继承值的话,
41
+ * 一个配了 GHE 的用户会拿到**另一台主机**的 token,而我们接下来把它发去
42
+ * `api.github.com` —— 那是一次真正的凭据泄漏,不是配置错误。
43
+ * 🔴 token 类变量也要删:`gh auth token` 在 `GH_TOKEN` 存在时会**原样回显它**。
44
+ * 我们只有在三个变量都不存在时才走到这里,所以这一条在今天是冗余的 ——
45
+ * 留着是因为它防的是「将来有人改了上面的顺序」,而那种改动不会让任何测试变红。
46
+ */
47
+ const GH_ENV_STRIP = Object.freeze([
48
+ 'GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN',
49
+ 'GH_HOST', 'GH_REPO',
50
+ ]);
51
+
52
+ export class TokenError extends Error {
53
+ /** 认证/权限类一律 EXIT.AUTH(7) —— 见 09-cli.md §6。 */
54
+ constructor(message) { super(message); this.name = 'TokenError'; this.exitCode = 7; }
55
+ }
56
+
57
+ /**
58
+ * token 的形状门。**不是**在验证它有效(那要出网),是在拒绝明显不是 token 的东西。
59
+ *
60
+ * 🔴 存在的理由很具体:`gh auth token` 失败时也可能往 stdout 写东西,
61
+ * 多行输出 / 带空白的输出被当成 token 塞进 `Authorization` header,
62
+ * 会造成一次**带着垃圾内容的请求**,而且那段垃圾可能被服务端记进日志。
63
+ * 宁可在这里失败。
64
+ */
65
+ export function assertTokenShape(raw, where) {
66
+ if (typeof raw !== 'string') throw new TokenError(`${where} 给出的不是字符串`);
67
+ // 🔴 这里**不能**把值放进错误文案 —— 哪怕它"看起来不像 token"。
68
+ if (raw.length < 8 || raw.length > 512) {
69
+ throw new TokenError(`${where} 给出的 token 长度不合理(应在 8–512 之间)—— 已忽略,不予使用。`);
70
+ }
71
+ if (!/^[\x21-\x7e]+$/.test(raw)) {
72
+ throw new TokenError(
73
+ `${where} 给出的 token 含空白或不可见字符 —— 拒绝使用。\n`
74
+ + ' 🔴 常见成因:把整段命令输出(含换行/提示语)塞进了环境变量。',
75
+ );
76
+ }
77
+ return raw;
78
+ }
79
+
80
+ /**
81
+ * 遮蔽器 —— **最后一道,不是第一道**。
82
+ *
83
+ * 🔴 两条规则缺一不可:
84
+ * ① 替换掉**这次实际用的** token 原文(最准,但只覆盖我们知道的那一个);
85
+ * ② 按**形态**替换任何看起来像 GitHub 凭据的串 —— 覆盖「响应体里回显了另一个
86
+ * token」「用户把 token 写进了 PR body」这类我们不知道的情况。
87
+ * 只做 ① 的话,一次 `422` 响应体里回显的**别的**凭据会被原样打出来。
88
+ *
89
+ * ⚠️ **诚实边界**:`②` 认的是今天 GitHub 在用的前缀(`ghp_`/`gho_`/`ghu_`/`ghs_`/
90
+ * `ghr_`/`github_pat_`)。老式的 40 位 hex PAT **不在其中** —— 那个形态与
91
+ * commit sha 完全一样,按形态替换会把每一个 sha 都打成 `***`,而 sha 正是
92
+ * 本命令要给用户看的关键事实。老式 PAT 只由 ① 兜住。
93
+ */
94
+ export function scrub(text, token = null) {
95
+ let s = String(text);
96
+ if (typeof token === 'string' && token.length >= 8) {
97
+ s = s.split(token).join('***');
98
+ }
99
+ return s
100
+ .replace(/\bgh[pousr]_[A-Za-z0-9]{16,}/g, '***')
101
+ .replace(/\bgithub_pat_[A-Za-z0-9_]{16,}/g, '***');
102
+ }
103
+
104
+ /**
105
+ * 环境变量这一层。返回**存在**的那些(trim 后非空才算存在)。
106
+ * 🔴 空串视为"不存在"而不是"存在但为空":CI 里把一个变量置空是常见的
107
+ * "关掉它"写法,把它当成一个候选会让后面的冲突检测报出无意义的红。
108
+ */
109
+ export function collectEnvTokens(env) {
110
+ const found = [];
111
+ for (const name of TOKEN_ENV_ORDER) {
112
+ const raw = env?.[name];
113
+ if (typeof raw !== 'string') continue;
114
+ const v = raw.trim();
115
+ if (v === '') continue;
116
+ found.push({ name, value: v });
117
+ }
118
+ return found;
119
+ }
120
+
121
+ /**
122
+ * 🔴 多个来源同时存在**且值不同** → 直接拒,不静默挑一个。
123
+ *
124
+ * 为什么不"按顺序挑第一个就好":用户看到的身份与实际投稿身份必须一致。
125
+ * 一台机器上同时有 `GH_TOKEN`(个人账号)与 `GITHUB_TOKEN`(CI job token)
126
+ * 是**常态**,两者是不同的身份 —— 静默挑一个的话,投稿会以他没预期的身份提交,
127
+ * 而他要到 PR 页面上才看得出来。
128
+ *
129
+ * ⚠️ 值**相同**时不拒:那只是同一个 token 被写进了多个变量,身份没有歧义。
130
+ * 但披露里仍然要把其余变量名列出来(`others`)。
131
+ *
132
+ * 🔴 错误文案里**只有变量名**。不列值、不列长度、不列前缀 ——
133
+ * 长度和前缀足以区分 classic PAT / fine-grained / Actions token,
134
+ * 那已经是在泄漏「这台机器上有什么凭据」了。
135
+ */
136
+ export function resolveEnvToken(env) {
137
+ const found = collectEnvTokens(env);
138
+ if (found.length === 0) return null;
139
+ const distinct = new Set(found.map((f) => f.value));
140
+ if (distinct.size > 1) {
141
+ throw new TokenError(
142
+ `检测到多个 GitHub token 环境变量,且它们的值**不同**:${found.map((f) => f.name).join('、')}。\n`
143
+ + ' 🔴 拒绝替你挑一个 —— 它们是不同的身份,挑错了你会以没预期的账号投稿,\n'
144
+ + ' 而要到 PR 页面上才看得出来。\n'
145
+ + ` ⚠️ 如果你在 GitHub Actions 里:\`GITHUB_TOKEN\` 是 runner 自动注入的 job token,\n`
146
+ + ' 它多半不是你想用的那个身份。\n'
147
+ + ' 怎么办:只留一个,或显式设 `GEOLY_GITHUB_TOKEN` 指定你要用的那一个\n'
148
+ + ' (它的优先级最高,但**值不同时本命令仍然拒绝** —— 请把其余的 unset)。',
149
+ );
150
+ }
151
+ const chosen = found[0];
152
+ assertTokenShape(chosen.value, `环境变量 ${chosen.name}`);
153
+ return {
154
+ token: chosen.value,
155
+ source: `env:${chosen.name}`,
156
+ sourceLabel: `环境变量 ${chosen.name}`,
157
+ others: found.slice(1).map((f) => f.name),
158
+ };
159
+ }
160
+
161
+ /**
162
+ * `gh auth token` 这一层。
163
+ *
164
+ * 🔴 `shell: false`(`spawnSync` 的默认,这里显式写出来):绝不经过 shell。
165
+ * 🔴 `--hostname github.com` 固定,并且**同时**把继承来的 `GH_HOST` 删掉再显式设回
166
+ * —— 两条都要:`--hostname` 决定它去哪个 host 取凭据,删 `GH_HOST` 防的是
167
+ * 将来有人去掉那个参数时静默退回继承值。
168
+ * 🔴 **stdout / stderr 一律不原样输出。** gh 的输出里可能带 token
169
+ * (`gh auth token` 的正常输出**就是** token),把它拼进错误文案是最容易犯的错。
170
+ * 这里按 exit code 分档给固定文案。
171
+ */
172
+ export function resolveGhToken(env, spawnSync = nodeSpawnSync) {
173
+ const childEnv = { ...env };
174
+ for (const k of GH_ENV_STRIP) delete childEnv[k];
175
+ childEnv.GH_HOST = 'github.com';
176
+ // 🔴 `GH_PROMPT_DISABLED` / `NO_COLOR`:不让它试图交互,也不要 ANSI 转义进 stdout。
177
+ childEnv.GH_PROMPT_DISABLED = '1';
178
+ childEnv.NO_COLOR = '1';
179
+
180
+ let r;
181
+ try {
182
+ r = spawnSync('gh', ['auth', 'token', '--hostname', 'github.com'], {
183
+ shell: false,
184
+ encoding: 'utf8',
185
+ env: childEnv,
186
+ timeout: 15000,
187
+ // 🔴 很小的 maxBuffer:`gh auth token` 的正常输出是**一行**。
188
+ // 留一个大缓冲区,等于愿意把一大坨我们不打算读的东西收进内存。
189
+ maxBuffer: 64 * 1024,
190
+ windowsHide: true,
191
+ });
192
+ } catch (e) {
193
+ // spawn 本身抛(极少见)——**不带 e.message**,它可能含路径等环境信息。
194
+ throw new TokenError(`调用 \`gh\` 失败(${e?.code ?? 'spawn 错误'})。${GH_HINT}`);
195
+ }
196
+ if (r.error) {
197
+ const code = r.error.code;
198
+ if (code === 'ENOENT') {
199
+ throw new TokenError(`找不到 \`gh\`(GitHub CLI 未安装或不在 PATH 上)。${GH_HINT}`);
200
+ }
201
+ throw new TokenError(`调用 \`gh\` 失败(${code ?? 'spawn 错误'})。${GH_HINT}`);
202
+ }
203
+ // 🔴 超时被 kill 时 `status` 是 null、`signal` 有值 —— 单看 exit code 会漏。
204
+ if (r.signal !== null && r.signal !== undefined) {
205
+ throw new TokenError(`\`gh auth token\` 被信号 ${r.signal} 中止(多半是 15 秒超时)。${GH_HINT}`);
206
+ }
207
+ if (r.status !== 0) {
208
+ // 🔴 **不透传 gh 的 stderr。** gh 未登录时 exit 1、stderr 是人类文案,
209
+ // 看着无害 —— 但「看着无害」不是判据:没有任何机制保证它下一个版本不会
210
+ // 把 token 打进 stderr(比如某个 debug 开关从环境里被继承进去)。
211
+ // ⚠️ 也**不靠 exit code 分辨"没装"与"没登录"** —— 它分辨不了。
212
+ // 「没装」由上面的 `ENOENT` 分支认(那个判据是可靠的);
213
+ // 到这里就统一给"两条出路"的固定文案。
214
+ throw new TokenError(
215
+ `\`gh auth token --hostname github.com\` 以 exit ${r.status} 失败`
216
+ + '(最常见的原因是**没有登录 github.com**)。' + GH_HINT,
217
+ );
218
+ }
219
+ const out = typeof r.stdout === 'string' ? r.stdout.trim() : '';
220
+ if (out === '') throw new TokenError(`\`gh auth token\` 没有输出任何 token。${GH_HINT}`);
221
+ assertTokenShape(out, '`gh auth token`');
222
+ return { token: out, source: 'gh', sourceLabel: '`gh auth token`(GitHub CLI 的已登录身份)', others: [] };
223
+ }
224
+
225
+ const GH_HINT =
226
+ '\n 怎么办(二选一):\n'
227
+ + ' · `gh auth login --hostname github.com`(推荐:本命令不持有、也不存储任何 token)\n'
228
+ + ' · 或设 `GEOLY_GITHUB_TOKEN=<你的 token>` 后重跑\n'
229
+ + ' 🔴 本命令**不做** login,也**不会**把 token 存到任何地方 —— 撤销请去\n'
230
+ + ' GitHub 的 Settings → Developer settings,或 `gh auth logout`。';
231
+
232
+ /**
233
+ * 完整的来源解析:环境变量 → `gh`。
234
+ *
235
+ * @param {object} a
236
+ * @param {Record<string,string>} a.env
237
+ * @param {function} [a.spawnSync] 注入点(测试用)。生产走 node:child_process。
238
+ * @returns {{token:string, source:string, sourceLabel:string, others:string[]}}
239
+ */
240
+ export function resolveToken({ env, spawnSync = nodeSpawnSync }) {
241
+ const fromEnv = resolveEnvToken(env);
242
+ if (fromEnv !== null) return fromEnv;
243
+ return resolveGhToken(env, spawnSync);
244
+ }