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/login.mjs ADDED
@@ -0,0 +1,1332 @@
1
+ /**
2
+ * `login`:授权码 + PKCE 的本地回调流程。
3
+ *
4
+ * 完整时序(按本项目的真实实现):
5
+ * 1. CLI 起一个**只监听 127.0.0.1** 的临时 HTTP 服务
6
+ * 2. 生成 code_verifier / S256 challenge / state
7
+ * 3. 打开浏览器到 `authorization_endpoint?client_id&redirect_uri&scope&state&code_challenge…`
8
+ * —— 未登录会先跳登录页,然后回到同意页
9
+ * 4. 用户在页面上点「同意」,页面 POST /api/oauth/consent,服务端签发 code 并跳回 redirect_uri
10
+ * 5. CLI 的本地服务收到 `?code=…&state=…` → **先校验 state**(服务端不校验,这是客户端的活)
11
+ * 6. CLI 用 code + code_verifier 换 token → 写本地凭据(0600)→ 回一个"可以关闭本页"的 HTML
12
+ *
13
+ * 为什么端口默认写死 53682 又要能换:
14
+ * 服务端登记的 redirect_uri 是 `http://127.0.0.1:53682/callback`。
15
+ * 服务端按 RFC 8252 对**回环地址忽略端口**(实测 54321 也匹配),所以端口被占用时
16
+ * 退到随机端口依然能授权成功。这条很重要:用户机器上 53682 被占是常事,
17
+ * 不能因为这个就让他去改客户端配置。
18
+ *
19
+ * 依赖注入(deps)是为了**可测**:offline 单测里把 fetch / 开浏览器 / 端口都替换掉,
20
+ * 就成了一台"假浏览器",可以逐条验证 state 校验、超时、错误页这些分支。
21
+ *
22
+ * ⚠️ 两个等待窗口(别搞混,`commands/auth.mjs` 靠 `kind` 区分"要不要自动改设备码"):
23
+ * * `DEFAULT_CALLBACK_FIRST_WAIT_MS`(**180 秒**,`--callback-first-wait` 可调/可关,
24
+ * ★ **只在检测到系统代理时**才由命令层启用)
25
+ * → 到点抛 `kind = 'callback_first_wait'` → **自动改用设备码继续,不退出**(第二条保险);
26
+ * * `config.loginTimeoutMs`(默认 300 秒,`--timeout` 可调)
27
+ * → 到点抛 `kind = 'timeout'`(整段等待的兜底,也是老板报的那个故障的现场)。
28
+ *
29
+ * ★★ 2026-10-03(老板真机反馈)起,这两条"到点"都**不再关掉本地回调监听**:
30
+ * 命令层会通过 `deps.handoff` 拿到通知,**并发**去跑设备码那条路,**哪条先成功就用哪条**
31
+ * (详见 `DEFAULT_CALLBACK_FIRST_WAIT_MS` 与 `deps.handoff` 的注释)。
32
+ */
33
+
34
+ import http from 'node:http';
35
+ import { spawn } from 'node:child_process';
36
+ import { URL } from 'node:url';
37
+
38
+ import { createApi } from './api.mjs';
39
+ import { successPage, errorPage, alreadyDonePage } from './page.mjs';
40
+ import { createVerifier, challengeOf, createState, safeEqual } from './pkce.mjs';
41
+ import { writeCredentials, credentialsFromToken } from './credentials.mjs';
42
+ import { withTrailingSlash } from './config.mjs';
43
+ import { proxyRiskHint } from './proxy.mjs';
44
+ import { CliError, EXIT } from './errors.mjs';
45
+
46
+ /**
47
+ * 每个平台**依次尝试哪几个程序**打开网址,以及怎么把 URL 传给它。
48
+ *
49
+ * ⚠️⚠️ 为什么必须"多条策略"而不是"一条命令"(老板 2026-10-01 反馈):
50
+ * 老板的 Windows 上**两次"什么都没弹出来"**,而当时 win32 只有一条
51
+ * `rundll32 url.dll,FileProtocolHandler` —— 它既不报错、退出码也是 0,
52
+ * 失败是**完全静默**的。单条命令的可用性取决于这台机器上恰好装了/启用了它;
53
+ * 三条互不依赖的路径同时不可用的概率远低于任意一条。
54
+ * (真客户不会自己复制粘贴网址,所以"自动打开"失败等于流程断掉。)
55
+ *
56
+ * ⚠️⚠️ Windows 这条分支踩过一个真机实测的大坑(2026-10-01):
57
+ * 原来的写法是 `spawn('cmd', ['/c', 'start', '', url])` —— 浏览器**确实弹出来了**,
58
+ * 但打开的是光秃秃的 `/admin/oauth/authorize`,**所有 query 参数都没了**,
59
+ * 授权页显示「授权请求不完整」。
60
+ *
61
+ * 机理(必须记住):**在 Windows 的 cmd.exe 里,`&` 是命令分隔符**。
62
+ * `spawn` 不带 `shell: true` 时虽然不经过 shell,但我们**主动**去调了 `cmd.exe` ——
63
+ * 于是 cmd 拿到整条命令行后会**重新解析一遍**:
64
+ * cmd /c start "" https://www.wkongai.cn/admin/oauth/authorize/?client_id=…&redirect_uri=…
65
+ * ↑ 这里被当成命令分隔
66
+ * → cmd 认为第一条命令是 `start "" https://…?client_id=…`(query 只剩第一个参数),
67
+ * `&` 后面的 `redirect_uri=…` / `scope=…` / `state=…` / `code_challenge=…`
68
+ * 被当成**另一条命令**(cmd 报"不是内部或外部命令",用户看不到,因为是隐藏窗口)。
69
+ * 本质上和"手拼 URL 不编码"是同一类坑:URL 交给**另一个命令行解析器**时被二次解释。
70
+ *
71
+ * 修法(现在是三条策略,按顺序试;2026-10-01 又调了一次①的位置 —— 见下面"为什么这么排"):
72
+ * ① `rundll32 url.dll,FileProtocolHandler <url>` —— **最稳的一条,所以排第一**:
73
+ * * 它**不是 shell**:URL 作为独立 argv 传给进程,`&`、`=`、`%` 都只是普通字符
74
+ * (CreateProcess → CommandLineToArgvW,`&` 在这里没有特殊含义);
75
+ * * 所以 URL 是**逐字符原样**落到系统里的,不存在"被另一个命令行解析器二次解释";
76
+ * * rundll32.exe + url.dll 是 Windows 自带,不需要用户装任何东西;
77
+ * * 它也正是之前线上一直在用、真机实测**确实能弹出浏览器**的那条。
78
+ * ② `cmd /c start "" "<url>"` —— 退到第二位做**兜底**(写法与引号**原样保留**):
79
+ * * 能用 `start` 是最贴近"用户双击网址"的路径(走的正是资源管理器里双击 URL
80
+ * 那条注册表路径,对"装了非默认浏览器 / 处理器注册得不规范"的机器命中率最高),
81
+ * 所以它必须留着;引号也**必须留在字符串里**(见下面策略表上的说明)。
82
+ * * 但它的解析路径和①**根本不同**:URL 要先经过 cmd.exe 的命令行解析。
83
+ * 哪怕写了引号,这条路径上仍然有**残余风险**:"命令退出码 0,而 URL 里的 `&`
84
+ * 已经被截断" —— 这是**静默失败**:兜底逻辑根本不会触发,
85
+ * 用户只会看到「授权请求不完整」。所以它只能排在①后面当兜底,绝不能放第一。
86
+ * 另外 args 里的引号还要再经过 Node/libuv 的 Windows 命令行转义,而 `\"` 在 cmd
87
+ * 里的解释与 CommandLineToArgvW 并不一致,也可能出现"看起来加了引号其实还是被拆开"。
88
+ * ③ `powershell -NoProfile -Command "Start-Process '<url>'"` —— 前两条都哑了时的最后一条。
89
+ * 为什么这么排:①(不经 shell、URL 原样)是**最不可能静默失败**的一条,放最前面;
90
+ * ② 虽是"用户双击"路径、命中率最高,却带上面那个残余解析风险,所以只在前一条不可用时
91
+ * 才启用;③ 收尾。这样单条失败**不再等于**整个自动打开失败(这正是这次要修的问题)。
92
+ */
93
+ export const BROWSER_STRATEGY_TABLE = {
94
+ /**
95
+ * ⚠️⚠️ Windows 第 ② 条(`cmd /c start`)的**引号**是必须的(老板真机踩过,见上面那段记录):
96
+ * `cmd.exe` 里 `&` 是**命令分隔符**,而授权 URL 的 query 里全是 `&`
97
+ * (client_id&redirect_uri&response_type&scope&state&code_challenge…)。
98
+ * 所以必须写成 `start "" "<url>"`,并且**整串(连引号)放进同一个 argv 元素**,
99
+ * 让那对 `"` 真的出现在 cmd 的命令行上。
100
+ * ⛔ **不要**改成 `['/c', 'start', '', url]`:那样 URL 是作为**独立 argv**
101
+ * 交给 cmd 的,cmd 会再解析一遍命令行,`&` 又开始生效 ——
102
+ * 第一个 `&` 之后的内容会被当成"另一条命令",浏览器只拿到光秃秃的
103
+ * `/admin/oauth/authorize`(页面报「授权请求不完整」),cmd 还会因为
104
+ * `redirect_uri=…` 不是可执行命令而返回非零退出码。旧 bug 就是这么来的。
105
+ * 三条里只有②经过 cmd;① rundll32 与 ③ powershell 都不过 shell,
106
+ * URL 里的 `&`、`=`、`%` 都只是普通字符。
107
+ */
108
+ win32: [
109
+ // ① 最稳的一条:不经任何 shell,URL 作为独立 argv 逐字符原样交给系统(线上实测能弹浏览器的那条)
110
+ { label: 'rundll32 url.dll,FileProtocolHandler', cmd: 'rundll32', args: (url) => ['url.dll,FileProtocolHandler', url] },
111
+ // ② 兜底:命令与引号写法**保持原样**(引号必须在字符串里);它要经 cmd 解析,存在"退出码 0 却截断 URL"的残余风险,所以不排第一
112
+ { label: 'cmd /c start', cmd: 'cmd', args: (url) => ['/c', `start "" "${url}"`] },
113
+ // ③ 前两条都哑了时的最后一条
114
+ { label: 'powershell Start-Process', cmd: 'powershell', args: (url) => ['-NoProfile', '-Command', `Start-Process '${url}'`] },
115
+ ],
116
+ darwin: [
117
+ { label: 'open', cmd: 'open', args: (url) => [url] },
118
+ // 兜底:`open <url>` 是交给 LaunchServices 挑处理器,偶尔挑不出来;指名 Safari 再试一次
119
+ { label: 'open -a Safari', cmd: 'open', args: (url) => ['-a', 'Safari', url] },
120
+ ],
121
+ // linux 以及其它类 unix(freebsd 等):xdg-open 是桌面环境的事实标准,后两条是常见兜底
122
+ linux: [
123
+ { label: 'xdg-open', cmd: 'xdg-open', args: (url) => [url] },
124
+ { label: 'sensible-browser', cmd: 'sensible-browser', args: (url) => [url] },
125
+ { label: 'x-www-browser', cmd: 'x-www-browser', args: (url) => [url] },
126
+ ],
127
+ };
128
+
129
+ /** 找不到对应平台时按 linux 处理(xdg-open 在非 Windows/macOS 上最通用) */
130
+ const FALLBACK_STRATEGIES = BROWSER_STRATEGY_TABLE.linux;
131
+
132
+ /** 每条策略最多等多久(3~5 秒区间;为什么超时按"乐观成功"处理见 launchStrategy) */
133
+ export const BROWSER_LAUNCH_TIMEOUT_MS = 4000;
134
+
135
+ /**
136
+ * ★ 「回调模式首轮等待」窗口(默认 **180 秒**)—— 第二条保险,**只在检测到系统代理时启用**。
137
+ *
138
+ * ## ⚠️⚠️ 2026-10-03 老板真机反馈:这条自愈**被用错了场景**(本次修的就是它)
139
+ * 老板**没有开任何 VPN / 代理**,但连着重跑几次 `wkgeo login` 都**自动变成了设备码**,
140
+ * 原话:「现在变成设备码了,我们因为没有开任何VPN,所以这个是不对的。」
141
+ * 现场:旧逻辑把这个窗口**无条件**挂在 60 秒上,而"把授权页送到用户面前、用户读完再点
142
+ * 【同意】"经常超过 60 秒 —— 于是明明不需要设备码,也切了过去。
143
+ * ⇒ 现在:**只有 `proxy_warning.detected === true`(真检测到生效中的代理)才启用**;
144
+ * **没有代理时一律不启用**(老老实实等满 `--timeout`)。
145
+ * 用户**显式**写 `--callback-first-wait <秒>` 或 `WKONGAI_CALLBACK_FIRST_WAIT_MS` 时照旧尊重
146
+ * —— **显式指定优先于自动判断**(他怎么要求就怎么做)。
147
+ * ⇒ 连带结论:`auto_device.applied=true, reason='callback_first_wait'` **只可能出现在有代理的机器上**;
148
+ * 没有代理、用户也没显式指定时,**绝不会**发生。
149
+ * (判定点在 `commands/auth.mjs` 的 `resolveCallbackFirstWaitMs()`,那边有一份对应的说明与单测钉子。)
150
+ *
151
+ * ## 为什么还需要它(什么时候它是对的)
152
+ * "代理把回调吃掉了"这种故障里,用户其实早就点完【同意授权】了,只是结果回不来;
153
+ * 而设备码**不需要任何入站回调**,几分钟内就能拿到令牌 —— 比让他干等 5~15 分钟强得多。
154
+ * 放宽到 180 秒(原来是 60 秒)就是给"用户真在认真读授权页"留足余量:
155
+ * 它只在"有代理、回调本来就可能回不来"的机器上生效,宁可多等一会儿再改道。
156
+ *
157
+ * ## ★★ 到点之后**不关本地回调监听**(同一次修复的第二条,真机证据见下)
158
+ * 旧行为:到点 → 抛 `callback_first_wait` → 命令层改走设备码,而 `runLogin` 的 `finally`
159
+ * **已经把本地 server 关掉了**。于是用户随后才点【同意授权】时,浏览器跳到
160
+ * `http://127.0.0.1:<port>/callback?code=…` 得到 **ERR_CONNECTION_REFUSED**
161
+ * (老板真机截图见过)—— 用户以为授权失败,又重来一次(其实他马上就会登录成功)。
162
+ * 现在的契约:调用方传了 `deps.handoff` 时,到点**只通知、不结束、不关监听**(见 `runLogin`),
163
+ * 由命令层**并发**跑设备码,两条路**先到先得**。
164
+ *
165
+ * ## 覆盖方式(优先级:命令行 > 环境变量 > 这个默认值)
166
+ * * `--callback-first-wait <时长>`:默认单位**秒**(`--callback-first-wait 90` = 90 秒),
167
+ * 也支持 `90s` / `90000ms`;写 `0` / `off` / `none` 表示**关掉这个自愈窗口**
168
+ * (那就只剩整段 `--timeout` 窗口)。
169
+ * * `WKONGAI_CALLBACK_FIRST_WAIT_MS`:毫秒(沿用 `WKONGAI_LOGIN_TIMEOUT_MS` 的口径)。
170
+ * * `--no-auto-device` / `WKONGAI_AUTO_DEVICE=0` 时**强制关闭**:用户明确要求只走回调,
171
+ * 我们就没有"改道"这个选项,不能偷偷改(见 `commands/auth.mjs` 的 resolveCallbackFirstWaitMs)。
172
+ *
173
+ * ## 约束
174
+ * * 必须**小于** `config.loginTimeoutMs` 才有意义(否则只会先撞整段超时);
175
+ * * 到点后抛 `kind = 'callback_first_wait'`(**不是** `timeout`),命令层据此自动改设备码、
176
+ * **不退出**;换票失败/被拒绝等其他错误**照旧不降级**。
177
+ */
178
+ export const DEFAULT_CALLBACK_FIRST_WAIT_MS = 180_000;
179
+
180
+ /**
181
+ * ★ 设备码那条路**先**拿到令牌之后,本地回调监听再多留多久(毫秒)。
182
+ *
183
+ * 为什么需要:用户很可能在两个标签页之间来回 —— 一边是 CLI 让他打开的授权页(回调模式),
184
+ * 一边是切设备码后让他打开的确认页。设备码先确认成功后,如果**立刻**关掉监听,
185
+ * 他回到那个还开着的授权页点【同意授权】时,浏览器就又是一次
186
+ * `ERR_CONNECTION_REFUSED`(真机截图见过的那一幕,用户会以为授权失败)。
187
+ * 留这一小段时间,迟到的回调会拿到一张「本次登录已经完成」的页面(见 page.mjs 的 alreadyDonePage)。
188
+ *
189
+ * ## 两个刻意的选择
190
+ * * **只给"首轮等待"这条自愈留 grace**:那时候用户很可能正在读授权页、手指就在鼠标上;
191
+ * 而"整段 `--timeout` 用完"(默认 300 秒,授权码本身也就 5 分钟有效期)之后再来的回调,
192
+ * 基本已经换不到令牌了,留监听没有意义 ⇒ 那种情况 grace = 0(立刻收尾)。
193
+ * * **不 await**:`runLogin` 立刻把结果交回命令层(用户马上看到"授权成功"),
194
+ * 监听与这个定时器在后台再活一小会儿,到点(或被那次迟到的回调提前唤醒)就自行关闭。
195
+ * 代价是进程会晚几秒退出 —— 换来的是"用户之后点的那一下不会看到连接被拒绝"。
196
+ */
197
+ export const DEFAULT_POST_DEVICE_GRACE_MS = 3000;
198
+
199
+ /**
200
+ * 取某个平台要**依次尝试**的策略表。
201
+ * `strategies` 可整体注入 —— 单测靠它构造"前两条失败、第三条成功"和"全失败",
202
+ * 且注入的是**数据**而不是替换整个 `openInBrowser`,这样被测代码还是生产那份。
203
+ */
204
+ export function strategiesFor(platform, strategies) {
205
+ if (Array.isArray(strategies) && strategies.length > 0) return strategies;
206
+ return BROWSER_STRATEGY_TABLE[platform] || FALLBACK_STRATEGIES;
207
+ }
208
+
209
+ /** 子进程的固定选项:不继承 stdio、独立进程组(CLI 退出后浏览器继续活着)、不闪黑框 */
210
+ function browserSpawnOptions() {
211
+ return { stdio: 'ignore', detached: true, windowsHide: true };
212
+ }
213
+
214
+ /**
215
+ * 打开浏览器(**同步**版,按策略表依次尝试)。
216
+ *
217
+ * 这是 `openInBrowser` 的历史契约,`device.mjs`(设备码模式)与 `lib/index.mjs`
218
+ * 都在用它,所以**返回值必须保持 boolean**、函数必须保持同步。
219
+ * 它只能判断"进程有没有被创建出来"(spawn 同步抛错 → 试下一条),
220
+ * 判断不了退出码 —— 要那份判断请用异步的 `openInBrowserWithFallback`(login 走那条)。
221
+ *
222
+ * 异步失败(进程创建不出来 → 下个 tick emit `error`)时,它会在**后台静默**继续试
223
+ * 后面的策略;只有**全部策略都异步失败**才回调 `onError`(只回调一次,
224
+ * 避免用户看到三遍一模一样的提示)。
225
+ *
226
+ * **永不抛错、永不中断登录**:失败只返回 `false`。
227
+ *
228
+ * @param {string} url 要打开的**完整**授权地址(含 query,逐字符传给系统)
229
+ * @param {object} [opts]
230
+ * @param {string} [opts.platform] 平台(默认 process.platform;测试可注入)
231
+ * @param {Function} [opts.spawnImpl] child_process.spawn(测试可注入,避免真的弹浏览器)
232
+ * @param {Array} [opts.strategies] 策略表(默认按平台取;测试可注入)
233
+ * @param {(e:Error)=>void} [opts.onError] **异步**失败的回调(同步失败直接 return false)
234
+ * @returns {boolean} 是否成功把打开动作交给了系统(乐观判断,见上)
235
+ */
236
+ export function openInBrowser(url, { platform = process.platform, spawnImpl = spawn, onError, strategies } = {}) {
237
+ const target = typeof url === 'string' ? url.trim() : '';
238
+ // 空地址没有任何可打开的:直接 false,别去 spawn 一个空参数
239
+ if (!target) return false;
240
+
241
+ const list = strategiesFor(platform, strategies);
242
+
243
+ /**
244
+ * `fromAsync` 用来区分两种失败,别把提示打成两遍:
245
+ * * 同步失败(spawn 直接抛):返回值已经是 false,调用方自己会提示"手动复制",
246
+ * 这里**不再**回调 onError;
247
+ * * 异步失败(emit `error`):返回值当时是 true,只能靠 onError 告诉用户"没打开成功"。
248
+ */
249
+ const attempt = (i, fromAsync, lastError) => {
250
+ if (i >= list.length) {
251
+ if (fromAsync && typeof onError === 'function') onError(lastError);
252
+ return false;
253
+ }
254
+ const strategy = list[i];
255
+ let child;
256
+ try {
257
+ child = spawnImpl(strategy.cmd, strategy.args(target), browserSpawnOptions());
258
+ } catch (e) {
259
+ // spawn 同步抛(参数非法等):不致命,换下一条策略
260
+ return attempt(i + 1, fromAsync, e);
261
+ }
262
+ if (!child) return attempt(i + 1, fromAsync, lastError);
263
+
264
+ /**
265
+ * ⚠️ `error` 监听**必须挂**:spawn 的目标不存在(例如容器里没有 xdg-open)时,
266
+ * Node 会在**下一个 tick** 异步 emit `error`。没有监听者的话 EventEmitter
267
+ * 会把它当未捕获异常**直接崩掉整个 CLI**(登录进程半途而死,用户只看到一段栈)。
268
+ *
269
+ * 挂上之后它同时是"静默降级到下一条策略"的入口 —— 多策略的意义就在这里。
270
+ */
271
+ if (typeof child.on === 'function') {
272
+ child.on('error', (e) => attempt(i + 1, true, e));
273
+ }
274
+ // detached + unref:浏览器进程不归 CLI 管,CLI 退出后它继续活着
275
+ if (typeof child.unref === 'function') child.unref();
276
+ return true;
277
+ };
278
+
279
+ return attempt(0, false, null);
280
+ }
281
+
282
+ /**
283
+ * 依次尝试策略表里的每一条,**每一条都等结果**(`error` / 退出码 / 超时)再决定下一条。
284
+ * 这是 `login`(本地回调模式)走的那条路 —— 老板要的"每个都检查结果后再决定下一个"。
285
+ *
286
+ * 与同步版的分工:同步版只为 `device.mjs` 保持历史契约(它 await 不了),
287
+ * 这里才拿得到"到底哪几条试过、最后成没成"。
288
+ *
289
+ * **永不抛错**(只 resolve,不 reject):调用方可以放心 await 而不用担心
290
+ * "因为打不开浏览器把登录搞失败"。
291
+ *
292
+ * @param {string} url
293
+ * @param {object} [opts]
294
+ * @param {string} [opts.platform] 平台(默认 process.platform)
295
+ * @param {Function} [opts.spawnImpl] child_process.spawn(测试可注入)
296
+ * @param {Array} [opts.strategies] 策略表(测试可注入,构造"前两条失败、第三条成功")
297
+ * @param {number} [opts.timeoutMs] 单条策略的超时(默认 BROWSER_LAUNCH_TIMEOUT_MS)
298
+ * @param {(info:object)=>void} [opts.onAttempt] 每条策略结束后的回调(--verbose 用)
299
+ * @returns {Promise<{attempted:boolean, opened:boolean, tried:string[], url:string}>}
300
+ */
301
+ export async function openInBrowserWithFallback(url, opts = {}) {
302
+ const {
303
+ platform = process.platform,
304
+ spawnImpl = spawn,
305
+ strategies,
306
+ timeoutMs = BROWSER_LAUNCH_TIMEOUT_MS,
307
+ onAttempt,
308
+ } = opts;
309
+
310
+ const target = typeof url === 'string' ? url.trim() : '';
311
+ // 空地址:一条策略都不试(`attempted:false` 让上层能如实上报"根本没试")
312
+ if (!target) return { attempted: false, opened: false, tried: [], url: '' };
313
+
314
+ const status = { attempted: true, opened: false, tried: [], url: target };
315
+ for (const strategy of strategiesFor(platform, strategies)) {
316
+ status.tried.push(strategy.label);
317
+ const result = await launchStrategy(strategy, target, spawnImpl, timeoutMs);
318
+ if (typeof onAttempt === 'function') {
319
+ try {
320
+ onAttempt({ label: strategy.label, ok: result.ok, error: result.error, timedOut: result.timedOut === true });
321
+ } catch {
322
+ /* 记日志本身失败绝不能影响"打开浏览器"这件事 */
323
+ }
324
+ }
325
+ if (result.ok) {
326
+ status.opened = true;
327
+ return status;
328
+ }
329
+ // 失败**静默**试下一条(只有 --verbose 才由 onAttempt 记一笔)
330
+ }
331
+ return status;
332
+ }
333
+
334
+ /**
335
+ * 跑**单条**策略:等 `error` / 退出码 / 超时,谁先到谁说了算。**永不 reject**。
336
+ *
337
+ * ⚠️ 超时按"乐观成功"处理(不再试后面的策略):
338
+ * 这些启动器(cmd start / rundll32 / open / xdg-open / Start-Process)都是
339
+ * "把 URL 交给系统就立刻退出"的程序,正常几十~几百毫秒就会 exit;
340
+ * 真拖到 3~5 秒说明它既没报错也没退出(吊住了)。此时再试下一条有把浏览器
341
+ * **开两次**的风险,而多开一个窗口比少开一次更惹人烦 —— 何况网址本来就打印在上面。
342
+ *
343
+ * ⚠️ 超时定时器**故意不 unref**:它就是"保证这次 await 一定会结算"的锚。
344
+ * (子进程是 unref 的,若定时器也 unref,事件循环可能直接空转退出,
345
+ * 这个 Promise 就永远不 resolve,登录会卡死在"打开浏览器"这一步。)
346
+ */
347
+ function launchStrategy(strategy, target, spawnImpl, timeoutMs) {
348
+ return new Promise((resolve) => {
349
+ let settled = false;
350
+ let timer = null;
351
+ const settle = (result) => {
352
+ if (settled) return;
353
+ settled = true;
354
+ if (timer) clearTimeout(timer);
355
+ resolve(result);
356
+ };
357
+
358
+ let child;
359
+ try {
360
+ child = spawnImpl(strategy.cmd, strategy.args(target), browserSpawnOptions());
361
+ } catch (e) {
362
+ resolve({ ok: false, error: e });
363
+ return;
364
+ }
365
+ if (!child) {
366
+ resolve({ ok: false, error: new Error('spawn 没有返回子进程') });
367
+ return;
368
+ }
369
+
370
+ timer = setTimeout(() => settle({ ok: true, timedOut: true }), timeoutMs);
371
+
372
+ if (typeof child.on === 'function') {
373
+ // 命令不存在 / 没权限 → 下个 tick 的 error:这条策略算失败,试下一条
374
+ child.on('error', (e) => settle({ ok: false, error: e }));
375
+ // 进程正常退出才算成功:非零退出码意味着"它自己认为没干成"(例如 xdg-open 找不到浏览器)
376
+ child.on('exit', (code, signal) => {
377
+ if (code === 0) return settle({ ok: true });
378
+ settle({ ok: false, error: new Error(`退出码 ${code}${signal ? `(信号 ${signal})` : ''}`) });
379
+ });
380
+ } else {
381
+ // 注入的假子进程连事件接口都没有:只能当"已交给系统"
382
+ settle({ ok: true });
383
+ }
384
+ if (typeof child.unref === 'function') child.unref();
385
+ });
386
+ }
387
+
388
+ /**
389
+ * 把"打开浏览器"的结果统一成事件里的那四个字段(`open_browser`)。
390
+ * 兼容历史注入:老测试注入的是 `() => true` / `() => false` 这种 boolean 返回值。
391
+ */
392
+ function normalizeOpenStatus(raw, url, skippedReason = null) {
393
+ if (raw && typeof raw === 'object') {
394
+ return {
395
+ attempted: raw.attempted !== false,
396
+ opened: raw.opened === true,
397
+ tried: Array.isArray(raw.tried) ? raw.tried.slice() : [],
398
+ url: typeof raw.url === 'string' && raw.url ? raw.url : url,
399
+ skipped_reason: raw.skipped_reason === undefined ? skippedReason : raw.skipped_reason,
400
+ };
401
+ }
402
+ return { attempted: true, opened: raw === true, tried: [], url, skipped_reason: skippedReason };
403
+ }
404
+
405
+ /**
406
+ * ★★ 「要不要替用户弹**系统**浏览器」—— **全 CLI 唯一的裁决点**。
407
+ *
408
+ * ## ★ 老板最终拍板:走**传统路线**(弹系统浏览器),本函数因此回到"默认总是尝试打开"
409
+ * 原话:「还是返回以前那种**打开跳到一个浏览器**,然后打开授权那个吧,**大家也都是那个的**。
410
+ * 我们在右侧这个……能力还不会太完善,我们这样做也不太好,**还是走传统的路吧**。」
411
+ * (A 路线 = CLI 自动弹系统浏览器 ✔ 采用;B 路线 = AI 客户端右侧内置浏览器 ✘ 暂时不做。)
412
+ *
413
+ * 为什么传统路线更好(写进代码里,免得下次又被"弹窗会不会吓到用户"带偏):
414
+ * * **稳定**:各平台都有成熟的启动器(`rundll32` / `open` / `xdg-open`…),
415
+ * 而"在 AI 客户端右侧打开"要靠宿主插件 + 客户端配合,能力还**不完善**;
416
+ * * **`window.close()` 在独立浏览器里生效**:授权页/收银台完成后的那一屏能**自己关掉**,
417
+ * 用户不会留下一个"我是不是没成功"的疑问窗口(内置侧边栏里关不掉,反而更别扭)。
418
+ *
419
+ * ## 判定(三条,顺序即优先级)
420
+ * 1. `--no-browser`(`noBrowser`)→ **不打开**(`skipped_reason: "no_browser"`)。
421
+ * ⚠️ 这是 AI 端"我不想弹窗"的**唯一开关**,也压过 `--open-browser`;
422
+ * 2. `--open-browser`(`forceOpen`)→ 打开(`mode: "forced"`)。
423
+ * ⚠️ 它现在与**默认行为等价**,但**保留**:显式语义 + 兼容既有脚本;
424
+ * 3. 默认(其它一切情况)→ **总是尝试打开系统浏览器**:
425
+ * * 人在终端(`isTTY === true && json !== true`)→ `mode: "terminal"`;
426
+ * * 其余(非 TTY / `--json` = **AI 场景**)→ `mode: "agent"`,★ **同样打开**。
427
+ * ⇒ 这是本次的行为变更:`json` / `isTTY` **不再**是"不打开"的理由。
428
+ *
429
+ * ## ⚠️ 已弃用的取值:`skipped_reason = "non_tty_agent_mode"`(不再可能出现)
430
+ * 2026-10-01 曾按"AI 场景默认不弹、改提示用户去右侧内置浏览器打开"做过一版,
431
+ * 那时才会有这个取值。老板最终拍板走传统路线后**它已经消失** ——
432
+ * 现在 `skipped_reason` 只有 `"no_browser"` 或 `null` 两种(老文档/老代码看到它就知道是历史值)。
433
+ * ⚠️ `open_browser` 的**结构与取值口径没有变**:`{attempted, opened, tried, url, skipped_reason}`。
434
+ *
435
+ * ## ⚠️ 绝不允许因为"没打开浏览器"让命令失败
436
+ * 返回 `allow:false` 只是"不弹窗";即使 `allow:true` 而打开失败(`attempted:true, opened:false`),
437
+ * 也只是多打一段**含完整 URL** 的醒目提示(见 `browserFailedHintLines`)——
438
+ * 授权/支付流程**照旧继续等回调、继续轮询**,语义完全不变。
439
+ *
440
+ * @param {object} [opts]
441
+ * @param {boolean} [opts.isTTY] stdout 是不是终端(命令层传 `!!stdout.isTTY`;现在只影响 `mode` 这个诊断字段)
442
+ * @param {boolean} [opts.json] 是不是 JSON 输出(同上,只影响 `mode`)
443
+ * @param {boolean} [opts.forceOpen] 用户写了 `--open-browser`
444
+ * @param {boolean} [opts.noBrowser] 用户写了 `--no-browser`(或 deps.openBrowser === false)
445
+ * @returns {{allow:boolean, mode:'off'|'forced'|'terminal'|'agent', skipped_reason:('no_browser'|null)}}
446
+ */
447
+ export function resolveBrowserIntent({ isTTY = false, json = false, forceOpen = false, noBrowser = false } = {}) {
448
+ // ① 用户明确关掉:连 `--open-browser` 也不许把它顶回来(--no-browser 优先)
449
+ if (noBrowser === true) return { allow: false, mode: 'off', skipped_reason: 'no_browser' };
450
+ // ② 用户明确要求打开:保留(与默认行为等价,但显式语义与兼容性都要留住)
451
+ if (forceOpen === true) return { allow: true, mode: 'forced', skipped_reason: null };
452
+ // ③ 人在终端:保持 CLI 一直以来的体验(这条**不能**破坏)
453
+ if (isTTY === true && json !== true) return { allow: true, mode: 'terminal', skipped_reason: null };
454
+ // ④ AI 场景(非 TTY / --json):★ 本次改回来的那一条 —— **同样尝试打开系统浏览器**
455
+ return { allow: true, mode: 'agent', skipped_reason: null };
456
+ }
457
+
458
+ /**
459
+ * ★ 打开成功时的一句话(老板最终拍板走**传统路线**:弹系统浏览器)。
460
+ *
461
+ * 文案的两个要点:
462
+ * 1. **先说结果**(「已为你打开浏览器」)—— 用户第一眼要确认"它到底开了没有";
463
+ * 以前这里写的是「正在打开浏览器…」,打开失败时那句话会让用户以为"还在开";
464
+ * 2. 说清**去那里干什么**(授权 / 支付),别让他回终端猜。
465
+ * "完成后页面会自动关闭 / 没关就手动关掉即可"由调用方紧接着单独打一行
466
+ * (**失败路径**也要同一句话,所以不塞进这一行)。
467
+ */
468
+ export function browserOpenedText({ kind = 'auth' } = {}) {
469
+ return kind === 'pay'
470
+ ? '✅ 已为你打开浏览器,请在那里完成支付(微信扫码)。'
471
+ : '✅ 已为你打开浏览器,请在那里完成授权。';
472
+ }
473
+
474
+ /**
475
+ * ★ 打开失败时的一段**醒目提示**(`⚠️` 起头 + **完整 URL 单独一行**,可直接复制)。
476
+ *
477
+ * 三条硬要求(都是老板反馈里点名的):
478
+ * 1. **醒目**:明确说"没能自动打开",别让用户以为还在打开中;
479
+ * 2. **可操作**:把**完整 URL 原样再打一遍**(就在这段提示里,用户不用往上翻页找),
480
+ * 并说清动作是"复制这一整行 → 粘贴到浏览器地址栏";
481
+ * 3. 说清打开之后干什么(点「同意」/ 用微信扫码),让非技术用户也能走完。
482
+ * 真客户不会自己去找网址,所以这段是"自动打开失败"时唯一能救回流程的东西。
483
+ *
484
+ * @param {string} url 完整地址(逐字符原样打印,方便复制)
485
+ * @param {object} [opts]
486
+ * @param {'auth'|'pay'} [opts.kind]
487
+ * @param {boolean} [opts.agent] AI 场景(非 TTY / `--json`)时追加一句**可选**提示:
488
+ * "若你的 AI 客户端支持内置浏览器,也可以让它在那里打开" ——
489
+ * ⚠️ 只是备选,**不是主路径**(B 路线暂时不做,老板最终选择传统路线)。
490
+ * @param {string} [opts.cause] 失败原因(spawn 报的原文),有就附在第一行括号里,方便排障
491
+ */
492
+ export function browserFailedHintLines(url, { kind = 'auth', agent = false, cause = '' } = {}) {
493
+ const 动作 = kind === 'pay' ? '支付' : '授权';
494
+ return [
495
+ '',
496
+ `⚠️ 浏览器没能自动打开${cause ? `(${cause})` : ''},请手动复制这个地址到浏览器完成${动作}:`,
497
+ ` ${url}`,
498
+ kind === 'pay'
499
+ ? '(这是我们网站的收银台页面:打开后用微信扫码支付;支付成功后页面会自动尝试关闭,没关就手动关掉即可。)'
500
+ : '(打开后点「同意」即可;授权完成后回到终端。授权成功后页面会自动尝试关闭,没关就手动关掉即可。)',
501
+ ...(agent ? ['(若你的 AI 客户端支持内置浏览器,也可以让它在那里打开同一个地址;这只是备选,不是必须的。)'] : []),
502
+ '',
503
+ ];
504
+ }
505
+
506
+ /**
507
+ * 「照着念」的一句话 —— 给 AI 端直接转述用(AI 不该自己组织语言,否则很容易漏掉网址)。
508
+ *
509
+ * ★ 老板最终拍板走传统路线(弹系统浏览器)之后,**两种场景共用同一句话术**:
510
+ * 以"已为你打开浏览器"为主,并把地址**原样带上**(万一本机没弹出来,AI 还能把它交给用户手动打开)。
511
+ *
512
+ * @param {string} url
513
+ * @param {object} [opts]
514
+ * @param {'auth'|'pay'} [opts.kind]
515
+ * @param {boolean} [opts.agent] ⚠️ **已弃用**(仅为兼容既有调用方而保留):
516
+ * 以前用它切"请在右侧(内置浏览器)…"的口径;B 路线暂停后两种场景话术已统一,
517
+ * 这个入参**不再影响输出**。
518
+ */
519
+ export function browserPasteText(url, { kind = 'auth', agent = false } = {}) {
520
+ // 已弃用:见上面的参数说明(保留形参只是不想为了一个布尔值去改所有调用方)
521
+ void agent;
522
+ const 收尾 = '(完成后页面会自动关闭;没关就手动关掉即可)';
523
+ return kind === 'pay'
524
+ ? `已为你打开浏览器,请在那里完成支付(微信扫码):${url}${收尾}`
525
+ : `已为你打开浏览器,请在那里完成授权:${url}${收尾}`;
526
+ }
527
+
528
+ /**
529
+ * 默认的"打开浏览器"实现(单独抽出来是为了**可测**)。
530
+ *
531
+ * `deps.spawnImpl` / `deps.platform` 可以注入 —— 这样单测能让"打开失败"真实发生
532
+ * (而不是把 openBrowser 整个换掉,那样就测不到 warn 文案与"不中断"这两件事了)。
533
+ *
534
+ * 两种失败(同步抛 / 异步 emit error)都打**同一段**"请手动复制这个地址…"的醒目提示,
535
+ * 而且提示里**自带完整 URL**(真客户不会往上翻页找地址),并且**都不中断**授权。
536
+ */
537
+ export function defaultOpenBrowser(deps = {}, log = () => {}) {
538
+ const agent = !(deps.isTTY === true && deps.json !== true);
539
+ return (url) =>
540
+ openInBrowser(url, {
541
+ platform: deps.platform,
542
+ spawnImpl: deps.spawnImpl,
543
+ onError: (e) =>
544
+ log(
545
+ browserFailedHintLines(url, {
546
+ kind: deps.browserHintKind === 'pay' ? 'pay' : 'auth',
547
+ agent,
548
+ cause: (e && e.message) || String(e),
549
+ }).join('\n'),
550
+ ),
551
+ });
552
+ }
553
+
554
+ /**
555
+ * `login`(本地回调模式)专用的默认实现:**异步**版,多策略逐条等待结果。
556
+ *
557
+ * ⚠️ 为什么不复用上面的 `defaultOpenBrowser`:`device.mjs`(设备码模式)是**同步**调用它
558
+ * 并拿 boolean 的,那段代码不在本次改动范围内 —— 所以同步版保留原契约不动,
559
+ * login 这条链路上再加一个能 await 结果的版本。两者共用同一张策略表,
560
+ * 差别只在"要不要等退出码"。
561
+ *
562
+ * 失败**不在这里**打印长提示:login 主流程拿到 `opened:false` 后会打印
563
+ * 一段**带完整 URL** 的醒目提示(见 runLogin 第 4 步)。这里只在 `--verbose` 时
564
+ * 逐条记录"哪条策略为什么挂了",方便真机排障。
565
+ *
566
+ * @param {object} deps { platform, spawnImpl, browserStrategies, browserTimeoutMs, verbose }
567
+ * @param {(msg:string)=>void} log
568
+ */
569
+ export function defaultOpenBrowserAsync(deps = {}, log = () => {}) {
570
+ const verbose = deps.verbose === true;
571
+ return (url) =>
572
+ openInBrowserWithFallback(url, {
573
+ platform: deps.platform,
574
+ spawnImpl: deps.spawnImpl,
575
+ strategies: deps.browserStrategies,
576
+ timeoutMs: deps.browserTimeoutMs,
577
+ onAttempt: verbose
578
+ ? ({ label, ok, error, timedOut }) =>
579
+ log(
580
+ ok
581
+ ? `[verbose] 打开浏览器:「${label}」${timedOut ? '超时未返回,按已交给系统处理' : '成功'}`
582
+ : `[verbose] 打开浏览器:「${label}」失败(${(error && error.message) || error}),静默试下一条`,
583
+ )
584
+ : undefined,
585
+ });
586
+ }
587
+
588
+ /**
589
+ * 「把一个网址交给用户的浏览器」—— **全 CLI 唯一入口**(login / 钱包充值共用)。
590
+ *
591
+ * ## 为什么必须抽出来(不允许各命令自己 spawn 一份)
592
+ *
593
+ * 打开浏览器这件事踩过的坑全在这个文件顶部(`cmd /c start` 的引号、URL 里的 `&`
594
+ * 被 shell 吃掉、`http.server` 没挂 `error` 监听……)。各命令自己复制一份,
595
+ * 就等于把这些坑再踩一遍,而且**只有其中一份会被修**。
596
+ *
597
+ * 与 `defaultOpenBrowserAsync` 的区别:
598
+ * * `defaultOpenBrowserAsync` 只管"按多策略打开",返回原始结果;
599
+ * * 本函数是**给命令层用的**:把"注入的假实现(测试里返回 boolean)"与
600
+ * "真实现(返回四个字段)"**归一化成同一个状态对象**,并且
601
+ * **吞掉任何异常**(打不开浏览器绝不能把命令搞失败 —— 网址已经打印给用户了)。
602
+ *
603
+ * @param {string} url 要打开的完整网址
604
+ * @param {object} [opts]
605
+ * @param {object} [opts.deps] 运行期依赖 `{ openBrowser, platform, spawnImpl, browserStrategies, browserTimeoutMs, verbose,
606
+ * isTTY, json, forceOpen, noBrowser }`
607
+ * @param {(msg:string)=>void} [opts.log] 诊断日志(`--verbose` 用)
608
+ * @returns {Promise<{attempted:boolean, opened:boolean, tried:string[], url:string, skipped_reason:(string|null)}>}
609
+ */
610
+ export async function openUrlForUser(url, { deps = {}, log = () => {} } = {}) {
611
+ const target = typeof url === 'string' ? url.trim() : '';
612
+ /**
613
+ * ★ 先过**唯一闸门**(见 resolveBrowserIntent):默认**总是尝试打开**系统浏览器
614
+ * (人在终端 ✔ + AI 场景 ✔),只有 `--no-browser` 才不打开。
615
+ * ⚠️ 这里是钱包充值那条链路(login 走 runLogin 里的同一个函数),所以两处行为天然一致 ——
616
+ * **不允许**在任何命令里自己判断一遍 TTY 再决定要不要 spawn(那就是复制逻辑,
617
+ * 迟早会出现"授权不弹、支付还弹"这种不一致)。
618
+ */
619
+ const intent = resolveBrowserIntent({
620
+ isTTY: deps.isTTY === true,
621
+ json: deps.json === true,
622
+ forceOpen: deps.forceOpen === true,
623
+ noBrowser: deps.noBrowser === true || deps.openBrowser === false,
624
+ });
625
+ if (!intent.allow) {
626
+ // 不弹窗 ≠ 失败:地址照常由调用方打印/放进 JSON,流程一丝不变
627
+ return { attempted: false, opened: false, tried: [], url: target, skipped_reason: intent.skipped_reason };
628
+ }
629
+ const opener =
630
+ typeof deps.openBrowser === 'function'
631
+ ? deps.openBrowser
632
+ : defaultOpenBrowserAsync(
633
+ {
634
+ platform: deps.platform,
635
+ spawnImpl: deps.spawnImpl,
636
+ browserStrategies: deps.browserStrategies,
637
+ browserTimeoutMs: deps.browserTimeoutMs,
638
+ verbose: deps.verbose === true,
639
+ },
640
+ log,
641
+ );
642
+ try {
643
+ return normalizeOpenStatus(await opener(target), target);
644
+ } catch (e) {
645
+ // 注入的实现抛错 / 真实现 reject:如实记成"没打开",命令继续往下走
646
+ log(`[verbose] 打开浏览器时抛错(已忽略):${(e && e.message) || e}`);
647
+ return { attempted: true, opened: false, tried: [], url: target, skipped_reason: null };
648
+ }
649
+ }
650
+
651
+ /**
652
+ * 回一个 HTML 页面。
653
+ *
654
+ * 页面本体(成功页 / 各错误页)都在 `./page.mjs` —— 那里是**纯模板**,
655
+ * 单测可以直接断言文案与体积,不必起 server;本文件只负责"发出去"。
656
+ *
657
+ * ⚠️ `Content-Type` 必须显式写 `charset=utf-8`:不写的话浏览器会按**本地
658
+ * 默认编码**猜(中文 Windows 上常猜成 GBK),而这个页面**通篇是中文**,
659
+ * 猜错就是整页乱码 —— 比白屏还糟(用户以为授权坏了)。
660
+ * ⚠️ `Cache-Control: no-store`:回调 URL 里带着一次性授权码,
661
+ * 绝不能被浏览器缓存、也不该留在历史里。
662
+ */
663
+ function sendHtml(res, status, page) {
664
+ res.writeHead(status, {
665
+ 'Content-Type': 'text/html; charset=utf-8',
666
+ 'Cache-Control': 'no-store',
667
+ 'Content-Length': Buffer.byteLength(page.html),
668
+ });
669
+ res.end(page.html);
670
+ }
671
+
672
+ /**
673
+ * 跑一次登录。
674
+ * @param {object} args
675
+ * @param {object} args.config 已生效的配置(base/clientId/scope/credentialsFile/callback*)
676
+ * @param {(msg:string)=>void} args.log
677
+ * @param {object} [args.deps] { fetchImpl, openBrowser, now, listenPort, callbackFirstWaitMs,
678
+ * spawnImpl, platform, onAuthorizationUrl, proxyRisk,
679
+ * ★ isTTY, json, forceBrowser, noBrowser —— 见 resolveBrowserIntent(),
680
+ * ★ handoff, postDeviceGraceMs —— 见下 }
681
+ * ★ `isTTY` / `json` / `forceBrowser`(`--open-browser`)/ `noBrowser`(`--no-browser`)四个字段
682
+ * 决定"要不要替用户弹系统浏览器":★ **默认总是尝试打开**(人在终端与 AI 场景都一样),
683
+ * 只有 `noBrowser`(`--no-browser`)才不打开;`isTTY` / `json` 现在只影响 `mode` 这个诊断字段
684
+ * ——理由见 resolveBrowserIntent()。
685
+ * `callbackFirstWaitMs` —— ★ 首轮等待窗口(默认 `DEFAULT_CALLBACK_FIRST_WAIT_MS` = 180 秒;
686
+ * 传 0 或负数 = 关掉自愈,只等整段 `loginTimeoutMs`)。命令层会把它透传进来
687
+ * (**只有检测到系统代理时才给非 0**,见 `commands/auth.mjs` 的 resolveCallbackFirstWaitMs),
688
+ * 单测则用短窗口(例如 400ms)来验证"自动改设备码"这条分支,不必真的等 180 秒。
689
+ * ★★ `handoff` —— **「回调 / 设备码两条路并存」的交接面**(2026-10-03 修 ERR_CONNECTION_REFUSED 时加的)。
690
+ * 调用方(命令层)传一个**可变对象**,两边各填一半:
691
+ * * 调用方**先**填 `handoff.onSwitch(reason)` —— 首轮等待/整段超时到点时会被调用
692
+ * (`reason` 是 `'callback_first_wait'` 或 `'callback_timeout'`),它应当去启动设备码那条路,
693
+ * **不要**在这里结束回调这条路:本地监听由本函数继续持有;
694
+ * * 本函数填 `handoff.finishWithDevice(result)` / `handoff.abort(error)` ——
695
+ * 设备码那条路先拿到令牌时调前者(本函数据此收尾,并给"迟到的回调"回一张已完成页),
696
+ * 设备码那条路也失败时调后者(本函数用这个错误结束等待)。
697
+ * ⚠️ **没传 `handoff`(或 `onSwitch` 不是函数)= 旧契约**:到点直接抛 `callback_first_wait` /
698
+ * `timeout` 并在 `finally` 里关掉监听(既有的 runLogin 级单测走的就是这条,语义不许变)。
699
+ * `postDeviceGraceMs` —— 设备码先成功后,本地监听再多留多久(默认 `DEFAULT_POST_DEVICE_GRACE_MS`;
700
+ * 只对"首轮等待"这条自愈生效;单测注入小值即可确定性地验证"迟到的回调拿到已完成页")。
701
+ */
702
+ export async function runLogin({ config, log = () => {}, deps = {} }) {
703
+ const fetchImpl = deps.fetchImpl || globalThis.fetch;
704
+ /**
705
+ * ⚠️ 这里必须用 `!== false` 显式判断,不能写 `deps.openBrowser || 默认实现` ——
706
+ * 后来加 `--no-browser` 时传的就是 `openBrowser: false`,`false || 默认` 会**打开浏览器**,
707
+ * 等于开关失效(经典 `||` 陷阱)。同时支持注入一个函数用于测试。
708
+ *
709
+ * ★ 2026-10-01 起:`deps.openBrowser === false` 只是"用户主动关掉"这一种情况;
710
+ * **要不要弹系统浏览器**这件事统一由 `resolveBrowserIntent()` 裁决 ——
711
+ * ★ 老板最终拍板走**传统路线**:**默认总是尝试打开**(AI 场景也打开),
712
+ * 只有 `--no-browser` 才不打开(见 resolveBrowserIntent 的说明)。
713
+ */
714
+ const browserIntent = resolveBrowserIntent({
715
+ isTTY: deps.isTTY === true,
716
+ json: deps.json === true,
717
+ forceOpen: deps.forceBrowser === true,
718
+ noBrowser: deps.openBrowser === false || deps.noBrowser === true,
719
+ });
720
+ /**
721
+ * ★★ 最终是否自动打开系统浏览器 = **唯一闸门**的结论。
722
+ *
723
+ * ★ 本次变更(老板最终拍板):「**总是尝试打开**」重新成为默认 ——
724
+ * 人在终端 ✔、AI 场景(非 TTY / `--json`)✔ 都打开;`json` / `isTTY` **不再**是"不打开"的理由。
725
+ * 唯一的"不打开"开关是 `--no-browser`(AI 端不想弹窗就用它)。
726
+ * 为什么回到传统路线(原话):
727
+ * 「还是返回以前那种打开跳到一个浏览器,然后打开授权那个吧,大家也都是那个的。
728
+ * 我们在右侧这个……能力还不会太完善……还是走传统的路吧。」
729
+ * ⇒ 稳定(成熟的系统启动器)+ `window.close()` 在独立浏览器里生效(页面能自己关)。
730
+ * ⚠️ 无论哪种情况,**绝不**因为"没打开浏览器"让登录失败 —— 地址一直完整打印,回调照旧等。
731
+ */
732
+ const shouldOpenBrowser = browserIntent.allow;
733
+ /**
734
+ * 默认实现走 `defaultOpenBrowserAsync(deps, log)`:**异步多策略**版 —— 把
735
+ * `deps.spawnImpl` / `deps.platform`(以及测试用的 `browserStrategies` /
736
+ * `browserTimeoutMs`)透传给 `openInBrowserWithFallback`,逐条策略等结果,
737
+ * 拿得到"试过哪几条、最后成没成"(`--json` 的 `open_browser` 字段就是它)。
738
+ *
739
+ * ⚠️ 注意这里和 `device.mjs` 用的 `defaultOpenBrowser`(同步版)是**两个**函数:
740
+ * device 那条链路不在本次改动范围内,不能改它的同步 boolean 契约。
741
+ */
742
+ const openBrowser =
743
+ typeof deps.openBrowser === 'function'
744
+ ? deps.openBrowser
745
+ : defaultOpenBrowserAsync({ ...deps, verbose: deps.verbose === true || config.verbose === true }, log);
746
+ const now = deps.now || (() => Date.now());
747
+ /** 回调地址在真正 listen 之后才知道端口;handler 里要用,所以先声明 */
748
+ let redirectUri = '';
749
+ const api = createApi({ config, fetchImpl });
750
+ /**
751
+ * 代理风险(命令层探测后经 `deps.proxyRisk` 透传进来)。
752
+ *
753
+ * ⚠️ 为什么**不在这里**自己探测:探测只需要做一次(runtime 里做了记忆化),
754
+ * 而命令层必须在本流程**开始之前**就拿到结论(先提示、甚至直接改走设备码)。
755
+ * 这里只是把同一份结论织进"回调没等到"的错误提示里 —— 那是老板报的那个故障的现场。
756
+ */
757
+ const proxyHint = proxyRiskHint(deps.proxyRisk, { cmdName: config.cmdName || 'wkgeo' });
758
+
759
+ /* ---- 1. 发现授权端点(metadata)---- */
760
+ let metadata = null;
761
+ try {
762
+ metadata = await api.metadata();
763
+ } catch (e) {
764
+ // metadata 拉不到不算致命:用兜底地址继续(但要如实告知)
765
+ log(`⚠️ 读取授权服务器元数据失败(${e.message}),改用内置默认授权地址继续`);
766
+ }
767
+
768
+ let authorizeBase = (metadata && metadata.authorization_endpoint) || `${config.oauthWebBase}/oauth/authorize`;
769
+ // 线上曾经返回 http://localhost:8000/...(云函数默认值没配环境变量)。
770
+ // 那种地址打开的是**用户自己机器**的端口 → 第一步就断,必须纠正。
771
+ // ★ 2026-10-02(三站拆分):纠偏目标改成**授权站子域** `config.oauthWebBase`。
772
+ // 顺带加一条守卫:host 不属于本平台域名(*.wkgeo.com / *.wkongai.cn)的一律纠偏 ——
773
+ // 服务端配置写错域名时,比"打开一个陌生站点"更安全的做法是用内置默认值。
774
+ const 本平台授权域 = /^https?:\/\/[^/]*\.(wkgeo\.com|wkgeo\.cn)(:\d+)?\//i;
775
+ if (
776
+ /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i.test(String(authorizeBase)) ||
777
+ !本平台授权域.test(String(authorizeBase))
778
+ ) {
779
+ log(
780
+ `⚠️ 服务端返回的授权地址不是本平台域名(${authorizeBase}),已改用 ${config.oauthWebBase}/oauth/authorize`,
781
+ );
782
+ authorizeBase = `${config.oauthWebBase}/oauth/authorize`;
783
+ }
784
+
785
+ /**
786
+ * ★ 授权页地址**从构造时就带尾斜杠**(`/admin/oauth/authorize/?client_id=…`)。
787
+ *
788
+ * 为什么:前端是静态导出(每个路由一个目录),托管侧(OSS 静态托管 / 阿里云 ESA)
789
+ * 对**不带尾斜杠**的目录路径会 302,而那个 302 **不携带 query** ——
790
+ * 授权参数会在任何 JS 执行之前就被丢光,页面显示「授权请求不完整」。
791
+ * 这里对**两种来源**都做一次归一化(服务端 metadata 给的 + 我们自己拼的兜底地址):
792
+ * * 线上 metadata 已修好、会返回带斜杠的地址 → 这一步是无副作用的兜底(不拼出 `//`);
793
+ * * 但不能假设服务端配置永远正确(本项目真被 `authorization_endpoint` 配成本机地址坑过一次),
794
+ * 客户端多做一道是**便宜且必要**的防御。
795
+ * 收口在 `config.mjs` 的 `withTrailingSlash()`(唯一构造入口,有单测钉住)。
796
+ */
797
+ authorizeBase = withTrailingSlash(authorizeBase);
798
+
799
+ const ttlSec = Number(metadata && metadata.access_token_ttl_seconds);
800
+ const onExpiry = (metadata && metadata.on_token_expiry) || 'reauthorize';
801
+
802
+ /* ---- 2. PKCE + state ---- */
803
+ const verifier = createVerifier();
804
+ const challenge = challengeOf(verifier);
805
+ const state = createState();
806
+
807
+ /* ---- 3. 起本地回调服务(只监听 127.0.0.1)---- */
808
+ let resolveFlow;
809
+ let rejectFlow;
810
+ const flow = new Promise((resolve, reject) => {
811
+ resolveFlow = resolve;
812
+ rejectFlow = reject;
813
+ });
814
+ /**
815
+ * ⚠️ 这里挂一个空 catch 是**必须**的:
816
+ * `finish()` 可能在调用方 `await flow` **之前**就 reject(比如换票失败发生在 HTTP 回调里,
817
+ * 而调用方此刻还在 await fetch)。Node 16+ 对"无人处理的 rejection"会直接崩进程,
818
+ * 单测里就表现为"login:换票失败"这条用例莫名其妙地失败。
819
+ * 挂一个空 catch 只是加一个旁观者,不影响后面正常的 await(仍然会抛)。
820
+ */
821
+ flow.catch(() => {});
822
+
823
+ let settled = false;
824
+ let server;
825
+ let timer = null;
826
+ /** ★ 首轮等待定时器(见 DEFAULT_CALLBACK_FIRST_WAIT_MS):到点 → 让命令层改走设备码 */
827
+ let firstWaitTimer = null;
828
+ /**
829
+ * ★★ 交接面(见 deps.handoff 的说明):命令层传进来的可变对象,两边各填一半。
830
+ * `onSwitch` 由命令层填(到点时启动设备码那条路),`finishWithDevice` / `abort` 由这里填。
831
+ * ⚠️ `canHandoff === false`(没传/形状不对)= 保持旧契约:到点直接抛错并关监听。
832
+ */
833
+ const handoff = deps.handoff && typeof deps.handoff === 'object' ? deps.handoff : null;
834
+ const canHandoff = !!(handoff && typeof handoff.onSwitch === 'function');
835
+ /** 这次自愈是被哪个窗口触发的(只有 `callback_first_wait` 才留 grace,见 DEFAULT_POST_DEVICE_GRACE_MS) */
836
+ let switchReason = null;
837
+ /** 设备码那条路**已经**拿到令牌(命令层调 handoff.finishWithDevice 之后为 true) */
838
+ let deviceDone = false;
839
+ let deviceResult = null;
840
+ /** 设备码先成功后,本地监听"再活多久"(毫秒);0 = 立刻收尾 */
841
+ let postGraceMs = 0;
842
+ let graceTimer = null;
843
+
844
+ const finish = (err, value) => {
845
+ if (settled) return;
846
+ settled = true;
847
+ if (timer) clearTimeout(timer);
848
+ if (firstWaitTimer) clearTimeout(firstWaitTimer);
849
+ if (graceTimer) clearTimeout(graceTimer);
850
+ if (err) rejectFlow(err);
851
+ else resolveFlow(value);
852
+ };
853
+
854
+ /** 关掉本地监听(grace 结束后或被"迟到的回调"提前唤醒时用) */
855
+ const closeServer = () =>
856
+ new Promise((resolve) => {
857
+ try {
858
+ // ⚠️ 浏览器可能用 keep-alive 挂着连接,`close()` 会一直等它 —— 直接掐断更干脆
859
+ if (typeof server.closeAllConnections === 'function') server.closeAllConnections();
860
+ server.close(() => resolve());
861
+ } catch {
862
+ resolve();
863
+ }
864
+ });
865
+
866
+ /**
867
+ * ★★ 设备码那条路**先**拿到令牌 → 这里收尾(真机证据与取舍见 DEFAULT_POST_DEVICE_GRACE_MS)。
868
+ *
869
+ * 关键点:**不立刻关监听**,而且**不 await** —— 命令层要马上拿到结果去打印"授权成功",
870
+ * 本地监听在后台再活 `postGraceMs`(或被一次迟到的回调提前唤醒,见 handler 里的 `deviceDone` 分支)。
871
+ */
872
+ if (handoff) {
873
+ handoff.finishWithDevice = (result) => {
874
+ if (settled) return;
875
+ deviceDone = true;
876
+ deviceResult = result;
877
+ // 令牌已经到手:这两个窗口的定时器都不再有意义(否则它们会在 grace 期间把流程判成失败)
878
+ if (timer) {
879
+ clearTimeout(timer);
880
+ timer = null;
881
+ }
882
+ if (firstWaitTimer) {
883
+ clearTimeout(firstWaitTimer);
884
+ firstWaitTimer = null;
885
+ }
886
+ postGraceMs = switchReason === 'callback_first_wait' ? Number(deps.postDeviceGraceMs ?? DEFAULT_POST_DEVICE_GRACE_MS) : 0;
887
+ if (!Number.isFinite(postGraceMs) || postGraceMs < 0) postGraceMs = 0;
888
+ finish(null, result);
889
+ };
890
+ /** 设备码那条路也没走通 → 用命令层给的"两条路都不通"错误结束等待(见 commands/auth.mjs) */
891
+ handoff.abort = (err) => finish(err);
892
+ }
893
+
894
+ /**
895
+ * ★ 把"回调这条路没等到结果"**交接**给命令层(只通知、不结束、**不关监听**)。
896
+ *
897
+ * 文案与旧版保持一致的关键词(`没收到回调` / `本地回调没有等到结果` / `已自动改用`),
898
+ * 既有单测与排障文档按这些字样检索;新增的只有"监听继续保留"这句 —— 那正是本次修复的核心。
899
+ */
900
+ const handOff = (reason) => {
901
+ if (settled || switchReason) return;
902
+ switchReason = reason;
903
+ const 原因 = reason === 'callback_first_wait' ? '首轮等待没收到回调' : '等待超时';
904
+ log('');
905
+ log(`⚠️ 本地回调没有等到结果(${原因}):已自动改用【设备码模式】继续(不需干等,两条路并行)。`);
906
+ log(
907
+ 'ℹ️ 本机回调监听**继续保留**:如果你之后才在浏览器里点【同意授权】,那一次照样能直接完成登录' +
908
+ '(不会再出现"连接被拒绝 / ERR_CONNECTION_REFUSED")。',
909
+ );
910
+ try {
911
+ handoff.onSwitch(reason);
912
+ } catch (e) {
913
+ // 交接失败不能让"回调这条路"跟着崩:它还在正常等回调,用户还有机会成功
914
+ log(`[verbose] 交接给设备码那条路时出错(已忽略,回调这条路继续等):${(e && e.message) || e}`);
915
+ }
916
+ };
917
+
918
+ server = http.createServer(async (req, res) => {
919
+ // 只管回调路径;其它路径(比如浏览器自动请求 /favicon.ico)一律 404,避免误判
920
+ let parsed;
921
+ try {
922
+ parsed = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`);
923
+ } catch {
924
+ res.writeHead(400).end('bad request');
925
+ return;
926
+ }
927
+ if (parsed.pathname !== config.callbackPath) {
928
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
929
+ res.end('not found');
930
+ return;
931
+ }
932
+ if (req.method !== 'GET') {
933
+ sendHtml(
934
+ res,
935
+ 405,
936
+ errorPage({
937
+ tone: 'warn',
938
+ heading: '方法不允许',
939
+ reason: '这个回调地址只接受浏览器直接打开的 GET 请求(收到了 ' + req.method + ')。',
940
+ hint: '请把终端里打印的那一整行授权网址复制到浏览器地址栏打开。',
941
+ }),
942
+ );
943
+ return;
944
+ }
945
+
946
+ /**
947
+ * ★★ 设备码那条路**已经拿到令牌**了 ⇒ 这一次回调只是"用户后来才点的同意"。
948
+ *
949
+ * 真机证据(老板 2026-10-03 反馈的连带缺陷):旧逻辑在切设备码时**关掉了本地回调监听**,
950
+ * 于是用户随后点【同意授权】时浏览器跳到 `http://127.0.0.1:<port>/callback?code=…`
951
+ * 得到 **ERR_CONNECTION_REFUSED** —— 用户会以为授权失败(而他其实已经登录成功了)。
952
+ * 现在监听还开着,所以这里**必须**给一张友好的"已完成"页,而**不是**任何错误页。
953
+ *
954
+ * 🔴 两条硬约束:
955
+ * 1. **不要**拿这个 code 去换票:凭据已经由设备码那条路写好,再换一次等于同一份授权重复签发
956
+ * (多一份令牌,还可能把刚写好的凭据覆盖掉);
957
+ * 2. 这一页**不回显** code,也不出现任何令牌(与其它页同一条安全底线)。
958
+ */
959
+ if (deviceDone) {
960
+ sendHtml(res, 200, alreadyDonePage(deviceResult && deviceResult.account));
961
+ // 页面已经交给浏览器了:grace 不必再等,监听立刻收尾
962
+ if (graceTimer) {
963
+ clearTimeout(graceTimer);
964
+ graceTimer = null;
965
+ }
966
+ closeServer();
967
+ return;
968
+ }
969
+
970
+ const gotState = parsed.searchParams.get('state');
971
+ const code = parsed.searchParams.get('code');
972
+ const err = parsed.searchParams.get('error');
973
+ const errDesc = parsed.searchParams.get('error_description');
974
+
975
+ // ★ state 校验:**服务端原样回传但不校验**,防 CSRF/串号是客户端自己的责任。
976
+ // 不匹配就拒绝这一条,但**不结束流程**(可能是别人瞎打的),等真正的回调。
977
+ if (!gotState || !safeEqual(gotState, state)) {
978
+ /**
979
+ * ⚠️ 大标题保留「state 校验失败」这句原文:它是本文件的**契约字样**,
980
+ * `test/login.test.mjs` 用它分辨"这条分支被走到了",
981
+ * 外部排障文档也按这句话搜索。改样式可以,改这句话不行。
982
+ */
983
+ sendHtml(
984
+ res,
985
+ 400,
986
+ errorPage({
987
+ tone: 'warn',
988
+ heading: 'state 校验失败',
989
+ reason: '这次回调带的 state 与本次登录不一致,已忽略(可能是别人误打进来的请求)。',
990
+ hint: '如果终端还在等待授权,请回到浏览器重新走一次同意页;已重新点过同意的话,回终端重跑一次即可。',
991
+ }),
992
+ );
993
+ return;
994
+ }
995
+
996
+ if (err) {
997
+ const hint = err === 'access_denied' ? '你(或页面上的人)点了「拒绝」。' : `服务端返回错误:${err}`;
998
+ sendHtml(
999
+ res,
1000
+ 200,
1001
+ errorPage({
1002
+ tone: 'warn',
1003
+ heading: '授权未完成',
1004
+ reason: hint,
1005
+ hint: '没有拿到授权码,也没有写入任何凭据。',
1006
+ /**
1007
+ * ⚠️ 这一页**不自动关闭**,哪怕它已经是终态(下面 `finish(...)` 会带着错误结束等待)。
1008
+ * 两个理由:
1009
+ * 1. 用户可能想看清"到底为什么没成"(拒绝了?还是服务端报错?);
1010
+ * 2. 阿里云 `mcp-remote` 那套的教训 —— 它在**刚收到 code** 就替用户宣告成功并
1011
+ * `window.close()`,而此刻令牌还没换(后面才 POST token 端点);一旦换票失败,
1012
+ * 用户已经被打发走了,只剩一个"我刚才是不是失败了"的疑惑。
1013
+ * 所以本项目的口径是:**只有"真的把凭据写进本机"之后的那张成功页**才尝试自动关,
1014
+ * 其余页面(含本页)一律留在屏幕上让人看完。见 `page.mjs` 里 AUTO_CLOSE_SCRIPT 的注释。
1015
+ */
1016
+ }),
1017
+ );
1018
+ finish(new CliError(err === 'access_denied' ? '用户拒绝了本次授权' : `授权失败:${err}${errDesc ? ' — ' + errDesc : ''}`, {
1019
+ kind: err === 'access_denied' ? 'denied' : 'authz_error',
1020
+ code: err,
1021
+ hint: '没有拿到授权码,未写入任何凭据',
1022
+ exitCode: EXIT.AUTH,
1023
+ }));
1024
+ return;
1025
+ }
1026
+
1027
+ if (!code) {
1028
+ sendHtml(
1029
+ res,
1030
+ 400,
1031
+ errorPage({
1032
+ tone: 'warn',
1033
+ heading: '缺少授权码',
1034
+ reason: '回调地址里没有 code 参数,无法继续换票。',
1035
+ hint: '通常说明授权页那边没走完(或被手工改过地址)。',
1036
+ }),
1037
+ );
1038
+ return;
1039
+ }
1040
+
1041
+ // 用 code 换 token(先换再回页面,页面才能如实显示成功/失败)
1042
+ try {
1043
+ const tokenResponse = await api.token({
1044
+ grant_type: 'authorization_code',
1045
+ code,
1046
+ redirect_uri: redirectUri,
1047
+ client_id: config.clientId,
1048
+ code_verifier: verifier,
1049
+ });
1050
+
1051
+ let account = null;
1052
+ try {
1053
+ account = await api.userinfo(tokenResponse.access_token);
1054
+ } catch {
1055
+ // 身份信息拿不到不影响"已经登录"这个事实(权限收敛在服务端,不依赖它)
1056
+ }
1057
+
1058
+ const credentials = credentialsFromToken({
1059
+ clientId: config.clientId,
1060
+ tokenResponse,
1061
+ account,
1062
+ scope: config.scope,
1063
+ now: now(),
1064
+ });
1065
+ const mode = writeCredentials(config.credentialsFile, credentials, { now: now() });
1066
+
1067
+ /**
1068
+ * ⚠️ 成功页只吃 `account`(邮箱/昵称/脱敏手机号),**绝不**把
1069
+ * `tokenResponse` 传进去 —— 那是"页面里不出现令牌"这条红线的实现方式:
1070
+ * 模板没有接收令牌的位置,就不可能漏出去。
1071
+ *
1072
+ * ⚠️ **顺序是有约束的,别调换**:必须先 `await api.token(...)` 换到令牌、
1073
+ * 再 `writeCredentials(...)` 落盘,**最后**才回这张成功页 ——
1074
+ * 因为成功页会尝试自动关掉标签页(见 page.mjs 的 AUTO_CLOSE_SCRIPT)。
1075
+ * 阿里云那套正是在"刚收到 code、还没换票"时就回了成功页并关窗,
1076
+ * 于是换票失败时用户已被打发走、还被告知"成功"(那个坑别踩)。
1077
+ * 我们这里失败一律走下面的 catch → 换票失败页(**不自动关闭**,原因留在屏幕上)。
1078
+ */
1079
+ sendHtml(res, 200, successPage(account));
1080
+
1081
+ finish(null, { credentials, account, mode, metadata });
1082
+ } catch (e) {
1083
+ const msg = (e && e.message) || String(e);
1084
+ /**
1085
+ * ⚠️ 失败原因来自服务端/网络层,可能夹带用户不可控的内容 ——
1086
+ * `errorPage` 内部会做 HTML 转义(见 page.mjs 的安全底线 4)。
1087
+ * ⚠️ 这里**只放原因**,不放任何凭据:换票失败时服务端的响应体里
1088
+ * 可能带 `error_description`,但绝不会带明文令牌(签发成功才会走上面那条路)。
1089
+ */
1090
+ sendHtml(res, 500, errorPage({ heading: '换取令牌失败', reason: msg, hint: '没有写入任何凭据。' }));
1091
+ finish(e instanceof CliError ? e : new CliError(`换取令牌失败:${msg}`, { kind: 'token_exchange', hint: '重新运行 `wkgeo login`' }));
1092
+ }
1093
+ });
1094
+
1095
+ const listenOn = (port) =>
1096
+ new Promise((resolve, reject) => {
1097
+ const onError = (e) => {
1098
+ server.removeListener('listening', onListening);
1099
+ reject(e);
1100
+ };
1101
+ const onListening = () => {
1102
+ server.removeListener('error', onError);
1103
+ resolve(server.address());
1104
+ };
1105
+ server.once('error', onError);
1106
+ server.once('listening', onListening);
1107
+ // ⚠️ 只绑 127.0.0.1:绑 0.0.0.0 等于把授权码暴露给同网段任何人
1108
+ server.listen(port, config.callbackHost);
1109
+ });
1110
+
1111
+ let bound;
1112
+ try {
1113
+ bound = await listenOn(deps.listenPort ?? config.callbackPort);
1114
+ } catch (e) {
1115
+ if (e && e.code === 'EADDRINUSE') {
1116
+ log(`端口 ${config.callbackPort} 被占用,改用随机回环端口(服务端按 RFC 8252 忽略端口,仍能回调成功)`);
1117
+ bound = await listenOn(0);
1118
+ } else {
1119
+ throw new CliError(`无法在 ${config.callbackHost} 上监听回调端口:${e.message}`, {
1120
+ kind: 'listen',
1121
+ hint: `检查防火墙/端口占用,或用 --callback-port 指定其它端口${proxyHint ? `。另外:${proxyHint}` : ''}`,
1122
+ cause: e,
1123
+ });
1124
+ }
1125
+ }
1126
+ const actualPort = bound.port;
1127
+ redirectUri = `http://${config.callbackHost}:${actualPort}${config.callbackPath}`;
1128
+ const authUrl = new URL(authorizeBase);
1129
+ authUrl.searchParams.set('client_id', config.clientId);
1130
+ authUrl.searchParams.set('redirect_uri', redirectUri);
1131
+ authUrl.searchParams.set('response_type', 'code');
1132
+ authUrl.searchParams.set('scope', config.scope);
1133
+ authUrl.searchParams.set('state', state);
1134
+ authUrl.searchParams.set('code_challenge', challenge);
1135
+ authUrl.searchParams.set('code_challenge_method', 'S256');
1136
+
1137
+ /* ---- 4. 交给用户 ---- */
1138
+ // 授权网址必须**同时**以人类可读文本(走 log,JSON 模式下命令层会把它转到 stderr)
1139
+ // 和机器可读 JSON(走 onAuthorizationUrl 回调)输出 —— AI 端要拿到链接交给用户点。
1140
+ log('');
1141
+ log('请在浏览器中完成授权(设备码/回调模式都是同一条授权页;未登录会先让你登录):');
1142
+ log('');
1143
+ log(` ${authUrl.toString()}`);
1144
+ log('');
1145
+ if (shouldOpenBrowser) {
1146
+ log('如果浏览器没有自动打开,请把上面这一整行复制到浏览器地址栏打开。');
1147
+ }
1148
+ /**
1149
+ * ★ 2026-10-02(老板):授权成功后**那个页面会自己关掉** —— 提前交代一句,
1150
+ * 否则用户会一直等一个其实已经结束的页面,或者回去找那个标签页。
1151
+ * ⚠️ "没关就手动关掉即可"**不能省**:`window.close()` 只对**脚本打开的**窗口有效,
1152
+ * 本页是用户自己点开的标签页 ⇒ 大概率关不掉,那句话就是兜底。
1153
+ * ★ 现在对**所有**路径都打(`--no-browser` / 打开成功 / 打开失败都一样)——
1154
+ * B 路线(内置浏览器)暂停后不再有"只给 AI 场景打提示"的分支。
1155
+ */
1156
+ log('(授权成功后页面会自动尝试关闭;没关就手动关掉即可。)');
1157
+
1158
+ /**
1159
+ * 自动打开浏览器:**多策略依次尝试**(策略表见文件顶部的 BROWSER_STRATEGY_TABLE)。
1160
+ *
1161
+ * ⚠️ 打开失败**绝不允许**让 login 失败:地址已经完整打印在上面(那一整行),
1162
+ * 手动复制照样能走完流程。所以连"注入的 openBrowser 抛错 / 返回 rejected Promise"
1163
+ * 都要在这里吞掉 —— 以前不 await,注入的异步实现 reject 会变成 unhandledRejection
1164
+ * 直接把进程崩掉,比"打不开浏览器"严重得多。
1165
+ *
1166
+ * ⚠️ 顺序是刻意的:先在**人类可读那一段**里打印完整网址,再尝试打开,再打印结果。
1167
+ * 这样哪怕打开很慢(多策略逐条超时,最坏十几秒),用户第一眼也已经看到网址了。
1168
+ * 代价是 `--json` 的 `awaiting_callback` 事件要等打开动作结束才发出去 —— 值得,
1169
+ * 因为 AI 端要靠事件里的 `open_browser` 判断"到底要不要催用户手动打开"。
1170
+ */
1171
+ let openStatus = {
1172
+ attempted: false,
1173
+ opened: false,
1174
+ tried: [],
1175
+ url: authUrl.toString(),
1176
+ // ★ 现在只可能因为 `--no-browser` 不尝试(`non_tty_agent_mode` 已随老板拍板走传统路线而弃用)
1177
+ skipped_reason: browserIntent.skipped_reason,
1178
+ };
1179
+ if (shouldOpenBrowser) {
1180
+ try {
1181
+ /**
1182
+ * `await` 是必须的:注入的同步实现返回 boolean(`await` 一个非 Promise 就是它本身),
1183
+ * 生产默认实现(`defaultOpenBrowserAsync`)返回上面那四个字段。
1184
+ */
1185
+ openStatus = normalizeOpenStatus(await openBrowser(authUrl.toString()), authUrl.toString());
1186
+ } catch (e) {
1187
+ // 打开浏览器**永不允许**把登录搞失败:如实记成"没打开",继续等回调
1188
+ openStatus = { attempted: true, opened: false, tried: [], url: authUrl.toString(), skipped_reason: null };
1189
+ log(`[verbose] 打开浏览器时抛错(已忽略,继续等回调):${(e && e.message) || e}`);
1190
+ }
1191
+ }
1192
+ if (!shouldOpenBrowser) {
1193
+ // ⚠️ 只有"--no-browser"才打这一行(用户主动关掉的,不该报"失败")
1194
+ if (browserIntent.skipped_reason === 'no_browser') log('(已按 --no-browser 跳过自动打开)');
1195
+ } else if (openStatus.opened) {
1196
+ // ★ 成功:明说"已经打开了",并告诉用户去那里做什么(老板最终拍板走传统路线)
1197
+ log(browserOpenedText({ kind: 'auth' }));
1198
+ } else {
1199
+ /**
1200
+ * 全部策略都失败时的提示:`browserFailedHintLines()` 统一了三条硬要求
1201
+ * —— 醒目(`⚠️`)、**自带完整 URL**、说清打开之后干什么;
1202
+ * AI 场景(非 TTY / `--json`)再多一句"客户端支持内置浏览器也可以在那里打开"的可选提示。
1203
+ * 真客户不会自己去找网址,所以这段是"自动打开失败"时唯一能救回流程的东西。
1204
+ */
1205
+ for (const line of browserFailedHintLines(authUrl.toString(), { kind: 'auth', agent: browserIntent.mode === 'agent' })) {
1206
+ log(line);
1207
+ }
1208
+ }
1209
+
1210
+ log(`等待你在浏览器里完成授权…(最多 ${Math.round(config.loginTimeoutMs / 1000)} 秒,Ctrl+C 可取消)`);
1211
+ if (typeof deps.onAuthorizationUrl === 'function') {
1212
+ deps.onAuthorizationUrl(authUrl.toString(), {
1213
+ redirect_uri: redirectUri,
1214
+ timeout_ms: config.loginTimeoutMs,
1215
+ scope: config.scope,
1216
+ client_id: config.clientId,
1217
+ /**
1218
+ * ★ 机器可读的"自动打开"状态:AI 端据此判断"到底要不要催用户手动打开"。
1219
+ * `authorization_url` 仍然照旧单独给(绝不能因为多了这个字段就把它挤掉)。
1220
+ * ★ 老板最终拍板走**传统路线**:CLI 默认**总是尝试**打开系统浏览器(AI 场景也一样),
1221
+ * 所以 `skipped_reason` 现在只有两种取值:
1222
+ * * `null` = 真的尝试过(成没成看 `opened`);
1223
+ * * `"no_browser"` = 调用方/用户明确要求别打开(唯一开关)。
1224
+ * (历史取值 `"non_tty_agent_mode"` 已弃用,见 resolveBrowserIntent 的注释。)
1225
+ */
1226
+ open_browser: openStatus,
1227
+ /** ★ 给 AI 端"照着念"的一句话(AI 不该自己组织语言,否则容易漏掉网址) */
1228
+ paste_text: browserPasteText(authUrl.toString(), { kind: 'auth' }),
1229
+ });
1230
+ }
1231
+
1232
+ timer = setTimeout(() => {
1233
+ /**
1234
+ * ⚠️ 超时是**老板报的那个故障的现场**:用户在同意页点了「同意」,终端却一直等。
1235
+ * 最常见的原因就是"代理把 127.0.0.1 的回调也接管了"(code 从没打到本地服务)。
1236
+ * 所以检测到代理时,hint 里必须带上"三选一"的可照做指引(第 3 条是设备码模式)。
1237
+ * (⚠️ 注意:有代理时命令层通常**已经**走设备码了,根本走不到这里;
1238
+ * 能走到这里的情况是"用户在代理下强制回调"或"探测没发现代理但回调仍被别的
1239
+ * 东西挡了 —— 防火墙/安全软件/浏览器插件"。)
1240
+ * ★ 传了 `handoff` 时**不再抛错**:改成通知命令层"并发去跑设备码",本地监听**保持开着**
1241
+ * (用户随后才点的那次【同意】照样能成功;见 DEFAULT_CALLBACK_FIRST_WAIT_MS 的真机证据)。
1242
+ */
1243
+ if (canHandoff && !deviceDone) {
1244
+ handOff('callback_timeout');
1245
+ return;
1246
+ }
1247
+ finish(new CliError(`等待授权超时(${config.loginTimeoutMs}ms)`, {
1248
+ kind: 'timeout',
1249
+ hint:
1250
+ '授权码本身只有 5 分钟有效期。请重新运行 `wkgeo login`(浏览器里通常还有登录态,点一下同意即可)' +
1251
+ (proxyHint ? `。另外:${proxyHint}` : ''),
1252
+ exitCode: EXIT.AUTH,
1253
+ }));
1254
+ }, config.loginTimeoutMs);
1255
+ if (timer && typeof timer.unref === 'function') timer.unref();
1256
+
1257
+ /**
1258
+ * ★ 第二条保险:**首轮等待**(默认 180 秒,且只由命令层在"检测到代理"时给非 0 值)没等到回调
1259
+ * → 主动结束这一轮等待,让命令层自动改用设备码继续(`kind = 'callback_first_wait'`,**不是** `timeout`)。
1260
+ *
1261
+ * 为什么不是在这里直接改走设备码:设备码流程在 `device.mjs` / `commands/auth.mjs`,
1262
+ * 而 `runLogin` 的契约是"要么拿到凭据、要么抛一个说明原因的错误"。
1263
+ * 由命令层统一裁决"这个错要不要降级"更简单,也保证 `finally` 里把本地 server 关干净
1264
+ * (否则 53682 端口会被自己占着,回落到回调时反而起不来)。
1265
+ * ⚠️ **2026-10-03 起**:命令层传了 `deps.handoff` 时,上面这条"关干净"的口径变了 ——
1266
+ * 到点**只通知、不关监听**,因为关掉监听正是真机上 ERR_CONNECTION_REFUSED 的成因。
1267
+ *
1268
+ * ⚠️ 只在 `0 < firstWait < loginTimeoutMs` 时挂:否则先撞到的必然是整段超时,
1269
+ * 挂上去只会让两个定时器互相打乱,也让错误信息变得难以理解。
1270
+ */
1271
+ const firstWaitMs = Number(deps.callbackFirstWaitMs ?? DEFAULT_CALLBACK_FIRST_WAIT_MS);
1272
+ if (Number.isFinite(firstWaitMs) && firstWaitMs > 0 && firstWaitMs < config.loginTimeoutMs) {
1273
+ const label = firstWaitMs >= 1000 ? `${Math.round(firstWaitMs / 1000)} 秒` : `${firstWaitMs} 毫秒`;
1274
+ // 提前告知"不用干等",并说清"监听还会开着"(后者正是本次修复:用户之后点同意也来得及)
1275
+ log(`(如果 ${label}内没收到回调,会自动改用设备码模式继续,不用一直干等;本地回调监听也会继续保留)`);
1276
+ firstWaitTimer = setTimeout(() => {
1277
+ if (canHandoff && !deviceDone) {
1278
+ handOff('callback_first_wait');
1279
+ return;
1280
+ }
1281
+ finish(new CliError(`等待授权回调的前 ${label}没有收到结果`, {
1282
+ kind: 'callback_first_wait',
1283
+ hint:
1284
+ '本地回调在这段时间内没有收到结果;命令层会自动改用设备码模式继续(不需要本地回调,授权页照旧点同意即可)' +
1285
+ (proxyHint ? `。另外:${proxyHint}` : ''),
1286
+ exitCode: EXIT.AUTH,
1287
+ }));
1288
+ }, firstWaitMs);
1289
+ // 与整段超时一致:unref 掉,避免"登录早就结束了,进程还被定时器吊着"
1290
+ if (firstWaitTimer && typeof firstWaitTimer.unref === 'function') firstWaitTimer.unref();
1291
+ }
1292
+
1293
+ // Ctrl+C 时把监听端口关掉再退(否则 53682 会短暂占用)
1294
+ const onSigint = () => {
1295
+ // 令牌已经拿到(设备码那条路先成功)时,Ctrl+C 不该把它判成失败:照旧把结果交出去
1296
+ if (deviceDone) {
1297
+ finish(null, deviceResult);
1298
+ return;
1299
+ }
1300
+ finish(new CliError('用户中断了登录', { kind: 'aborted', exitCode: 130 }));
1301
+ };
1302
+ process.once('SIGINT', onSigint);
1303
+
1304
+ try {
1305
+ const result = await flow;
1306
+ return {
1307
+ ...result,
1308
+ redirectUri,
1309
+ tokenTtlSeconds: ttlSec,
1310
+ onTokenExpiry: onExpiry,
1311
+ metadata,
1312
+ };
1313
+ } finally {
1314
+ process.removeListener('SIGINT', onSigint);
1315
+ if (deviceDone && postGraceMs > 0) {
1316
+ /**
1317
+ * ★★ 设备码先成功时**不马上关监听**(见 DEFAULT_POST_DEVICE_GRACE_MS):
1318
+ * 用户可能还在那个授权页上,他之后点【同意】时不该看到 ERR_CONNECTION_REFUSED。
1319
+ * ⚠️ 这里刻意**不 await**:调用方要立刻拿到结果去打印"授权成功";
1320
+ * 监听与 grace 定时器在后台再活一小会儿,到点(或被那次迟到的回调提前唤醒)自行关闭。
1321
+ * 定时器 unref 掉不吃事件循环 —— 让进程活下来的是 server 本身,关掉它进程就退出了。
1322
+ */
1323
+ graceTimer = setTimeout(() => {
1324
+ graceTimer = null;
1325
+ closeServer();
1326
+ }, postGraceMs);
1327
+ if (graceTimer && typeof graceTimer.unref === 'function') graceTimer.unref();
1328
+ } else {
1329
+ await closeServer();
1330
+ }
1331
+ }
1332
+ }