wkgeo-cli 0.2.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/lib/page.mjs ADDED
@@ -0,0 +1,396 @@
1
+ /**
2
+ * 本机授权回调页面的 HTML 模板(成功页 + 各类错误页)。
3
+ *
4
+ * ## 为什么单独一个模块(而不是继续塞在 `login.mjs` 里)
5
+ * 1. 页面模板是**纯字符串**逻辑,与 OAuth 时序无关。抽出来之后单测可以
6
+ * **直接断言 HTML**(文案 / 品牌 / 不含令牌 / 不带外链 / 体积),
7
+ * 不必为了测一句文案就起一个本地 HTTP server 再发请求。
8
+ * 2. 老板 2026-10-01 截图吐槽的正是这一页(原来一共 764 字节:一个裸 `<h1>`
9
+ * 加两段 `<p>`,手机上顶格贴边)。页面一定会被反复改,
10
+ * 放在独立文件里改样式就**碰不到**授权时序那段危险代码。
11
+ *
12
+ * ## 🔴 这个页面的安全底线(改样式时不要破坏,有单测钉住)
13
+ * 1. **不出现任何令牌 / 授权码**。这里只有"成功 / 失败"这个事实和账号标识;
14
+ * 模板里**没有**任何位置接收 `access_token` / `code`,调用方也不该往里传。
15
+ * (`login.test.mjs` 里那条"成功页里没有任何令牌"的断言必须继续通过。)
16
+ * 2. **不引任何外链资源**:没有 CDN、没有 web font、没有 `<img src=http…>`、
17
+ * 没有统计脚本。这是跑在 `http://127.0.0.1:<随机端口>` 上的一次性页面,
18
+ * 一旦依赖网络,在离线 / 内网代理 / 被墙的机器上就会变成"半张白页"——
19
+ * 而它唯一要表达的只是"授权成功了,回终端去"。
20
+ * 图标一律**内联 SVG**,样式一律**内联 `<style>`**。
21
+ * 3. **不依赖 JavaScript**:正文(结论 + 账号 + 兜底文案)全部是静态 HTML + CSS。
22
+ * 这个页面出现的时机很敏感(用户刚在同意页点完"同意"),任何脚本报错
23
+ * 都会让人以为授权失败,所以脚本只能做**锦上添花**的事,绝不能承载信息。
24
+ * 目前唯一的脚本是**成功页**上的"尝试自动关闭标签页"(见 `AUTO_CLOSE_SCRIPT`):
25
+ * 它失败 / 被浏览器拒绝 / 被用户插件拦掉,页面照旧完整可读。
26
+ * ⚠️ 它**只**出现在成功页(那是换到令牌、凭据写盘之后的页),错误页一律没有脚本。
27
+ * 4. **所有动态内容必须转义**(`escapeHtml`)。账号标识来自服务端 `userinfo`,
28
+ * 其中昵称是**用户自己可控**的字符串,直接拼进 HTML 就等于在本机页面上
29
+ * 开一个自伤型 XSS(本机页面同样能去访问 `http://127.0.0.1` 上的其它服务)。
30
+ * ⚠️ 原来那版是裸模板字符串,**没有**转义,这是顺手补上的安全债。
31
+ * 5. **手机号只脱敏显示**:邮箱可以全量显示(它本来就是账号标识),
32
+ * 手机号按 `189****3607` 脱敏。理由:这个页面可能被截图、
33
+ * 也可能被同事瞄一眼屏幕,而"授权成功"根本不需要看到完整手机号。
34
+ *
35
+ * ## 手机端
36
+ * `<meta name="viewport">` + `max-width` 卡片 + `@media (max-width:420px)`
37
+ * 收窄内边距;再补一份 `prefers-color-scheme: dark`,
38
+ * 免得夜间模式下白底刺眼(纯静态,不掉一行 JS)。
39
+ */
40
+
41
+ /**
42
+ * HTML 实体转义。
43
+ *
44
+ * ⚠️ 五个字符一个都不能少:`&` 必须**第一个**替换,否则后面替换出来的
45
+ * `&lt;` 会被再转一次,变成用户看到的 `&amp;lt;`(经典的顺序 bug)。
46
+ */
47
+ export function escapeHtml(value) {
48
+ return String(value === undefined || value === null ? '' : value)
49
+ .replace(/&/g, '&amp;')
50
+ .replace(/</g, '&lt;')
51
+ .replace(/>/g, '&gt;')
52
+ .replace(/"/g, '&quot;')
53
+ .replace(/'/g, '&#39;');
54
+ }
55
+
56
+ /**
57
+ * 账号标识脱敏:**只处理手机号**,邮箱 / 昵称原样返回。
58
+ *
59
+ * 为什么不一并脱敏邮箱:邮箱是用户在这个平台上认自己账号的主要标识
60
+ * (终端里 `login` 打印的也是它),把它打码成 `w***@qq.com` 反而让用户
61
+ * 认不出"登的是哪个号",那是把安全做成了不好用。
62
+ * 手机号则相反:它对"这是不是我的账号"没有额外信息量,但有实打实的
63
+ * 隐私价值(截图 / 旁边有人看屏幕),所以按 `+86 189****3607` 显示。
64
+ *
65
+ * @param {string} raw 邮箱 / 昵称 / 手机号
66
+ * @returns {string} 可直接放进页面的账号标识
67
+ */
68
+ export function maskAccount(raw) {
69
+ const text = String(raw === undefined || raw === null ? '' : raw).trim();
70
+ if (!text) return '';
71
+ // 邮箱(含 @ 且两侧都有内容):原样返回
72
+ const at = text.indexOf('@');
73
+ if (at > 0 && at < text.length - 1) return text;
74
+ /**
75
+ * 手机号:容忍 `+86 189 1234 3607` / `+8618912343607` / `189-1234-3607`
76
+ * 这些写法(用户资料里的 phone_number 可能是带区号的 E.164),
77
+ * 先去掉所有非数字再判,避免"格式稍变就漏脱敏"。
78
+ */
79
+ const digits = text.replace(/\D/g, '');
80
+ const m = /^(?:(86))?(1\d{2})(\d{4})(\d{4})$/.exec(digits);
81
+ if (m) return `${m[1] ? `+86 ` : ''}${m[2]}****${m[4]}`;
82
+ // 其它(区号带 +、座机、昵称):不认识就原样显示,绝不猜着截断
83
+ return text;
84
+ }
85
+
86
+ /**
87
+ * 从 `userinfo` 的返回里挑一个给用户看的账号标识。
88
+ *
89
+ * 优先级:邮箱 > 昵称 > 脱敏手机号 > 空串(页面会省略这一行)。
90
+ * ⚠️ 顺序是有意的:邮箱/昵称能唯一对应"哪个号",手机号只是兜底
91
+ * (有些账号是手机号注册、没填邮箱也没昵称)。
92
+ */
93
+ export function accountLabel(account) {
94
+ if (!account || typeof account !== 'object') return '';
95
+ const email = typeof account.email === 'string' ? account.email.trim() : '';
96
+ if (email) return email;
97
+ const name = [account.name, account.nickname]
98
+ .map((v) => (typeof v === 'string' ? v.trim() : ''))
99
+ .find(Boolean);
100
+ if (name) return name;
101
+ const phone = typeof account.phone_number === 'string' ? account.phone_number.trim() : '';
102
+ if (phone) return maskAccount(phone);
103
+ return '';
104
+ }
105
+
106
+ /**
107
+ * 样式表。**别引任何外部资源**(见文件头的安全底线)。
108
+ * 尺寸刻意压得很小:整个页面(含 CSS)目标 < 8KB。
109
+ */
110
+ const PAGE_CSS = [
111
+ ':root{color-scheme:light dark}',
112
+ '*{box-sizing:border-box}',
113
+ 'html,body{margin:0;padding:0}',
114
+ 'body{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;',
115
+ 'gap:18px;padding:32px 16px;background:#f5f6f8;color:#1f2329;line-height:1.7;',
116
+ '-webkit-text-size-adjust:100%;',
117
+ 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif}',
118
+ '.card{width:100%;max-width:520px;background:#fff;border-radius:16px;padding:40px 32px 32px;',
119
+ 'box-shadow:0 8px 32px rgba(15,23,42,.08);text-align:center}',
120
+ '.badge{display:block;width:64px;height:64px;margin:0 auto 18px}',
121
+ 'h1{font-size:24px;font-weight:600;line-height:1.35;margin:0 0 12px}',
122
+ '.lead{font-size:16px;margin:0 0 18px;color:#3d4451}',
123
+ '.who{font-size:14px;margin:0 0 16px;padding:10px 14px;border-radius:10px;background:#f7f8fa;',
124
+ // ⚠️ 用 `overflow-wrap:anywhere` 而**不是** `word-break:break-all`:
125
+ // 后者会**无条件**把 `wkgeo@agent.qq.com` 从中间劈开成两行(320px 屏上实测很丑),
126
+ // 前者只在"这一整串真的放不下"时才断行 —— 既保证长邮箱/长昵称不会撑破卡片,
127
+ // 又不会把本来放得下的标识拆开。
128
+ 'color:#3d4451;overflow-wrap:anywhere}',
129
+ '.who strong{font-weight:600;color:#1f2329}',
130
+ '.note{font-size:12.5px;color:#8f959e;margin:0}',
131
+ '.hint{font-size:12.5px;color:#8f959e;margin:10px 0 0}',
132
+ 'footer{font-size:12px;color:#9aa2ad}',
133
+ '@media (max-width:420px){body{padding:20px 12px;gap:14px}',
134
+ '.card{padding:28px 20px 24px;border-radius:14px}h1{font-size:21px}.lead{font-size:15px}}',
135
+ '@media (prefers-color-scheme:dark){body{background:#14171c;color:#e6e8eb}',
136
+ '.card{background:#1d2128;box-shadow:0 8px 32px rgba(0,0,0,.4)}',
137
+ '.lead,.who{color:#c2c7cf}.who{background:#23272f}.who strong{color:#e6e8eb}',
138
+ '.note,.hint,footer{color:#8b929c}}',
139
+ ].join('');
140
+
141
+ /** 成功 = 绿色对勾;warn = 琥珀色感叹号;error = 红色叉。颜色同时用于描边和字形 */
142
+ const TONE = {
143
+ ok: { fg: '#16a34a', bg: '#e9f8ef' },
144
+ warn: { fg: '#d97706', bg: '#fdf3e3' },
145
+ error: { fg: '#dc2626', bg: '#fdecec' },
146
+ };
147
+
148
+ /**
149
+ * 状态图标:**内联 SVG**(不引外链、不用 emoji)。
150
+ *
151
+ * 为什么不用 emoji `✅`:emoji 的渲染完全交给系统字体 ——
152
+ * Windows 是彩色方框、某些 Linux 容器里根本没有字体,会渲染成豆腐块 `□`,
153
+ * 那正好是"授权成功页"最不该出现的东西。SVG 到哪里都是同一个样子。
154
+ */
155
+ function badgeSvg(tone) {
156
+ const c = TONE[tone] || TONE.ok;
157
+ const glyph =
158
+ tone === 'warn'
159
+ ? `<path d="M26 15.5v14.5" stroke="${c.fg}" stroke-width="3.2" stroke-linecap="round"/>` +
160
+ `<circle cx="26" cy="36.2" r="1.9" fill="${c.fg}"/>`
161
+ : tone === 'error'
162
+ ? `<path d="M19.2 19.2l13.6 13.6M32.8 19.2L19.2 32.8" stroke="${c.fg}" stroke-width="3.2" stroke-linecap="round"/>`
163
+ : `<path d="M16.6 27.2l6.6 6.6 12.2-14.2" stroke="${c.fg}" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"/>`;
164
+ return (
165
+ `<svg class="badge" viewBox="0 0 52 52" fill="none" aria-hidden="true" focusable="false">` +
166
+ `<circle cx="26" cy="26" r="24" fill="${c.bg}" stroke="${c.fg}" stroke-width="2"/>${glyph}</svg>`
167
+ );
168
+ }
169
+
170
+ /**
171
+ * 「尝试自动关闭标签页」的极简内联脚本 —— **只给"确实成功"的成功页用**。
172
+ *
173
+ * ## 为什么加这段(老板 2026-10-01 对比阿里云 MCP 的实测结论)
174
+ * 老板的原话:阿里云那套"点完授权,页面**自动就关了**,这样看着很正常"。
175
+ * 我们的现状是:跳回本机后留一张成功页,**要用户自己动手关**——不够顺。
176
+ *
177
+ * ## 🔴 但我们**不抄它的时机**(这是它源码里实打实的坑)
178
+ * 阿里云 `mcp-remote` 是在本地回调**刚收到 `code`** 的那一刻就把页面发回去、
179
+ * 页面立刻 `window.close()`,而此刻它还**没有 POST token 端点换令牌**:
180
+ * `authCompletedResolve(code); res.send('Authorization successful! …<script>window.close();</script>')`
181
+ * —— 一旦接下来换票失败,用户已经被打发走了(窗口都关了),却只记得"它说我成功了"。
182
+ * 所以本项目的口径是:
183
+ * * **只有** CLI 真的换到令牌、并且凭据已经写进本机之后的**那张成功页**才尝试关闭
184
+ * (对应 `login.mjs` 里 `api.token(...)` → `writeCredentials(...)` → `successPage(...)`
185
+ * 这个顺序,**不许调换**);
186
+ * * 中间态页面(例如"正在换取令牌…")即使将来真加了,也**不自动关闭**,统一由成功页收尾;
187
+ * * 错误页(state 失败 / 缺少授权码 / 授权未完成·拒绝 / 换票失败 / 405)**一律不关**,
188
+ * 详见 `errorPage` 的说明。
189
+ *
190
+ * ## ⚠️ 另一个关键事实:这一段**大概率关不掉**,所以它是"尽力而为 + 兜底文案"
191
+ * 浏览器有一条硬规则:**只允许脚本关闭"由脚本打开的窗口"**(`window.open` 打开的那类)。
192
+ * 我们这张页面是用户点同意后由**系统命令打开的那个浏览器**里导航过来的
193
+ * (`openInBrowser` 走 rundll32 / open / xdg-open),对浏览器来说它是
194
+ * "用户自己打开的标签页",`window.close()` 通常被**静默忽略**
195
+ * (Chrome / Edge 控制台里会留一句 "Scripts may close only the windows that
196
+ * were opened by them",Firefox 类似)。
197
+ * 所以:
198
+ * * 这段脚本**不是**"关掉页面"的保证,只是"能关就顺手关掉";
199
+ * * 页面上那句「本页面可以关闭了」**必须保留**——它就是给关不掉的情况兜底的;
200
+ * * 绝不为了"看起来关掉了"去做跳转 / 假按钮 / alert(那才是骗用户)。
201
+ *
202
+ * ## 为什么延迟 + 重试两次
203
+ * * **延迟 1.2 秒**:先让用户看清「授权成功」这四个字再关。上来就关,
204
+ * 用户会以为自己点错了、页面崩了(这正是老板说阿里云"看着正常"的反面:
205
+ * 页面一闪而过同样让人发慌)。
206
+ * * **再试一次**:部分浏览器(含移动端内核)对第一次 `close()` 会忽略、
207
+ * 第二次才生效;也有的是 DOM 尚未稳定时忽略。重试成本几乎为零,所以试两次。
208
+ * 两次之后就**不再骚扰**——继续调下去只会刷一屏控制台报错。
209
+ *
210
+ * ## 约束(单测钉住)
211
+ * * 不使用 `alert` / `confirm`,不跳转(不碰 `location`),不发任何请求(无 fetch/XHR),
212
+ * 不用 `eval`;整段脚本不碰 DOM,也不接收任何数据(页面里没有令牌,脚本里更没有)。
213
+ * * 用 `try/catch` 包住:某些内嵌浏览器(Electron / 企业管控浏览器)里
214
+ * `window.close` 可能直接被禁用甚至抛异常,不能因此弄脏页面。
215
+ */
216
+ const AUTO_CLOSE_SCRIPT = `<script>
217
+ (function(){
218
+ var n = 0;
219
+ function tryClose(){
220
+ n++;
221
+ try { window.close(); } catch (e) { /* 内嵌浏览器可能直接禁用,忽略 */ }
222
+ if (n < 2) setTimeout(tryClose, 700);
223
+ }
224
+ setTimeout(tryClose, 1200);
225
+ })();
226
+ </script>`;
227
+
228
+ /**
229
+ * 拼出整份文档。
230
+ *
231
+ * @param {object} spec
232
+ * @param {string} spec.title 浏览器标签页标题(会带上品牌)
233
+ * @param {'ok'|'warn'|'error'} [spec.tone]
234
+ * @param {string} spec.heading 页面大标题(h1)
235
+ * @param {string} [spec.lead] 主文案(成功=下一步;失败=明确的中文原因)
236
+ * @param {string} [spec.account] 账号标识(仅成功页;已脱敏,这里再转义一次)
237
+ * @param {string} [spec.note] 小字说明
238
+ * @param {string} [spec.hint] 更小的补充提示
239
+ * @param {boolean} [spec.autoClose] 是否附上"尝试自动关闭标签页"的脚本。
240
+ * ⚠️ 默认 `false`,而且目前**只允许成功页**用它(`successPage`)——那个页面代表
241
+ * "令牌已换到、凭据已写盘",是唯一"确实成功"的终态。
242
+ * 错误页 / 任何中间态页**不要**打开它(理由见 `AUTO_CLOSE_SCRIPT` 与 `errorPage` 的注释;
243
+ * 顺手关掉用户正在看的失败原因,正是阿里云那套踩过的坑)。
244
+ * 打开它的页面**必须同时**有「本页面可以关闭了」这类兜底文案(它大概率关不掉)。
245
+ */
246
+ export function renderPage({
247
+ title,
248
+ tone = 'ok',
249
+ heading,
250
+ lead,
251
+ account,
252
+ note,
253
+ hint,
254
+ autoClose = false,
255
+ } = {}) {
256
+ const parts = [badgeSvg(tone), `<h1>${escapeHtml(heading)}</h1>`];
257
+ if (lead) parts.push(`<p class="lead">${escapeHtml(lead)}</p>`);
258
+ if (account) {
259
+ parts.push(`<p class="who">已登录账号:<strong>${escapeHtml(account)}</strong></p>`);
260
+ }
261
+ if (note) parts.push(`<p class="note">${escapeHtml(note)}</p>`);
262
+ if (hint) parts.push(`<p class="hint">${escapeHtml(hint)}</p>`);
263
+
264
+ const html = `<!doctype html>
265
+ <html lang="zh-CN">
266
+ <head>
267
+ <meta charset="utf-8">
268
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
269
+ <meta name="color-scheme" content="light dark">
270
+ <meta name="robots" content="noindex,nofollow">
271
+ <title>${escapeHtml(title)}</title>
272
+ <style>${PAGE_CSS}</style>
273
+ </head>
274
+ <body>
275
+ <main class="card" role="main">
276
+ ${parts.join('\n')}
277
+ </main>
278
+ <footer>悟空AI · 云站平台</footer>${autoClose ? `\n${AUTO_CLOSE_SCRIPT}` : ''}
279
+ </body>
280
+ </html>`;
281
+ return { title, html };
282
+ }
283
+
284
+ /**
285
+ * 授权成功页 —— 用户在本机回调里**唯一**该看到的结论。
286
+ *
287
+ * 为什么必须存在(老板 2026-10-01 亲眼看过的现场):
288
+ * 点完「同意」后浏览器会跳到 `http://127.0.0.1:53682/callback?code=…`。
289
+ * 如果那一刻本机没有 CLI 在监听,浏览器甩出的是 `ERR_CONNECTION_REFUSED`
290
+ * 「无法访问此网站」——用户会以为**授权失败**,然后反复重来。
291
+ * 所以只要回调真的到达了 CLI,就必须给出一个明确的、中文的、体面的成功页。
292
+ *
293
+ * 文案分区(老板指定):大标题 / 副文案(回终端)/ 已登录账号 / 小字说明。
294
+ *
295
+ * ## 唯一会尝试自动关闭标签页的页面(`autoClose: true`)
296
+ * 这张页只在 `login.mjs` 里 **`api.token(...)` 换到令牌 → `writeCredentials(...)` 写盘之后**
297
+ * 才被返回,所以"授权成功"是**已经落地的事实**,不是"收到了 code"这种半成品状态
298
+ * —— 这是与阿里云 `mcp-remote` 最关键的区别(它收到 code 就宣告成功并关窗,换票失败时
299
+ * 用户已经被打发走了)。详见 `AUTO_CLOSE_SCRIPT` 的注释。
300
+ * ⚠️ 副文案里的「本页面可以关闭了」和 hint 里的"直接关闭标签页"是**兜底**,
301
+ * 关不掉时用户照着做就行,两条都不许删(单测钉住)。
302
+ *
303
+ * @param {object|null} account `userinfo` 的返回(可能为 null:拿不到账号信息)
304
+ */
305
+ export function successPage(account) {
306
+ return renderPage({
307
+ tone: 'ok',
308
+ title: '授权成功 · 悟空AI',
309
+ heading: '授权成功',
310
+ lead: '请回到终端(客户端)继续操作,本页面可以关闭了。',
311
+ account: accountLabel(account),
312
+ note: '凭据已保存在本机,仅本次授权使用;不会显示在本页面。',
313
+ // ⚠️ 刻意**不做**假的"返回终端"按钮:网页关不掉终端窗口,点了没反应只会更困惑
314
+ hint: '本页没有需要点击的按钮:页面会尝试自动关闭;如果没有关掉,直接关闭标签页或切回终端窗口即可。',
315
+ autoClose: true,
316
+ });
317
+ }
318
+
319
+ /** 错误页统一的收尾提示(每一步失败后用户唯一该做的事) */
320
+ const RETRY_HINT = '请回到终端(客户端)重试:关掉本页,回到命令行重新运行 wkgeo login。';
321
+
322
+ /**
323
+ * 「本次登录已经完成」页 —— 设备码那条路**先**拿到令牌之后,用户后来又点的那次回调该看到的页面。
324
+ *
325
+ * ## 为什么必须有这一页(老板 2026-10-03 真机反馈的连带缺陷)
326
+ * 旧行为:首轮等待到点 → CLI 先**关掉本地回调监听**、再改走设备码。于是用户随后在浏览器里点
327
+ * 【同意授权】时,浏览器跳到 `http://127.0.0.1:<port>/callback?code=…` 直接得到
328
+ * **ERR_CONNECTION_REFUSED**(真机截图见过)—— 他会以为"授权失败了",然后再登录一次,
329
+ * 而其实他**早就登录成功了**。
330
+ * 修复后本地监听会保留到本次登录收尾(见 login.mjs),所以那种"连接被拒绝"不会再出现;
331
+ * 而在"设备码先成功"的时序里,迟到的回调就由这一页收尾。
332
+ *
333
+ * ## ⚠️ 与「授权成功页」(successPage)的关键区别
334
+ * 这一页出现的时刻,**令牌不是这次回调换来的**(凭据早就由设备码那条路写好了)。所以文案必须
335
+ * 说清"登录已经完成、不用再点",而不是"授权成功" —— 否则用户会以为自己刚才那一下才是关键,
336
+ * 下一回还会去重复点、甚至怀疑第一次没成功。
337
+ *
338
+ * ## 安全底线(与其它页完全一致)
339
+ * * **不回显**回调 URL 里的 `code`(模板里根本没有接收它的位置),页面里不出现任何令牌;
340
+ * * 账号标识走 `accountLabel()` 脱敏 + `renderPage` 里的转义;
341
+ * * 这是**终态**(令牌已在手、凭据已写盘)⇒ 与成功页一样带上"尝试自动关闭标签页"
342
+ * (真机上用户点完就想走,留着这张页只是让他确认"没出错")。
343
+ *
344
+ * @param {object|null} account `userinfo` 的返回(可能为 null:拿不到账号信息)
345
+ */
346
+ export function alreadyDonePage(account) {
347
+ return renderPage({
348
+ tone: 'ok',
349
+ title: '本次登录已经完成 · 悟空AI',
350
+ heading: '本次登录已经完成',
351
+ lead: '你刚才已经在另一个页面完成过授权了,这一次不需要再点;本页面可以关闭了。',
352
+ account: accountLabel(account),
353
+ note: '凭据已保存在本机,仅本次授权使用;不会显示在本页面。',
354
+ hint: '本页没有需要点击的按钮:页面会尝试自动关闭;如果没有关掉,直接关闭标签页或切回终端窗口即可。',
355
+ autoClose: true,
356
+ });
357
+ }
358
+
359
+ /**
360
+ * 错误页 —— 失败分支**也要有像样的页面**,不能回一段纯文本。
361
+ *
362
+ * 为什么(真实体验问题):用户是"点完同意"被跳回本机的,此刻他
363
+ * 并不知道发生了什么。回纯文本 `not found` / 白屏,等于让他去猜;
364
+ * 而这几种失败(state 对不上、缺 code、换票失败)**原因完全不同**,
365
+ * 有的只要重试、有的要重新发起授权。所以:
366
+ * * 颜色区分等级(`warn` 琥珀 = 这次没成但不一定是错;`error` 红 = 确实失败了);
367
+ * * `reason` 必须是**明确的中文原因**(不出现英文栈、不出现令牌);
368
+ * * 永远带一句"请回到终端重试"。
369
+ *
370
+ * @param {object} spec
371
+ * @param {'warn'|'error'} [spec.tone]
372
+ * @param {string} spec.heading 大标题(短,一眼能懂)
373
+ * @param {string} spec.reason 明确的中文原因
374
+ * @param {string} [spec.hint] 补充说明(例如"没有写入任何凭据")
375
+ *
376
+ * ## 🔴 错误页**永远不**尝试自动关闭(这里刻意连开关都不留)
377
+ * 失败分支一共五处:`state 校验失败` / `缺少授权码` / `授权未完成`(拒绝或服务端 error)/
378
+ * `换取令牌失败` / `405 方法不允许`。它们全都不带自动关闭脚本,理由分两层:
379
+ * 1. 其中三处**流程还没结束**(state 校验失败、缺 code、405 时 CLI 仍在等真正的回调),
380
+ * 用户可能正照着这一页的说明回同意页重来 —— 自动关掉等于把线索删了;
381
+ * 2. 另外两处虽然已经终态,但**用户必须看清原因**("为什么没成"是排障的唯一线索,
382
+ * 而且失败提示里那句"请回到终端重试"要留在屏幕上让人照做)。
383
+ * ⚠️ 这里**不提供** `autoClose` 参数:不给调用方"图省事打开"的机会。
384
+ * 真要改这条口径,请先读完 `AUTO_CLOSE_SCRIPT` 上面那段(阿里云 `mcp-remote` 的教训)。
385
+ */
386
+ export function errorPage({ tone = 'error', heading, reason, hint } = {}) {
387
+ return renderPage({
388
+ tone,
389
+ title: `${heading} · 悟空AI`,
390
+ heading,
391
+ lead: reason,
392
+ note: hint,
393
+ hint: RETRY_HINT,
394
+ // autoClose 保持默认 false:错误页一律留在屏幕上
395
+ });
396
+ }
package/lib/pkce.mjs ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * PKCE(RFC 7636)与随机数工具 —— 全部用 node:crypto,零依赖。
3
+ *
4
+ * 为什么必须有 PKCE:
5
+ * 我们是**公共客户端**(CLI 没有能力保管 client_secret),授权码又是通过
6
+ * 回环地址回调交给本地进程的 —— 同机器上的其它程序理论上能抢到那个 code。
7
+ * PKCE 让"抢到 code 也换不了票"(换票必须出示 code_verifier,而它从头到尾只在内存里)。
8
+ * 服务端对公共客户端是**强制**校验 PKCE 的,缺了直接 400。
9
+ *
10
+ * 踩过的坑:
11
+ * * code_verifier 只允许 `[A-Za-z0-9-._~]`,长度 43~128。base64url 出来的字符集是它的子集,
12
+ * 32 字节随机数 → 43 个字符,刚好卡在下限(少一个字节就非法了)。
13
+ * * challenge 必须是 `base64url(sha256(ascii(verifier)))`,**不是** hex,也不能带 padding。
14
+ */
15
+
16
+ import crypto from 'node:crypto';
17
+
18
+ /** base64url(去 padding、`+`→`-`、`/`→`_`)—— Buffer 自带这个编码 */
19
+ export function base64url(buf) {
20
+ return Buffer.from(buf).toString('base64url');
21
+ }
22
+
23
+ /** 生成 code_verifier:32 字节 → 43 字符(RFC 7636 允许 43~128) */
24
+ export function createVerifier(bytes = 32) {
25
+ return base64url(crypto.randomBytes(bytes));
26
+ }
27
+
28
+ /** S256 challenge = base64url(sha256(verifier)) */
29
+ export function challengeOf(verifier, method = 'S256') {
30
+ if (method !== 'S256') throw new Error(`不支持的 code_challenge_method: ${method}(服务端只认 S256)`);
31
+ return base64url(crypto.createHash('sha256').update(String(verifier), 'ascii').digest());
32
+ }
33
+
34
+ /**
35
+ * state:防 CSRF。
36
+ * 服务端只负责**原样回传**,校验是客户端自己的责任 —— 不校验就等于没有防重放/防串号。
37
+ */
38
+ export function createState(bytes = 16) {
39
+ return base64url(crypto.randomBytes(bytes));
40
+ }
41
+
42
+ /** 恒定时间比较:state 比对不要用 `===`(虽然 state 不是密钥,但习惯要好) */
43
+ export function safeEqual(a, b) {
44
+ const ba = Buffer.from(String(a));
45
+ const bb = Buffer.from(String(b));
46
+ if (ba.length !== bb.length) return false;
47
+ return crypto.timingSafeEqual(ba, bb);
48
+ }