wkgeo-cli 0.2.1 → 0.2.2

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/README.md CHANGED
@@ -5,6 +5,8 @@
5
5
 
6
6
  * 技术形态:Node.js,**零第三方依赖**(只用 Node 内置模块),Node ≥ 20
7
7
  * 授权方式:OAuth 授权码 + **PKCE**(公共客户端,**代码里没有任何 client_secret**)
8
+ * 默认授权:**浏览器打开授权页 + 本地 `127.0.0.1` 回调**(等多久都不会自动改成设备码);设备码只在
9
+ 显式 `--device`、检测到系统代理、或本地回调端口真的起不来时兜底(见 §3.2)
8
10
  * 另一种授权:**设备码**(`login --device`,不需要回调地址,**对代理/防火墙免疫**;见 §3.2)
9
11
  * ⭐ **开着代理也能成功**:**只要检测到系统代理就直接改用设备码**(不看代理绕过列表),用户什么都不用做(见 §3.4)
10
12
  * 输出:人类可读 + **稳定 JSON**(默认非终端环境自动 JSON),失败也是结构化 JSON
@@ -143,10 +145,16 @@ wkgeo login
143
145
 
144
146
  于是"点同意"这件事**只需要 30 天做一次**(之后令牌过期或被吊销才需要重来)。
145
147
 
148
+ > ★ **0.2.2 起:默认就是这条本地回调路径**,`wkgeo login` **不会**因为"等了一会儿"
149
+ > 自动变成设备码。设备码只在三种情况出现:① 显式 `--device`;② 检测到系统代理
150
+ > (自动改道,`--no-auto-device` 可关,见 §3.4);③ 本地回调端口**真的起不来**
151
+ > (自动兜底,`--no-auto-device` 可关)。整段等待超时只报 `kind:"timeout"`,不再自动改道。
152
+
146
153
  选项:
147
154
 
148
155
  | 选项 | 说明 |
149
156
  | --- | --- |
157
+ | `--device` | 用**设备码模式**(RFC 8628):不需要本地回调地址/端口;本地读不了回调时的兜底(见 §3.2) |
150
158
  | `--no-browser` | **完全不尝试**自动打开浏览器(只打印网址,适合远程/无 GUI、自动化;★ 这是**唯一**的"不打开"开关) |
151
159
  | `--open-browser` | 强制尝试打开系统浏览器(⚠️ 现在与**默认行为等价** —— 默认就会尝试打开;保留只为显式语义与兼容,见下) |
152
160
  | `--scope "site:read site:write billing:self"` | 申请的能力范围(默认就是这三个,够建站+下单+支付) |
@@ -183,7 +191,12 @@ wkgeo login
183
191
  > (服务端只负责原样回传,防 CSRF 是客户端自己的责任)。
184
192
  > 端口被占用会自动换随机端口 —— 服务端按 RFC 8252 对回环地址**忽略端口**比对。
185
193
 
186
- ### 3.2 设备码模式(`--device`)—— **代理/防火墙环境的推荐路径**
194
+ ### 3.2 设备码模式(`--device`)—— **本地读不了回调 / 代理 / 远程机器时的兜底**
195
+
196
+ > ★ **默认不是设备码**:`wkgeo login` 默认走浏览器回调(§3.1)。设备码只在:
197
+ > ① 显式 `--device` / `--no-wait` / `--device-continue`;② 检测到系统代理(CLI 自动,§3.4);
198
+ > ③ 本地回调端口真的起不来(CLI 自动兜底,`reason:"callback_listen_failed"`)。
199
+ > **等待超时不会**自动改设备码(0.2.2 起)。
187
200
 
188
201
  ```bash
189
202
  wkgeo login --device # 申请短码 → 打印网址/短码 → 轮询到出结果(人盯着终端时用这条)
@@ -280,7 +293,7 @@ wkgeo login --device-continue --json
280
293
  多数代理默认绕过回环地址,但**全局/系统代理 + 绕过列表为空**时,浏览器会把这条回环请求
281
294
  也交给代理 → CLI 的本地服务永远收不到 `code`。**问题在浏览器那一侧,不在 CLI 出网**。
282
295
 
283
- **CLI 的处理(2026-10-02 第二版加固)**:
296
+ **CLI 的处理(2026-10-02 第二版加固;2026-10-21 默认回调调整)**:
284
297
 
285
298
  1. **检测**(只读,绝不改你的代理设置):Windows 读注册表 `…\Internet Settings` 的
286
299
  `ProxyEnable`/`ProxyServer`/`ProxyOverride`/`AutoConfigURL`;macOS 读 `scutil --proxy`;
@@ -300,27 +313,25 @@ wkgeo login --device-continue --json
300
313
  (检测详情:源=windows-registry 代理=127.0.0.1:7890 绕过列表=localhost,127.*,<local>;
301
314
  ⚠️ 绕过列表里就算写了 127.*/localhost 也不再作为放行回调的依据 —— 真机上出现过"写了却仍被代理接管"。)
302
315
  ```
303
- 4. ⭐ **回调模式"等太久"能自愈(第二条保险)**:★ **只在检测到系统代理时才启用** ——
304
- 有代理时首轮等待默认 **180 秒**(`--callback-first-wait 90` 或
305
- `WKONGAI_CALLBACK_FIRST_WAIT_MS=90000` 可显式指定,`0`/`off` 关闭):
306
- 这段时间没等到回调就**自动改用设备码继续**(不退出),不用再干等整段 `--timeout`
307
- (默认 300 秒,可到 900 秒)。
308
- ⚠️ **2026-10-03 修了两处**(老板真机反馈):
309
- * **没有代理时不再自愈**。以前它**无条件**挂在 60 秒上,而"用户把授权页读完再点同意"
310
- 经常超过 60 秒 ⇒ 明明没开任何代理也被切成设备码(原话:「现在变成设备码了,
311
- 我们因为没有开任何VPN,所以这个是不对的。」)。现在没代理就老老实实等满 `--timeout`,
312
- 只有你自己写了 `--callback-first-wait` 才按你写的来(**显式永远优先**)。
313
- * **切换设备码时不再关掉本地回调监听**:两条路**并存**、**哪条先成功就用哪条**。
314
- 旧版在切换那一刻就把监听关了,于是你**之后**才点【同意授权】时浏览器只会显示
315
- **ERR_CONNECTION_REFUSED**("无法访问此网站")——你会以为授权失败,其实马上就成功了。
316
- 现在那次点击照样能完成登录;若设备码先成功,之后到达的回调会看到一张
317
- 「本次登录已经完成」的页面。
318
- 本地回环端口**起不来**(防火墙/安全软件)时同样自动降级;
316
+ 4. ⭐ **等待超时不再自动改设备码(0.2.2,老板 2026-10-21 反馈)**:
317
+ 老板原话「默认的也不应该是设备授权,默认的就是那个传统的授权方式(浏览器打开授权页 +
318
+ 本地 localhost 回调)。设备码是本地读不了/代理时才用的。」
319
+ ⇒ 没有代理时 `login` 会**一路等回调到 `--timeout`**(默认 300 秒);超时只报
320
+ `kind:"timeout"`,并在 `hint` 里给出"显式改用 `--device`"的下一步,**不会**偷偷改道。
321
+ ⚠️ 历史行为(0.2.1 及更早):有代理时"首轮等待 180 秒"到点会改设备码、整段超时也会改。
322
+ 现在这个"首轮等待"**默认关闭**:只有你**显式**写 `--callback-first-wait 90` 或
323
+ `WKONGAI_CALLBACK_FIRST_WAIT_MS=90000` 才启用(**显式优先**,`reason:"callback_first_wait"`)。
324
+ 显式启用时仍然是**两条路并存**:本地回调监听**继续保留**,**哪条先成功就用哪条** ——
325
+ 你之后才点【同意授权】照样能完成登录。旧版在切换那一刻就把监听关了,于是你之后点同意
326
+ 浏览器只会显示 **ERR_CONNECTION_REFUSED**("无法访问此网站",2026-10-03 已修);
327
+ 若设备码先成功,之后到达的回调会看到一张「本次登录已经完成」的页面。
328
+ 5. ⭐ **本地回调端口真的起不来**(防火墙/安全软件/端口非法)→ 自动兜底设备码
329
+ (`reason:"callback_listen_failed"`),**不会**直接失败退出;
319
330
  **换票失败、用户点了拒绝这类"已经走到下一步"的错误不会降级**(避免让你白做一遍)。
320
- 5. **不想被自动改道**:加 `--no-auto-device`,或设 `WKONGAI_AUTO_DEVICE=0`。
331
+ 6. **不想被自动改道**:加 `--no-auto-device`,或设 `WKONGAI_AUTO_DEVICE=0`。
321
332
  ⚠️ 此时 CLI 会**明确警告**"若代理会接管 127.0.0.1,点了【同意授权】也收不到结果",
322
- 并且**关掉**第 4 条的自愈(你要求只走回调,就不会背着你偷偷改道)。
323
- 6. **排障入口**:`wkgeo config` 会直接打印本机代理情况与结论(`--json` 里是
333
+ 并且**关掉**第 4 条的自愈与第 5 条的端口兜底(你要求只走回调,就不会背着你偷偷改道)。
334
+ 7. **排障入口**:`wkgeo config` 会直接打印本机代理情况与结论(`--json` 里是
324
335
  `data.proxy` / `meta.proxy_warning`),支持同学让用户跑这一条就能判断。
325
336
 
326
337
  `--json` 里的结构化字段(AI 端据此判断"是不是代理的锅、该让用户做什么"):
@@ -346,6 +357,13 @@ wkgeo login --device-continue --json
346
357
  失败时的 `error.hint` 里也会带上同样的三选一(例如回调超时的 JSON 错误信封里,
347
358
  `meta.proxy_warning` 依然在)。
348
359
 
360
+ > ★ **0.2.2 兼容字段**:`data.auto_device` 形状**不变**(`{applied, reason}`),`mode` 仍是
361
+ > `"callback"` / `"device"`,**没有删除任何既有字段**。`reason` 现在只会是:
362
+ > `"proxy_detected"`(检测到代理)/ `"callback_listen_failed"`(本地端口起不来)/
363
+ > `"callback_first_wait"`(仅用户显式 `--callback-first-wait` 时)/ `null`。
364
+ > 历史取值 `"callback_timeout"` **不再产生**(0.2.2 起超时不改道;老脚本把它当"回调失败后的
365
+ > 设备码兜底"读到的语义不变)。
366
+
349
367
  > ⚠️ 两个容易误读的点:
350
368
  > 1. **CLI 自己的出网不受系统代理影响**:Node 的 fetch(undici)默认既不读系统代理、
351
369
  > 也不读 `HTTP_PROXY`(要 Node ≥ 24 的 `--use-env-proxy` / `NODE_USE_ENV_PROXY=1`)。
@@ -364,7 +382,7 @@ wkgeo login --device-continue --json
364
382
 
365
383
  | 命令 | 作用 | 关键参数 |
366
384
  | --- | --- | --- |
367
- | `login` | 授权一次,令牌存本地 | `--device`、`--no-wait`、`--device-continue`、`--auto-device`/`--no-auto-device`、`--callback-first-wait <秒>`、`--no-browser`、`--open-browser`、`--scope`、`--callback-port`、`--timeout <秒>` |
385
+ | `login` | 授权一次,令牌存本地(**默认浏览器回调**;设备码仅兜底) | `--device`、`--no-wait`、`--device-continue`、`--auto-device`/`--no-auto-device`、`--callback-first-wait <秒>`(可选,默认关闭)、`--no-browser`、`--open-browser`、`--scope`、`--callback-port`、`--timeout <秒>` |
368
386
  | `whoami` | 当前登录的是谁(账号标识) | `--verbose`(额外显示令牌**掩码**与文件权限) |
369
387
  | `logout` | 删本地凭据(默认还调服务端吊销) | `--local-only` |
370
388
  | `config` | 显示生效配置与登录状态(无令牌) | — |
@@ -708,7 +726,7 @@ node tencent/functions/_tests/oauth-device-live-e2e.js # 结
708
726
  | `WKONGAI_CREDENTIALS` | `~/.wkongai/credentials.json` | 凭据文件路径 |
709
727
  | `WKONGAI_CALLBACK_PORT` | `53682` | 本地回调端口 |
710
728
  | `WKONGAI_LOGIN_TIMEOUT_MS` | `300000` | 等待授权超时(⚠️ 变量名里带 `MS`,所以这里填**毫秒**;命令行 `--timeout` 填的是**秒**) |
711
- | `WKONGAI_CALLBACK_FIRST_WAIT_MS` | 未设置 | ★ 回调模式的**首轮等待**(毫秒):显式设了它(或命令行 `--callback-first-wait`)这段时间没等到回调就自动改用设备码继续(**本地回调监听会同时保留**);`0` 关闭(等价于 `--callback-first-wait 0`)。⚠️ **未设置时看代理**:检测到系统代理才用默认值 **180000**(180 秒),**没有代理则不启用**(这是 2026-10-03 修掉的"没开 VPN 也被切成设备码") |
729
+ | `WKONGAI_CALLBACK_FIRST_WAIT_MS` | 未设置 | ★ **可选**的"首轮等待"(毫秒,**默认关闭**):只有显式设了它(或命令行 `--callback-first-wait`),这段时间没等到回调才会自动改用设备码继续(**本地回调监听会同时保留**);`0` 关闭(等价于 `--callback-first-wait 0`)。⚠️ **未设置时一律不启用**(0.2.2 起:等待超时不再自动改设备码;老板 2026-10-21:「默认的就是那个传统的授权方式」) |
712
730
  | `WKONGAI_AUTO_DEVICE` | `1`(开) | 检测到系统代理时是否自动改用设备码;设 `0`/`false`/`no`/`off` 关闭(等价于 `--no-auto-device`,会打印"代理可能吃掉回调"的警告) |
713
731
  | `NO_COLOR` | — | 设了就不上色 |
714
732
 
@@ -719,7 +737,7 @@ node tencent/functions/_tests/oauth-device-live-e2e.js # 结
719
737
  | # | 项 | 现状 | 谁来处理 |
720
738
  | --- | --- | --- | --- |
721
739
  | 1 | **设备码端点** | ✅ **已上线并端到端真跑通过**(2026-10-02:metadata 声明 + 申请/确认/换票/重放全绿,真 CLI 子进程 `stage:"done"`)。协议细节见 `docs/设备码授权-服务端最小方案.md`(该文档已更新为"已实现") | 已完成;回归用 `_tests/oauth-device-live-e2e.js` |
722
- | 1b | **代理自动降级 / 首轮等待自愈** | ✅ 已实现(**检测到代理即改设备码**,不再相信绕过列表 + 整段超时/端口失败自动改道);2026-10-02 **真机翻车已复现并加固**(老板机器:绕过列表写了 `127.*` 却仍被代理接管,证据 `curl -x …7890 http://127.0.0.1:1/x` → 502)。★ 2026-10-03 **真机反馈已修**:① 首轮等待自愈**只在检测到代理时启用**(无代理不再误切设备码)、默认 180 秒、显式 `--callback-first-wait` 永远优先;② 切设备码时**保留本地回调监听**,两条路并存先到先得(旧版关掉监听 ⇒ 用户之后点【同意授权】得到 ERR_CONNECTION_REFUSED) | 已完成(单测 ①~⑤ 含鉴别力自证);建议老板在**没开代理**的机器上跑一次 `wkgeo login` 验收"不再自动变成设备码" |
740
+ | 1b | **代理自动降级 / 默认回调** | ✅ 已实现(**检测到代理即改设备码**,不再相信绕过列表;本地回调端口起不来自动兜底)。2026-10-02 **真机翻车已复现并加固**(老板机器:绕过列表写了 `127.*` 却仍被代理接管,证据 `curl -x …7890 http://127.0.0.1:1/x` → 502)。★ 2026-10-21 **默认模式已改回传统回调**(0.2.2):**等待超时不再自动改设备码**;设备码只由①显式 `--device`②检测到代理③本地回调端口真的起不来触发;`--callback-first-wait` 变成**默认关闭**的显式可选自愈;切设备码时**保留本地回调监听**,两条路并存先到先得(旧版关掉监听 ⇒ 用户之后点【同意授权】得到 ERR_CONNECTION_REFUSED) | 已完成(单测:默认 callback / `--device` 强制 / 端口失败兜底 / `--no-auto-device` 不兜底 / 超时不改道);建议老板真机跑一次 `wkgeo login` 验收"默认就是浏览器回调" |
723
741
  | 1c | **设备码页"没有完成态/不自动关"** | ⚠️ 老板 2026-10-02 反馈:点完设备码页的【同意授权】后页面**不给明确的完成态、也不自动关**,用户不知道要不要继续等。**并行改动**已在 `admin-web/src/pages/device/index.tsx` 补上终态卡片(`✅ 授权成功` + "本页面可以关闭了" + 返回首页)。本轮 CLI 侧**未改前端** | 见下面 §9.1 的补充建议(可选) |
724
742
  | 2 | **错误码 40101/40102** | `/api/data` **已返回 40102**(实测"访问令牌已被吊销,请重新授权");但 `userinfo` 仍是标准 `401 invalid_token`(靠文案区分)。CLI 两条路都兼容 | 服务端(可选,统一到 40101/40102) |
725
743
  | 3 | **`listMyOAuthAuthorizations.token_count` 虚高** | 该 SQL 用 `count(*)` 统计 `left join lateral unnest(t.scopes)` 后的行数 → **数的是 scope 个数,不是令牌数**(实测:1 把令牌、3 个 scope → 显示"3 把")。正确写法 `count(distinct t.access_token_hash)` | **主线程/服务端** |
package/SKILL.md CHANGED
@@ -107,7 +107,7 @@ npx -y wkgeo-cli@latest skill > SKILL.md # 直接落一份到本地,离线
107
107
 
108
108
  | 命令 | 作用 | 关键参数 |
109
109
  | --- | --- | --- |
110
- | `login` | 授权一次(令牌存本地 30 天) | `--device`、`--no-wait`、`--device-continue`、`--no-auto-device`、`--callback-first-wait <秒>`、`--no-browser`、`--open-browser`、`--scope`、`--timeout <秒>` |
110
+ | `login` | 授权一次(令牌存本地 30 天;**默认浏览器回调**,设备码仅兜底) | `--device`、`--no-wait`、`--device-continue`、`--no-auto-device`、`--callback-first-wait <秒>`(可选,默认关闭)、`--no-browser`、`--open-browser`、`--scope`、`--timeout <秒>` |
111
111
  | `whoami` | 当前是谁(账号标识) | `--verbose` |
112
112
  | `logout` | 退出(删本地凭据 + 服务端吊销) | `--local-only` |
113
113
  | `config` | 生效配置 / 登录状态 | — |
@@ -209,6 +209,10 @@ npx -y wkgeo-cli@latest login --json --timeout 500ms # 也可以显式写毫
209
209
 
210
210
  它会**阻塞**(默认最多 5 分钟)并输出 JSONL:
211
211
 
212
+ > ★ **0.2.2 起:默认就是这条回调**,等多久都**不会**自动改成设备码;整段超时只报
213
+ > `kind:"timeout"`(并在 hint 里给出"显式改用 `--device`"的下一步)。设备码只在
214
+ > ① 显式 `--device` ② 检测到系统代理 ③ 本地回调端口真的起不来 时出现(见 §5.3 / §5.4)。
215
+
212
216
  ```jsonc
213
217
  // 第一行 —— 拿到它就应该**立刻**把网址交给用户(不要等命令结束!)
214
218
  // ⚠️ `authorize/` 的**尾斜杠不能丢**:它是托管侧 302(会丢掉全部 query)的分界线
@@ -264,7 +268,14 @@ npx -y wkgeo-cli@latest login --json --timeout 500ms # 也可以显式写毫
264
268
  * 用户也可以在网页「我已授权的应用」里一键吊销(吊销后 CLI 会收到 40102)。
265
269
  * 🔒 不要把这个文件内容读出来放进任何输出/日志/第三方请求里。
266
270
 
267
- ### 5.3 设备码模式(`login --device`)—— **代理/远程机器的首选**
271
+ ### 5.3 设备码模式(`login --device`)—— **本地读不了回调 / 代理 / 远程机器时的兜底**
272
+
273
+ > ★ **默认不是设备码**:`wkgeo login` 默认走浏览器回调(授权页 + 本地 `127.0.0.1` 回调,见 §5.1)。
274
+ > 只有这几种情况才用设备码:
275
+ > 1. 用户**显式** `--device` / `--no-wait` / `--device-continue`;
276
+ > 2. **检测到系统代理**(CLI 自动改道,见 §5.4;`--no-auto-device` 可关);
277
+ > 3. 本地回调端口**真的起不来**(CLI 自动兜底,`reason:"callback_listen_failed"`;`--no-auto-device` 可关)。
278
+ > **等待超时不会**自动改设备码(0.2.2 起)。
268
279
 
269
280
  **什么时候用它**(不用问用户,直接选它就行):
270
281
 
@@ -337,7 +348,7 @@ npx -y wkgeo-cli@latest login --device-continue --json # 用户说"点好
337
348
  **机理**:回调要求**浏览器**访问 `http://127.0.0.1:<端口>/callback?code=…`;
338
349
  全局/系统代理若没把回环地址排除,这条请求会被代理接管 → CLI 收不到 code。
339
350
 
340
- **CLI 已经自己处理好了**(2026-10-02 第二版加固,**默认开**):
351
+ **CLI 已经自己处理好了**(2026-10-02 第二版加固 + 2026-10-21 默认模式调整):
341
352
 
342
353
  * ★ **只要检测到"生效中的系统代理"就直接改用设备码模式**(事件流里第一行就是
343
354
  `stage:"awaiting_user"`,而不是 `awaiting_callback`);`data.auto_device.reason = "proxy_detected"`。
@@ -346,22 +357,22 @@ npx -y wkgeo-cli@latest login --device-continue --json # 用户说"点好
346
357
  证据 `curl -x http://127.0.0.1:7890 http://127.0.0.1:1/x` → 502)。
347
358
  `proxy_warning.loopback_bypassed` / `risk` 现在**只是诊断字段**,不再代表"回调没问题";
348
359
  `proxy_warning.loopback_bypass_trusted` 恒为 `false`。
349
- * ★ **回调模式首轮等待**(★ **只在检测到系统代理时才启用**,默认 **180 秒**;
350
- `--callback-first-wait <秒>` / `WKONGAI_CALLBACK_FIRST_WAIT_MS` 可显式指定,
351
- `0`/`off` 关闭)没等到回调 → **自动改用设备码继续**(`reason: "callback_first_wait"`),
352
- 用户不用再干等整段 `--timeout`;
353
- ⚠️ **2026-10-03 修**:以前它**无条件**挂在 60 秒上,于是"用户读完授权页再点同意"经常超时,
354
- 明明没开代理也被切成设备码(老板真机原话:「现在变成设备码了,我们因为没有开任何VPN,
355
- 所以这个是不对的。」)。现在:**没有代理 → 不启用**(老实等满 `--timeout`),
356
- 用户显式指定时才照旧生效;`data.auto_device.reason == "callback_first_wait"`
357
- **只可能**出现在有代理的机器上。
358
- ⚠️ **切换时不再关掉本地回调监听**(同一次修复):两条路**并存**,哪条先成功就用哪条 ——
360
+ * ★★ **0.2.2 起:等待超时不再自动改设备码**(老板 2026-10-21 反馈):
361
+ 老板原话「默认的也不应该是设备授权,默认的就是那个传统的授权方式(浏览器打开授权页 +
362
+ 本地 localhost 回调)。设备码是本地读不了/代理时才用的。」
363
+ ⇒ 没有代理时 `login` 会**一路等回调到 `--timeout`**(默认 300 秒),超时只报
364
+ `kind:"timeout"`,并在 `hint` 里给出"显式改用 `--device`"的下一步。
365
+ ⚠️ 历史行为(0.2.1 及更早):有代理时"首轮等待 180 秒"到点会改设备码、整段超时也会改;
366
+ 现在那个"首轮等待"**默认关闭**,只有用户**显式**写 `--callback-first-wait <秒>` /
367
+ `WKONGAI_CALLBACK_FIRST_WAIT_MS` 才启用(显式优先,`reason:"callback_first_wait"`)。
368
+ 显式启用时仍然是**两条路并存**:本地回调监听继续保留,哪条先成功就用哪条 ——
359
369
  用户之后才点的那次【同意授权】照样能完成登录;旧逻辑在切换那一刻关了监听,用户随后点同意
360
- 只会看到 **ERR_CONNECTION_REFUSED**(真机截图见过的"无法访问此网站")。
370
+ 只会看到 **ERR_CONNECTION_REFUSED**(真机截图见过的"无法访问此网站",2026-10-03 已修)。
361
371
  若设备码先成功,之后到达的回调会拿到一张「本次登录已经完成」的页面(不是错误页);
362
372
  若回调先成功,设备码轮询会被停掉并说明(`ℹ️ 本地回调已经拿到令牌:设备码那条路已停止…`)。
363
- * 整段回调**超时**、或本地回环端口**起不来** → 同样自动改用设备码继续
364
- (`reason: "callback_timeout"` / `"callback_listen_failed"`),**不会**直接失败退出;
373
+ * 本地回环端口**真的起不来**(防火墙/安全软件/端口非法)→ 自动兜底设备码
374
+ (`reason: "callback_listen_failed"`),**不会**直接失败退出;
375
+ `--no-auto-device` 可关闭这个兜底(此时如实报 `kind:"listen"`)。
365
376
  * 换票失败 / 用户点了拒绝 → **不降级**(授权已经走到下一步了,别让用户白做一遍);
366
377
  * 上述情况下,`meta.proxy_warning` / `data.proxy_warning` 都会告诉你原因与建议动作:
367
378
 
@@ -384,12 +395,18 @@ npx -y wkgeo-cli@latest login --device-continue --json # 用户说"点好
384
395
  * 看到 `proxy_warning.callback_may_fail: true`:**不要**叫用户去改代理设置(很多用户不懂),
385
396
  直接按设备码流程走 —— CLI 通常已经自动切过去了;
386
397
  只有当用户明确说"我就想用浏览器回调"时,才把 `actions` 里第 2/3 条念给他;
387
- * 看到 `auto_device.applied: true`:可以在汇报里加一句
388
- 「检测到你开着代理,已自动改用设备码模式(不需要本地回调)」——如实说明,别隐瞒;
398
+ * 看到 `auto_device.applied: true`:按 `reason` 如实说明,别隐瞒:
399
+ * `"proxy_detected"` → 「检测到你开着代理,已自动改用设备码模式(不需要本地回调)」;
400
+ * `"callback_listen_failed"` → 「本地回调端口起不来,已自动改用设备码模式继续」;
401
+ * `"callback_first_wait"` → **只可能**是用户自己显式开了 `--callback-first-wait`:
402
+ 「等待到点没收到回调,已同时改用设备码(两条路哪条先成功算哪条)」。
403
+ * 没有 `auto_device`(`applied:false`)就是**默认的浏览器回调**,不要主动提设备码。
389
404
  * 想让 CLI **不要**自动改道(例如你确认回调一定通):加 `--no-auto-device`,或设
390
405
  `WKONGAI_AUTO_DEVICE=0`。⚠️ 此时 CLI 会**打印警告**说"若代理接管 127.0.0.1,
391
- 点了【同意授权】也收不到结果",并且**不会**再用首轮等待自愈 —— 尊重用户的选择,
392
- 但要**把这个风险如实转述**给他;
406
+ 点了【同意授权】也收不到结果",并且**不会**再用首轮等待自愈、端口起不来也**不兜底** ——
407
+ 尊重用户的选择,但要**把这个风险如实转述**给他;
408
+ * 用户只是**等得久**(没代理、也没报错):**不要**自己改成设备码 —— 0.2.2 起默认就是回调,
409
+ 等多久都不会自动变;要设备码必须**显式**加 `--device`(先问用户,别擅自换模式);
393
410
  * 排障:`npx -y wkgeo-cli@latest config --json` 里有 `data.proxy` 与 `meta.proxy_warning`,
394
411
  是"用户机器上是不是代理挡了回调"的**唯一权威判断**(只读检测,不会改用户任何设置)。
395
412
 
@@ -695,11 +712,11 @@ npx -y wkgeo-cli@latest publish --json
695
712
  | --- | --- | --- |
696
713
  | `whoami` 返回 exit 3 | 还没登录 / 令牌过期 / 被吊销 | 跑 `login`,把网址给用户点同意 |
697
714
  | 浏览器授权后显示"无法访问此网站" | 那一刻本机没有 CLI 在监听(login 被 Ctrl+C 或已退出);**或代理接管了 127.0.0.1** | 重新 `login`(开着代理时 CLI 会自动改设备码);并**保持它在前台**直到点完「同意」 |
698
- | `login` 等到超时(`kind:"timeout"`) | 用户没点完,**或代理把回调吃掉了** | 看 `hint` 与 `meta.proxy_warning`:只要检测到代理,CLI 默认**已经**直接改设备码了;若用户加了 `--no-auto-device`,就把"代理可能吃掉回调"的警告转述给他 |
699
- | 事件流第一行是 `awaiting_user` 而不是 `awaiting_callback` | 检测到系统代理 → **CLI 直接改用了设备码**(设计行为,不是出错;**绕过列表写了 127.* 也照样改**) | 按 §5.3 把 `paste_text` 转述给用户;想强制回调加 `--no-auto-device`(CLI 会警告风险) |
700
- | 事件流是 `awaiting_callback`,然后 `data.auto_device.reason:"callback_first_wait"` | ★ 只在**检测到代理**时才会发生:首轮等待(默认 180 秒,可 `--callback-first-wait` 指定)没收到回调 → CLI 自动改设备码继续(**本地回调监听同时保留**) | 正常自愈;如实告诉用户"已同时改用设备码,两条路哪条先成功算哪条,不用重来" |
701
- | 用户说「我**没开代理**,为什么变成设备码了」 | ★ 2026-10-03 已修:没有代理时 CLI **不再**启用首轮等待自愈(旧版默认 60 秒会误切)。若仍出现 `proxy_warning.detected:true`,那就是真的探到了系统代理(`config --json` 里能看来源) | 先看 `data.proxy_warning.detected`:为 `true` 才是代理的锅;为 `false` 却有 `reason:"callback_first_wait"` ⇒ 只可能是用户自己写了 `--callback-first-wait` |
702
- | 用户随后才点【同意授权】→ 浏览器显示 **ERR_CONNECTION_REFUSED** | ★ 2026-10-03 已修:切设备码时**不再关掉**本地回调监听(旧版一关就是这个报错)。若仍出现,说明 CLI 进程已经退出(Ctrl+C / 终端关掉了) | 让用户直接在**这一次**的浏览器页面上点同意即可(监听还在),或重跑 `login`;不要让他以为授权失败 |
715
+ | `login` 等到超时(`kind:"timeout"`) | 用户没点完,**或代理把回调吃掉了** | ★ 0.2.2 起超时**不会**自动改设备码:看 `hint` 与 `meta.proxy_warning`。若用户确认本地读不了回调,让他**显式**加 `--device` 重跑;若检测到代理,CLI 默认**已经**在第一跳直接改设备码了(除非加了 `--no-auto-device`) |
716
+ | 事件流第一行是 `awaiting_user` 而不是 `awaiting_callback` | 只有两种可能:**检测到系统代理**(CLI 直接改设备码;绕过列表写了 127.* 也照样改)或用户显式 `--device` | 按 §5.3 把 `paste_text` 转述给用户;想强制回调加 `--no-auto-device`(CLI 会警告风险) |
717
+ | 事件流是 `awaiting_callback`,然后 `data.auto_device.reason:"callback_first_wait"` | ★ **0.2.2 起只可能**是用户/调用方**显式**写了 `--callback-first-wait`(默认关闭):首轮等待没收到回调 → CLI 并发改设备码(**本地回调监听同时保留**) | 正常自愈;如实告诉用户"已同时改用设备码,两条路哪条先成功算哪条,不用重来" |
718
+ | 用户说「我**没开代理**,为什么变成设备码了」 | ★ 0.2.2 起默认就是浏览器回调,正常情况下**不会**再发生。若真出现,只可能是:① `proxy_warning.detected:true`(真探到了系统代理);② `reason:"callback_listen_failed"`(本地端口起不来);③ 用户自己显式 `--device` / `--callback-first-wait` | 按 `data.auto_device.reason` 如实解释;先看 `data.proxy_warning.detected`(`config --json` 能看来源) |
719
+ | 用户随后才点【同意授权】→ 浏览器显示 **ERR_CONNECTION_REFUSED** | ★ 2026-10-03 已修:显式首轮等待切设备码时**不再关掉**本地回调监听(旧版一关就是这个报错)。若仍出现,说明 CLI 进程已经退出(Ctrl+C / 终端关掉了) | 让用户直接在**这一次**的浏览器页面上点同意即可(监听还在),或重跑 `login`;不要让他以为授权失败 |
703
720
  | `login --device-continue` 报 `kind:"expired"` | 短码过期(默认 10 分钟)或已被换过票 | 重新 `login --device --no-wait` 申请新短码;**别**反复续跑 |
704
721
  | 下单直接返回 `paid:true` | 服务端设计:下单时余额足够就直接扣款并履约 | 这是正常的"钱包支付";用 `wallet` 复核余额 |
705
722
  | `sites create` 报 400「无法确定所属客户」 | `owner/admin` 这类账号必须显式指定客户 | 加 `--customer <客户ID>` |
@@ -5,7 +5,7 @@
5
5
  * 需要用户做的事(同意授权)表现为:打印网址 + 阻塞等待 + 超时给明确错误。
6
6
  */
7
7
 
8
- import { runLogin, DEFAULT_CALLBACK_FIRST_WAIT_MS } from '../login.mjs';
8
+ import { runLogin } from '../login.mjs';
9
9
  import { requestDeviceCode, announceDeviceCode, pollDeviceToken, devicePasteText } from '../device.mjs';
10
10
  import {
11
11
  pendingDeviceFile,
@@ -62,34 +62,36 @@ export function resolveAutoDevice(flags = {}, env = {}) {
62
62
  }
63
63
 
64
64
  /**
65
- * 「回调模式首轮等待」窗口(毫秒)的解析 —— ★ 第二条保险(见 login.mjs 的同名注释)。
65
+ * 「回调模式首轮等待」窗口(毫秒)的解析 —— **可选自愈,默认关闭**。
66
66
  *
67
- * ## ⚠️⚠️ 2026-10-03 老板真机反馈后改了触发条件(这是本次修复的第一条)
68
- * 旧逻辑:**无条件**返回默认 60 秒 ⇒ 用户机器上**没有任何代理/VPN**时也会自愈。
69
- * 而"把授权页送到用户面前、他读完再点【同意】"经常超过 60 秒 ⇒ 明明不需要设备码也切了过去。
70
- * 老板原话:「现在变成设备码了,我们因为没有开任何VPN,所以这个是不对的。」
71
- * ⇒ 现在:**只有 `proxyDetected === true`(真检测到生效中的代理)才用默认值启用**;
72
- * **没有代理时不启用**(返回 0,老老实实等满 `--timeout`)。
67
+ * ## ★★ 0.2.2(老板 2026-10-21 反馈):默认不再自动改设备码
68
+ * 老板原话:「默认的也不应该是设备授权,默认的就是那个传统的授权方式(浏览器打开授权页 +
69
+ * 本地 localhost 回调)。设备码是本地读不了/代理时才用的。」
70
+ * ⇒ 本函数**不再**按"有没有代理"给默认值:用户没显式指定时**一律返回 0**。
71
+ * 也就是说 `wkgeo login` 默认会一路等回调到 `--timeout`,**不会**因为等太久自动改设备码。
73
72
  *
74
- * ## 优先级(显式 > 自动判断)
75
- * `--callback-first-wait <时长>`(默认单位**秒**;`0` / `off` / `none` = 关掉自愈)
73
+ * 设备码只在这三种情况出现(见 loginCommand.run):
74
+ * 1. 用户显式 `--device` / `--no-wait` / `--device-continue`;
75
+ * 2. 检测到系统代理且判定回调不可用(`--no-auto-device` 可关);
76
+ * 3. 本地回调端口**真的起不来**(`kind = 'listen'`,真实失败兜底;`--no-auto-device` 可关)。
77
+ *
78
+ * ## 保留的显式开关(向后兼容,不参与默认判断)
79
+ * `--callback-first-wait <时长>`(默认单位**秒**;`0` / `off` / `none` = 关闭)
76
80
  * > `WKONGAI_CALLBACK_FIRST_WAIT_MS`(毫秒)
77
- * > **有代理**时的 `DEFAULT_CALLBACK_FIRST_WAIT_MS`(180 秒)/ **无代理**时的 0
78
- * ⇒ 用户**显式**指定时一律照旧尊重(哪怕没有代理)—— 显式指定优先于自动判断。
81
+ * > 默认 **0(关闭)**
82
+ * ⇒ 只有用户**显式**写了它,才会在首轮等待到点后并发启动设备码(两条路先到先得)。
83
+ * 显式指定是用户的明确选择,我们照旧尊重(但他不写就绝不会自动发生)。
79
84
  *
80
85
  * ⚠️ `--no-auto-device`(autoDevice=false)时**强制返回 0**:用户已经明确要求"只走回调",
81
- * 我们没有"改道"这个选项,就不能背着他偷偷改(那样比不给自愈更糟:他会以为
82
- * CLI 没听他的)。此时整段 `--timeout` 依然是兜底。
86
+ * 我们就连显式开关也不启用(不背着他改道)。此时整段 `--timeout` 依然是兜底。
83
87
  *
84
88
  * @param {object} flags 已解析的命令行标志(camelCase)
85
89
  * @param {object} env 环境变量
86
90
  * @param {{autoDevice?: boolean, proxyDetected?: boolean}} [opts]
87
- * `proxyDetected` **必须**由调用方用 `proxyInfo.detected` 显式传入;
88
- * 缺省按 `false`(= 没检测到代理)处理 —— 这是刻意的保守默认:
89
- * "信息不足时宁可不自愈",也避免有人忘了传就又回到"无代理也切设备码"的老毛病。
90
- * @returns {number} 毫秒;0 = 关闭自愈
91
+ * `proxyDetected` 仅为**兼容旧调用方**保留,**已不影响返回值**(0.2.2 起默认永不开启)。
92
+ * @returns {number} 毫秒;0 = 不启用(默认)
91
93
  */
92
- export function resolveCallbackFirstWaitMs(flags = {}, env = {}, { autoDevice = true, proxyDetected = false } = {}) {
94
+ export function resolveCallbackFirstWaitMs(flags = {}, env = {}, { autoDevice = true } = {}) {
93
95
  if (!autoDevice) return 0;
94
96
  const off = new Set(['0', 'off', 'none', 'false', 'no']);
95
97
  if (flags.callbackFirstWait !== undefined && flags.callbackFirstWait !== '') {
@@ -103,11 +105,11 @@ export function resolveCallbackFirstWaitMs(flags = {}, env = {}, { autoDevice =
103
105
  return parseDurationMs(envVal, { defaultUnit: 'ms', flag: 'WKONGAI_CALLBACK_FIRST_WAIT_MS' });
104
106
  }
105
107
  /**
106
- * ★ 走到这里 = 用户**没有**显式指定 ⇒ 只有"真检测到代理"才启用自愈。
107
- * 没有代理时返回 0:`auto_device.applied=true, reason='callback_first_wait'`
108
- * 因此**只可能**出现在有代理的机器上(老板真机那条反馈的口径)。
108
+ * ★ 走到这里 = 用户**没有**显式指定 ⇒ **默认不启用**(0.2.2 起)。
109
+ * 不再看 `proxyDetected`:有代理时命令层已经在第一跳直接走设备码了,
110
+ * 回调模式下的"等待超时"只该如实报错,不该偷偷改道。
109
111
  */
110
- return proxyDetected ? DEFAULT_CALLBACK_FIRST_WAIT_MS : 0;
112
+ return 0;
111
113
  }
112
114
 
113
115
  /**
@@ -190,7 +192,9 @@ function bothPathsFailedError(deviceError, reason, { loginTimeoutMs }) {
190
192
  const callbackSide =
191
193
  reason === 'callback_timeout'
192
194
  ? `等待授权超时(${loginTimeoutMs}ms,本机回调监听当时仍然开着)`
193
- : '首轮等待没收到回调(本机回调监听当时仍然开着)';
195
+ : reason === 'callback_listen_failed'
196
+ ? '本地回调端口起不来(根本没起监听)'
197
+ : '首轮等待没收到回调(本机回调监听当时仍然开着)';
194
198
  return new CliError(`本地回调没成功,改用设备码也失败了:${deviceError.message}`, {
195
199
  kind: deviceError.kind || 'device_authorization_failed',
196
200
  code: deviceError.code === undefined ? null : deviceError.code,
@@ -212,15 +216,15 @@ const TERMINAL_DEVICE_KINDS = new Set(['denied', 'expired', 'not_implemented', '
212
216
 
213
217
  export const loginCommand = {
214
218
  name: 'login',
215
- summary: '浏览器授权一次,把 30 天令牌存到本地(默认本地回调模式;--device 走设备码)',
219
+ summary: '浏览器授权一次,把 30 天令牌存到本地(默认本地回调模式;设备码仅在本地无法回调/检测到代理时兜底)',
216
220
  usage:
217
221
  'wkgeo login [--scope "site:read site:write billing:self"] [--no-browser] [--open-browser] [--client-id <id>] [--callback-port <n>] [--timeout <秒>]\n' +
218
- 'wkgeo login --device # 设备码模式:不需要回调地址/监听端口,给用户一个短码\n' +
222
+ 'wkgeo login --device # 设备码模式:不需要回调地址/监听端口,给用户一个短码(兜底路径)\n' +
219
223
  'wkgeo login --device --no-wait # ★ 只申请短码就退出(AI 端友好),稍后用下面这条领令牌\n' +
220
224
  'wkgeo login --device-continue # 用上次保存的设备码换票、写完凭据就完成登录\n' +
221
- 'wkgeo login --no-auto-device # **检测到系统代理也不**自动改设备码(强制走回调;CLI 会警告)\n' +
225
+ 'wkgeo login --no-auto-device # 检测到系统代理也不自动改设备码(强制回调;CLI 会警告;端口真起不来也不兜底)\n' +
222
226
  'wkgeo login --open-browser # 强制尝试打开系统浏览器(⚠️ 现在与默认行为等价,保留只为显式语义与兼容)\n' +
223
- 'wkgeo login --callback-first-wait 60 # 首轮等待 60 秒(★ 只在检测到代理时才有默认值 180;0/off = 关掉这个自愈)\n' +
227
+ 'wkgeo login --callback-first-wait 60 # ★ 可选:显式启用"首轮等待 60 秒没回调就并行改设备码"(默认关闭;0/off = 关闭)\n' +
224
228
  'wkgeo login --timeout 600 # 等待授权 600 秒(默认单位是秒,不是毫秒;也支持 600s / 500ms)',
225
229
  flags: {
226
230
  browser: { type: 'boolean', desc: '是否自动打开浏览器(★ 默认总是尝试打开:终端与 AI 场景都一样;--no-browser 关闭)' },
@@ -233,7 +237,7 @@ export const loginCommand = {
233
237
  * ⚠️ 与 `--no-browser` 同时出现时 **--no-browser 优先**(关掉就是关掉,见 resolveBrowserIntent)。
234
238
  */
235
239
  'open-browser': { type: 'boolean', desc: '强制尝试打开系统浏览器(⚠️ 现在与默认行为等价,保留只为显式语义与兼容)' },
236
- device: { type: 'boolean', desc: '用设备码模式(RFC 8628)代替本地回调:不需要任何回调地址,对代理/防火墙免疫' },
240
+ device: { type: 'boolean', desc: '用设备码模式(RFC 8628)代替本地回调:不需要任何回调地址,对代理/防火墙免疫(本地读不了回调时的兜底)' },
237
241
  /**
238
242
  * ★ 老板 2026-10-02 要的"AI 端友好路径"。
239
243
  * 为什么做成"申请完就退出"而不是"申请完继续挂着轮询"(后者靠 JSONL 事件流也能做):
@@ -246,20 +250,20 @@ export const loginCommand = {
246
250
  'device-continue': { type: 'boolean', desc: '继续上一次 --no-wait/被中断的设备码登录(读取本地保存的设备码并轮询到出结果)' },
247
251
  'auto-device': {
248
252
  type: 'boolean',
249
- desc: '检测到系统代理时自动改用设备码(**默认开**;--no-auto-device 或 WKONGAI_AUTO_DEVICE=0 关闭,关闭时会警告"代理可能吃掉回调")',
253
+ desc: '检测到系统代理时自动改用设备码(**默认开**;--no-auto-device 或 WKONGAI_AUTO_DEVICE=0 关闭,关闭时会警告"代理可能吃掉回调",且端口真起不来时也不兜底)',
250
254
  },
251
255
  /**
252
- * ★ 第二条保险(见 login.mjs 的 DEFAULT_CALLBACK_FIRST_WAIT_MS)。
256
+ * ★ 可选自愈(默认**关闭**,0.2.2 起)。
253
257
  * 默认单位**秒**,与 `--timeout` 一致(人类写等待窗口都按秒想);
254
- * `0` / `off` / `none` = 关掉自愈,回到"只等整段 --timeout"的旧行为。
258
+ * `0` / `off` / `none` = 关闭(默认值就是关闭)。
255
259
  * 环境变量 `WKONGAI_CALLBACK_FIRST_WAIT_MS` 是毫秒(与 `WKONGAI_LOGIN_TIMEOUT_MS` 同口径)。
256
260
  *
257
- * ⚠️ 2026-10-03 起:**不传它时默认值是"看有没有代理"** —— 有代理才是 180 秒,没有代理是 0
258
- * (老板真机反馈:没有开任何 VPN 也被切成了设备码)。显式传值时照旧尊重用户。
261
+ * ⚠️ 2026-10-21(老板反馈):**默认不再自动改设备码** —— 不写它时无论等多久都只走回调,
262
+ * 超时只报错。只有显式写它,才会在首轮等待到点后并发启动设备码(显式优先)。
259
263
  */
260
264
  'callback-first-wait': {
261
265
  type: 'string',
262
- desc: '回调模式的**首轮等待**(默认单位秒;★ 只在检测到系统代理时默认 180 秒,无代理默认不启用):这段时间没等到回调就自动改用设备码继续(本地回调监听会同时保留);0/off 关闭该自愈',
266
+ desc: '★ 可选自愈(默认**关闭**,默认单位秒):显式写它才会在"首轮等待"到点后并行改用设备码继续(本地回调监听同时保留);不写它时**不会**因为等待超时自动改设备码;0/off 关闭',
263
267
  },
264
268
  /**
265
269
  * 显式声明一次(`timeout` 本来就在 GLOBAL_FLAGS 里),只为让 `wkgeo login --help`
@@ -320,6 +324,12 @@ export const loginCommand = {
320
324
  const proxyInfo = !wantsDevice && !wantsContinue ? ctx.proxyInfo() : null;
321
325
  const proxyPayload = proxyInfo ? proxyWarningPayload(proxyInfo, { cmdName }) : null;
322
326
  const autoDevice = resolveAutoDevice(flags, ctx.env);
327
+ /**
328
+ * ★ 可选首轮等待(默认 0 = 关闭,0.2.2 起)。
329
+ * 只有用户**显式**写 `--callback-first-wait` / `WKONGAI_CALLBACK_FIRST_WAIT_MS`
330
+ * 才会启用;`--no-auto-device` 强制 0。它**不参与**"默认走什么模式"的判断。
331
+ */
332
+ const callbackFirstWaitMs = resolveCallbackFirstWaitMs(flags, ctx.env, { autoDevice });
323
333
 
324
334
  /**
325
335
  * ★★ 核心规则(2026-10-02 第二版加固):**`detected === true` 就直接走设备码**。
@@ -335,7 +345,7 @@ export const loginCommand = {
335
345
  */
336
346
  const proxyDetected = !!(proxyInfo && proxyInfo.detected);
337
347
  let useDevice = wantsDevice;
338
- /** 为什么会走设备码:proxy_detected / callback_first_wait / callback_timeout / callback_listen_failed */
348
+ /** 为什么会走设备码:proxy_detected / callback_listen_failed / callback_first_wait(仅显式开启时) */
339
349
  let downgradeReason = null;
340
350
  if (!useDevice && autoDevice && proxyDetected) {
341
351
  useDevice = true;
@@ -380,15 +390,14 @@ export const loginCommand = {
380
390
  noBrowser: flags.browser === false,
381
391
  platform: ctx.platform,
382
392
  /**
383
- * ★ 第二条保险:回调模式的**首轮等待**。
393
+ * ★ 可选首轮等待(见上面 `callbackFirstWaitMs`)。
384
394
  *
385
- * ⚠️⚠️ 2026-10-03 起默认值**取决于有没有代理**(老板真机反馈修的):
386
- * * 有代理(`proxyDetected === true`)→ `DEFAULT_CALLBACK_FIRST_WAIT_MS`(180 秒);
387
- * * **没有代理 → 0 = 不启用**(老老实实等满 `--timeout`);
388
- * * 用户显式写 `--callback-first-wait` / `WKONGAI_CALLBACK_FIRST_WAIT_MS` → 照旧尊重;
395
+ * ⚠️⚠️ 0.2.2 起**默认 0 = 关闭**(老板 2026-10-21 反馈):
396
+ * * 不写 `--callback-first-wait` / 环境变量 → 0,一直等满 `--timeout`,**不会**自动改设备码;
397
+ * * 显式写 `--callback-first-wait <秒>` / `WKONGAI_CALLBACK_FIRST_WAIT_MS` → 照旧启用;
389
398
  * * `--no-auto-device` → 强制 0(用户明确要求只走回调,就不能偷偷改道)。
390
399
  */
391
- callbackFirstWaitMs: resolveCallbackFirstWaitMs(flags, ctx.env, { autoDevice, proxyDetected }),
400
+ callbackFirstWaitMs,
392
401
  /**
393
402
  * ★★ 设备码先成功后,本地回调监听再多留多久(毫秒;0 = 立刻收尾)。
394
403
  * 生产不传 = `login.mjs` 的 `DEFAULT_POST_DEVICE_GRACE_MS`;
@@ -398,19 +407,23 @@ export const loginCommand = {
398
407
  };
399
408
 
400
409
  /**
401
- * ★ 无代理时的一句说明(老板真机反馈的直接回应)。
410
+ * ★ 默认模式的正面说明(老板 2026-10-21 反馈的直接回应)。
402
411
  *
403
- * 为什么值得打这一行:他这次的困惑正是"**我没有开代理,为什么变成设备码了**"。
404
- * 明说"这次不会因为首轮等待就自动改设备码"能直接消掉这个困惑;
405
- * 同时也要说清"整段 --timeout 用完仍可能改"(那是既有的兜底语义,本次没有改动)。
406
- * ⚠️ 只在"回调模式 + 没检测到代理 + 没开自愈"时打:其它情况一律不打(正常环境不要噪音)。
412
+ * 老板原话:「默认的也不应该是设备授权,默认的就是那个传统的授权方式(浏览器打开授权页 +
413
+ * 本地 localhost 回调)。设备码是本地读不了/代理时才用的。」
414
+ * ⇒ 这里明说"本次就是回调、等多久都不会自动改设备码",同时列出**唯一会走设备码**的三种情况,
415
+ * 并给出"主动提前切换"的显式开关,免得用户以为 CLI 背着他改道。
416
+ * ⚠️ 只在"回调模式 + 没检测到代理 + 没显式开首轮等待"时打:其它情况一律不打(正常环境不要噪音)。
407
417
  */
408
- if (!useDevice && !wantsContinue && !proxyDetected && loginDeps.callbackFirstWaitMs === 0) {
418
+ if (!useDevice && !wantsContinue && !proxyDetected && callbackFirstWaitMs === 0) {
409
419
  log(
410
- `ℹ️ 未检测到系统代理:本次会一直等你完成授权(最多 ${Math.round(config.loginTimeoutMs / 1000)} 秒),` +
411
- '不会因为"首轮等待"到点就自动改用设备码。',
420
+ `ℹ️ 未检测到系统代理:本次使用浏览器回调授权(默认),会一直等你完成授权(最多 ${Math.round(config.loginTimeoutMs / 1000)} 秒)。`,
412
421
  );
413
- log(' (想主动限制首轮等待:`--callback-first-wait <秒>`;想强制设备码:`--device`。)');
422
+ log(
423
+ ' 不会因为"首轮等待/等待超时"到点就自动改用设备码;只有显式 `--device`、检测到系统代理、' +
424
+ '或本地回调端口真的起不来时才会用设备码(`--no-auto-device` 可关掉后两种自动兜底)。',
425
+ );
426
+ log(' (想主动提前切换:`--callback-first-wait <秒>`;想直接走设备码:`--device`。)');
414
427
  }
415
428
 
416
429
  /**
@@ -561,10 +574,10 @@ export const loginCommand = {
561
574
  }
562
575
  }
563
576
 
564
- /* ---- 2c. 本地回调模式(含"回调没等到 → 并发改设备码",★ 两条路并存) ---- */
577
+ /* ---- 2c. 本地回调模式(默认路径;显式首轮等待时才与设备码两条路并存) ---- */
565
578
  if (!result && !useDevice) {
566
579
  /**
567
- * ★★ 2026-10-03(老板真机反馈 ⚠️):「改用设备码」**不再关掉本地回调监听**。
580
+ * ★★ 2026-10-03(老板真机反馈 ⚠️):显式开启的"首轮等待"到点改道时**不再关掉本地回调监听**。
568
581
  *
569
582
  * ## 旧行为(真机证据:ERR_CONNECTION_REFUSED)
570
583
  * 首轮等待到点 → `runLogin` 抛错,并在自己的 `finally` 里**关掉本地 server** → 命令层再走设备码。
@@ -572,15 +585,17 @@ export const loginCommand = {
572
585
  * `http://127.0.0.1:<port>/callback?code=…` → **ERR_CONNECTION_REFUSED**
573
586
  * (老板真机截图见过)→ 用户以为授权失败,再来一遍(而其实他马上就会登录成功)。
574
587
  *
575
- * ## 新行为:两条路**并存**,哪个先拿到令牌就用哪个(先到先得)
588
+ * ## 新行为(仅显式 `--callback-first-wait` 时):两条路**并存**,哪个先拿到令牌就用哪个
576
589
  * * 回调那条路:本地 server 一直开着(`runLogin` 收到 `handoff` 时到点**不结束等待**);
577
590
  * * 设备码那条路:由下面的 `handoff.onSwitch` 启动,轮询到出结果为止。
578
591
  * * 回调先成功 → 把设备码那条路**优雅停掉**(唤醒它挂着的 sleep + 删掉待完成状态文件);
579
592
  * * 设备码先成功 → `finishWithDevice()` 让回调那条路收尾(监听再多留几秒,
580
593
  * 迟到的回调会拿到"本次登录已经完成"的页面,见 login.mjs / page.mjs)。
594
+ * ★ 0.2.2 起默认**不启用**这套交接(`--callback-first-wait` 未显式给出时
595
+ * `callbackFirstWaitMs === 0`):默认就是一路等回调,超时只报错。
581
596
  */
582
- /** 允许"自动改道"吗:用户没写 --no-auto-device,也不是显式设备码模式 */
583
- const mayAutoSwitch = autoDevice && !wantsDevice && !wantsContinue;
597
+ /** 允许"等待到点改道"吗:只有用户**显式**开了首轮等待,且没写 --no-auto-device、不是显式设备码模式 */
598
+ const mayAutoSwitch = autoDevice && !wantsDevice && !wantsContinue && callbackFirstWaitMs > 0;
584
599
  /** 设备码那条路的可取消状态(回调先赢时要把它收拾干净) */
585
600
  const deviceSide = { reason: null, cancelled: false, promise: null, result: null, error: null };
586
601
  /** ★ 可被立刻叫醒的 sleep:回调先赢时,不能让设备码那个 5 秒定时器把进程吊住 */
@@ -668,31 +683,25 @@ export const loginCommand = {
668
683
  /** 设备码那条路也失败了 ⇒ 优先报"两条路都不通"(哪怕回调那边先抛了自己的错) */
669
684
  if (deviceSide.combinedError) throw deviceSide.combinedError;
670
685
  /**
671
- * ★ 「回调失败也自动降级」(老板 2026-10-02 要求"不要直接失败退出")。
672
- * 走得到这里的是**没有启用交接**(`--no-auto-device`)或"连端口都起不来"的两种情况:
673
- * * `listen` —— 连回环端口都起不来(被安全软件/防火墙拦):
674
- * 此时**根本没有监听可保留**,只能老老实实改走设备码;
675
- * * `callback_first_wait` / `timeout` —— 只可能出现在没传 `handoff.onSwitch` 时(旧契约兜底)。
676
- * 其它错误(换票失败、被拒绝、state 不匹配…)**不降级**:那些是"授权已经走到下一步了",
677
- * 再来一遍设备码只会让用户做重复劳动(甚至以为被耍了)—— 这条语义**本次没有改动**。
686
+ * ★★ 0.2.2 起:**只有"本地回调端口真的起不来"(`kind = 'listen'`)才自动兜底设备码**。
687
+ * (老板:「设备码是本地读不了/代理时才用的」—— 连监听都起不来就是"本地读不了"。)
688
+ * * `listen` —— 回环端口起不来(被安全软件/防火墙拦 / 端口非法):
689
+ * 此时**根本没有监听可保留**,只能老老实实改走设备码;
690
+ * * `timeout` / `callback_first_wait` —— **不再自动改道**:
691
+ * 等待超时只如实报错(默认就是回调,不该因为等太久变设备码)。
692
+ * * `--no-auto-device` 时**连 listen 兜底也关闭**(`canSwitch=false`)。
693
+ * 其它错误(换票失败、被拒绝、state 不匹配…)本来就不降级。
678
694
  */
679
- const canSwitch =
680
- autoDevice && !wantsDevice && (e.kind === 'callback_first_wait' || e.kind === 'timeout' || e.kind === 'listen');
695
+ const canSwitch = autoDevice && !wantsDevice && e.kind === 'listen';
681
696
  if (!canSwitch) throw e;
682
697
  log('');
683
- const reason =
684
- e.kind === 'timeout' ? '等待超时' : e.kind === 'listen' ? '本地端口监听失败' : '首轮等待没收到回调';
685
- log(`⚠️ 本地回调没有等到结果(${reason}):${e.message}`);
686
- if (e.kind === 'callback_first_wait') {
687
- log('ℹ️ 等了这一小段时间没收到回调,已自动改用**设备码模式**继续(不需要本地回调)。');
688
- } else {
689
- log('ℹ️ 已自动改用**设备码模式**继续授权(不需要本地回调)—— 常见原因是代理/防火墙挡了 127.0.0.1。');
690
- }
698
+ log(`⚠️ 本地回调端口起不来(${e.message}):这是"本地读不了回调"的真实失败,改用【设备码模式】继续授权。`);
699
+ log('ℹ️ 设备码不需要本地监听(常见原因是端口被安全软件/防火墙拦);授权页照旧点「同意」即可。');
691
700
  let device;
692
701
  try {
693
702
  device = await requestDeviceCode({ config, deps: loginDeps });
694
703
  } catch (e2) {
695
- throw bothPathsFailedError(e2, e.kind === 'timeout' ? 'callback_timeout' : 'callback_first_wait', {
704
+ throw bothPathsFailedError(e2, 'callback_listen_failed', {
696
705
  loginTimeoutMs: config.loginTimeoutMs,
697
706
  });
698
707
  }
@@ -702,12 +711,7 @@ export const loginCommand = {
702
711
  result = await pollDeviceToken({ config, log, deps: loginDeps, device });
703
712
  deletePendingDevice(pendingFile);
704
713
  useDevice = true;
705
- downgradeReason =
706
- e.kind === 'callback_first_wait'
707
- ? 'callback_first_wait'
708
- : e.kind === 'timeout'
709
- ? 'callback_timeout'
710
- : 'callback_listen_failed';
714
+ downgradeReason = 'callback_listen_failed';
711
715
  }
712
716
 
713
717
  if (deviceSide.reason) {
@@ -722,7 +726,8 @@ export const loginCommand = {
722
726
  * 这里只要把"最终模式/原因"如实记下来。
723
727
  */
724
728
  useDevice = true;
725
- downgradeReason = deviceSide.reason === 'callback_timeout' ? 'callback_timeout' : 'callback_first_wait';
729
+ // 0.2.2 起整段超时不再交接,`deviceSide.reason` 只可能是显式首轮等待
730
+ downgradeReason = 'callback_first_wait';
726
731
  } else {
727
732
  /**
728
733
  * ★ 回调那条路**先**成功(本次修复的核心价值:用户后来点的那次【同意】真的换到了令牌)
@@ -775,9 +780,9 @@ export const loginCommand = {
775
780
  if (downgradeReason === 'proxy_detected')
776
781
  out.info(' (本次因"检测到系统代理"自动改用了设备码模式:不再依赖 127.0.0.1 回调,绕过列表写了什么也不作依据。)');
777
782
  if (downgradeReason === 'callback_first_wait')
778
- out.info(' (回调模式首轮等待没收到回调,已自动改用设备码模式完成登录。)');
779
- if (downgradeReason === 'callback_timeout' || downgradeReason === 'callback_listen_failed')
780
- out.info(' (本地回调没成功,已自动改用设备码模式完成登录。)');
783
+ out.info(' (已按显式 --callback-first-wait:首轮等待没收到回调,已自动改用设备码模式完成登录。)');
784
+ if (downgradeReason === 'callback_listen_failed')
785
+ out.info(' (本地回调端口起不来,已自动改用设备码模式完成登录。)');
781
786
  }
782
787
  return data;
783
788
  },
package/lib/login.mjs CHANGED
@@ -19,16 +19,23 @@
19
19
  * 依赖注入(deps)是为了**可测**:offline 单测里把 fetch / 开浏览器 / 端口都替换掉,
20
20
  * 就成了一台"假浏览器",可以逐条验证 state 校验、超时、错误页这些分支。
21
21
  *
22
- * ⚠️ 两个等待窗口(别搞混,`commands/auth.mjs` 靠 `kind` 区分"要不要自动改设备码"):
23
- * * `DEFAULT_CALLBACK_FIRST_WAIT_MS`(**180 秒**,`--callback-first-wait` 可调/可关,
24
- * ★ **只在检测到系统代理时**才由命令层启用)
25
- * → 到点抛 `kind = 'callback_first_wait'` → **自动改用设备码继续,不退出**(第二条保险);
22
+ * ★★ 2026-10-21(老板反馈):**默认就是这条本地回调授权**,`wkgeo login` 不会因为
23
+ * "等了一会儿"自动变成设备码。设备码只在**这几种情况**出现(裁决点在 `commands/auth.mjs`):
24
+ * * 用户显式 `--device` / `--no-wait` / `--device-continue`;
25
+ * * 检测到系统代理且判定回调不可用(默认开,`--no-auto-device` 可关);
26
+ * * 本地回调端口**真的起不来**(`kind = 'listen'`,真实失败兜底;`--no-auto-device` 可关)。
27
+ * 整段等待超时(`kind = 'timeout'`)**只报错**,不再自动改设备码(0.2.2 起取消)。
28
+ *
29
+ * 两个等待窗口(别搞混,`commands/auth.mjs` 靠 `kind` 区分"要不要自动改设备码"):
30
+ * * 可选首轮等待(`--callback-first-wait` / `WKONGAI_CALLBACK_FIRST_WAIT_MS`,
31
+ * ★ **默认关闭**,只有用户显式指定才启用)
32
+ * → 到点抛 `kind = 'callback_first_wait'`;命令层在场时会**并发**启动设备码(仅显式开启时);
26
33
  * * `config.loginTimeoutMs`(默认 300 秒,`--timeout` 可调)
27
34
  * → 到点抛 `kind = 'timeout'`(整段等待的兜底,也是老板报的那个故障的现场)。
28
35
  *
29
- * ★★ 2026-10-03(老板真机反馈)起,这两条"到点"都**不再关掉本地回调监听**:
36
+ * ★★ 2026-10-03(老板真机反馈)起,"首轮等待"到点改道时**不再关掉本地回调监听**:
30
37
  * 命令层会通过 `deps.handoff` 拿到通知,**并发**去跑设备码那条路,**哪条先成功就用哪条**
31
- * (详见 `DEFAULT_CALLBACK_FIRST_WAIT_MS` 与 `deps.handoff` 的注释)。
38
+ * (详见 `deps.handoff` 的注释;该自愈现在只有显式 `--callback-first-wait` 才会触发)。
32
39
  */
33
40
 
34
41
  import http from 'node:http';
@@ -135,47 +142,23 @@ const FALLBACK_STRATEGIES = BROWSER_STRATEGY_TABLE.linux;
135
142
  export const BROWSER_LAUNCH_TIMEOUT_MS = 4000;
136
143
 
137
144
  /**
138
- * ★ 「回调模式首轮等待」窗口(默认 **180 秒**)—— 第二条保险,**只在检测到系统代理时启用**。
139
- *
140
- * ## ⚠️⚠️ 2026-10-03 老板真机反馈:这条自愈**被用错了场景**(本次修的就是它)
141
- * 老板**没有开任何 VPN / 代理**,但连着重跑几次 `wkgeo login` 都**自动变成了设备码**,
142
- * 原话:「现在变成设备码了,我们因为没有开任何VPN,所以这个是不对的。」
143
- * 现场:旧逻辑把这个窗口**无条件**挂在 60 秒上,而"把授权页送到用户面前、用户读完再点
144
- * 【同意】"经常超过 60 秒 —— 于是明明不需要设备码,也切了过去。
145
- * ⇒ 现在:**只有 `proxy_warning.detected === true`(真检测到生效中的代理)才启用**;
146
- * **没有代理时一律不启用**(老老实实等满 `--timeout`)。
147
- * 用户**显式**写 `--callback-first-wait <秒>` 或 `WKONGAI_CALLBACK_FIRST_WAIT_MS` 时照旧尊重
148
- * —— **显式指定优先于自动判断**(他怎么要求就怎么做)。
149
- * ⇒ 连带结论:`auto_device.applied=true, reason='callback_first_wait'` **只可能出现在有代理的机器上**;
150
- * 没有代理、用户也没显式指定时,**绝不会**发生。
151
- * (判定点在 `commands/auth.mjs` 的 `resolveCallbackFirstWaitMs()`,那边有一份对应的说明与单测钉子。)
145
+ * 历史常量:回调模式「首轮等待」的**旧默认窗口**(180 秒)。
152
146
  *
153
- * ## 为什么还需要它(什么时候它是对的)
154
- * "代理把回调吃掉了"这种故障里,用户其实早就点完【同意授权】了,只是结果回不来;
155
- * 而设备码**不需要任何入站回调**,几分钟内就能拿到令牌 —— 比让他干等 5~15 分钟强得多。
156
- * 放宽到 180 秒(原来是 60 秒)就是给"用户真在认真读授权页"留足余量:
157
- * 它只在"有代理、回调本来就可能回不来"的机器上生效,宁可多等一会儿再改道。
147
+ * ★ 0.2.2 起**不再作为默认值生效**(老板 2026-10-21 反馈):
148
+ * 「默认的也不应该是设备授权,默认的就是那个传统的授权方式(浏览器打开授权页 +
149
+ * 本地 localhost 回调)。设备码是本地读不了/代理时才用的。」
150
+ * ⇒ `wkgeo login` 默认一路等回调(最多 `--timeout`),**不会**因为等太久自动改设备码;
151
+ * 只有用户**显式**写 `--callback-first-wait <秒>` / `WKONGAI_CALLBACK_FIRST_WAIT_MS`
152
+ * 才启用这条可选自愈(显式优先)。
158
153
  *
159
- * ## ★★ 到点之后**不关本地回调监听**(同一次修复的第二条,真机证据见下)
160
- * 旧行为:到点 → 抛 `callback_first_wait` → 命令层改走设备码,而 `runLogin` 的 `finally`
161
- * **已经把本地 server 关掉了**。于是用户随后才点【同意授权】时,浏览器跳到
162
- * `http://127.0.0.1:<port>/callback?code=…` 得到 **ERR_CONNECTION_REFUSED**
163
- * (老板真机截图见过)—— 用户以为授权失败,又重来一次(其实他马上就会登录成功)。
164
- * 现在的契约:调用方传了 `deps.handoff` 时,到点**只通知、不结束、不关监听**(见 `runLogin`),
165
- * 由命令层**并发**跑设备码,两条路**先到先得**。
154
+ * ⚠️ 保留这个导出只为**兼容**仍 import 它的外部脚本/旧文档;产品行为以
155
+ * `commands/auth.mjs` 的 `resolveCallbackFirstWaitMs()` 为准(无显式指定时返回 0)。
156
+ * 这个常量本身**不参与任何默认判断**。
166
157
  *
167
- * ## 覆盖方式(优先级:命令行 > 环境变量 > 这个默认值)
168
- * * `--callback-first-wait <时长>`:默认单位**秒**(`--callback-first-wait 90` = 90 秒),
169
- * 也支持 `90s` / `90000ms`;写 `0` / `off` / `none` 表示**关掉这个自愈窗口**
170
- * (那就只剩整段 `--timeout` 窗口)。
171
- * * `WKONGAI_CALLBACK_FIRST_WAIT_MS`:毫秒(沿用 `WKONGAI_LOGIN_TIMEOUT_MS` 的口径)。
172
- * * `--no-auto-device` / `WKONGAI_AUTO_DEVICE=0` 时**强制关闭**:用户明确要求只走回调,
173
- * 我们就没有"改道"这个选项,不能偷偷改(见 `commands/auth.mjs` 的 resolveCallbackFirstWaitMs)。
158
+ * 历史背景(仍适用于"显式开启"的场景):到点后**不关本地回调监听**,由命令层并发跑设备码,
159
+ * 两条路先到先得(旧版关监听会让用户之后点【同意】看到 ERR_CONNECTION_REFUSED)。
174
160
  *
175
- * ## 约束
176
- * * 必须**小于** `config.loginTimeoutMs` 才有意义(否则只会先撞整段超时);
177
- * * 到点后抛 `kind = 'callback_first_wait'`(**不是** `timeout`),命令层据此自动改设备码、
178
- * **不退出**;换票失败/被拒绝等其他错误**照旧不降级**。
161
+ * @deprecated 0.2.2 起默认关闭;需要"等待到点改设备码"请显式传 `--callback-first-wait`。
179
162
  */
180
163
  export const DEFAULT_CALLBACK_FIRST_WAIT_MS = 180_000;
181
164
 
@@ -684,22 +667,24 @@ function sendHtml(res, status, page) {
684
667
  * 决定"要不要替用户弹系统浏览器":★ **默认总是尝试打开**(人在终端与 AI 场景都一样),
685
668
  * 只有 `noBrowser`(`--no-browser`)才不打开;`isTTY` / `json` 现在只影响 `mode` 这个诊断字段
686
669
  * ——理由见 resolveBrowserIntent()。
687
- * `callbackFirstWaitMs` —— ★ 首轮等待窗口(默认 `DEFAULT_CALLBACK_FIRST_WAIT_MS` = 180 秒;
688
- * 传 0 或负数 = 关掉自愈,只等整段 `loginTimeoutMs`)。命令层会把它透传进来
689
- * (**只有检测到系统代理时才给非 0**,见 `commands/auth.mjs` 的 resolveCallbackFirstWaitMs),
690
- * 单测则用短窗口(例如 400ms)来验证"自动改设备码"这条分支,不必真的等 180 秒。
670
+ * `callbackFirstWaitMs` —— 可选的「首轮等待」窗口,**默认 0 = 关闭**(0.2.2 起)。
671
+ * 只有用户显式写了 `--callback-first-wait` / `WKONGAI_CALLBACK_FIRST_WAIT_MS` 时,
672
+ * 命令层才会给非 0 值(见 `commands/auth.mjs` 的 resolveCallbackFirstWaitMs);
673
+ * 传 0 / 不传 / 负数 = 不自愈,一直等满整段 `loginTimeoutMs`。
674
+ * 单测用短窗口(例如 400ms)验证"显式开启后自动改设备码"这条分支。
691
675
  * ★★ `handoff` —— **「回调 / 设备码两条路并存」的交接面**(2026-10-03 修 ERR_CONNECTION_REFUSED 时加的)。
692
676
  * 调用方(命令层)传一个**可变对象**,两边各填一半:
693
- * * 调用方**先**填 `handoff.onSwitch(reason)` —— 首轮等待/整段超时到点时会被调用
694
- * (`reason` 是 `'callback_first_wait'` 或 `'callback_timeout'`),它应当去启动设备码那条路,
677
+ * * 调用方**先**填 `handoff.onSwitch(reason)` —— **显式首轮等待**到点时会被调用
678
+ * (`reason` 是 `'callback_first_wait'`),它应当去启动设备码那条路,
695
679
  * **不要**在这里结束回调这条路:本地监听由本函数继续持有;
680
+ * ⚠️ 整段 `--timeout` 到点**不会**再调用它(0.2.2 起取消"等太久自动改道")。
696
681
  * * 本函数填 `handoff.finishWithDevice(result)` / `handoff.abort(error)` ——
697
682
  * 设备码那条路先拿到令牌时调前者(本函数据此收尾,并给"迟到的回调"回一张已完成页),
698
683
  * 设备码那条路也失败时调后者(本函数用这个错误结束等待)。
699
- * ⚠️ **没传 `handoff`(或 `onSwitch` 不是函数)= 旧契约**:到点直接抛 `callback_first_wait` /
700
- * `timeout` 并在 `finally` 里关掉监听(既有的 runLogin 级单测走的就是这条,语义不许变)。
684
+ * ⚠️ **没传 `handoff`(或 `onSwitch` 不是函数)= 旧契约**:显式首轮等待到点直接抛
685
+ * `callback_first_wait` 并在 `finally` 里关掉监听(既有 runLogin 级单测走的就是这条)。
701
686
  * `postDeviceGraceMs` —— 设备码先成功后,本地监听再多留多久(默认 `DEFAULT_POST_DEVICE_GRACE_MS`;
702
- * 只对"首轮等待"这条自愈生效;单测注入小值即可确定性地验证"迟到的回调拿到已完成页")。
687
+ * 只对"显式首轮等待"这条自愈生效;单测注入小值即可确定性地验证"迟到的回调拿到已完成页")。
703
688
  */
704
689
  export async function runLogin({ config, log = () => {}, deps = {} }) {
705
690
  const fetchImpl = deps.fetchImpl || globalThis.fetch;
@@ -843,7 +828,7 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
843
828
  let settled = false;
844
829
  let server;
845
830
  let timer = null;
846
- /** ★ 首轮等待定时器(见 DEFAULT_CALLBACK_FIRST_WAIT_MS):到点 → 让命令层改走设备码 */
831
+ /** ★ 可选首轮等待定时器(仅显式 `--callback-first-wait` 才有):到点 → 让命令层并发跑设备码 */
847
832
  let firstWaitTimer = null;
848
833
  /**
849
834
  * ★★ 交接面(见 deps.handoff 的说明):命令层传进来的可变对象,两边各填一半。
@@ -1257,17 +1242,21 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1257
1242
  * (⚠️ 注意:有代理时命令层通常**已经**走设备码了,根本走不到这里;
1258
1243
  * 能走到这里的情况是"用户在代理下强制回调"或"探测没发现代理但回调仍被别的
1259
1244
  * 东西挡了 —— 防火墙/安全软件/浏览器插件"。)
1260
- * ★ 传了 `handoff` 时**不再抛错**:改成通知命令层"并发去跑设备码",本地监听**保持开着**
1261
- * (用户随后才点的那次【同意】照样能成功;见 DEFAULT_CALLBACK_FIRST_WAIT_MS 的真机证据)。
1245
+ * ★★ 0.2.2 起:整段超时**不再自动改设备码**(老板:默认就是回调,设备码只在
1246
+ * 本地读不了/代理时才用)。这里只报 `kind = 'timeout'`,并在 hint 里明确给出
1247
+ * "显式改用 `--device`"的下一步。
1248
+ * ⚠️ 唯一例外:用户**显式**开了 `--callback-first-wait`,到点已经把设备码那条路
1249
+ * 并发点起来了(`switchReason` 非空)。此时整段超时到点不能再把设备码那条路判死,
1250
+ * 交给它自己跑到设备码过期/成功为止(与 2026-10-03 的"两条路并存"契约一致)。
1262
1251
  */
1263
- if (canHandoff && !deviceDone) {
1264
- handOff('callback_timeout');
1252
+ if (canHandoff && switchReason && !deviceDone) {
1265
1253
  return;
1266
1254
  }
1267
1255
  finish(new CliError(`等待授权超时(${config.loginTimeoutMs}ms)`, {
1268
1256
  kind: 'timeout',
1269
1257
  hint:
1270
- '授权码本身只有 5 分钟有效期。请重新运行 `wkgeo login`(浏览器里通常还有登录态,点一下同意即可)' +
1258
+ '授权码本身只有 5 分钟有效期。请重新运行 `wkgeo login`(浏览器里通常还有登录态,点一下同意即可);' +
1259
+ '若本机确实无法接收 127.0.0.1 回调(代理/防火墙/远程环境),请显式使用 `wkgeo login --device`(设备码模式)' +
1271
1260
  (proxyHint ? `。另外:${proxyHint}` : ''),
1272
1261
  exitCode: EXIT.AUTH,
1273
1262
  }));
@@ -1275,8 +1264,11 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1275
1264
  if (timer && typeof timer.unref === 'function') timer.unref();
1276
1265
 
1277
1266
  /**
1278
- * ★ 第二条保险:**首轮等待**(默认 180 秒,且只由命令层在"检测到代理"时给非 0 值)没等到回调
1279
- * → 主动结束这一轮等待,让命令层自动改用设备码继续(`kind = 'callback_first_wait'`,**不是** `timeout`)。
1267
+ * 可选自愈:**首轮等待**(★ 默认关闭;只有用户显式 `--callback-first-wait` 才会 > 0)
1268
+ * 没等到回调 → 主动结束这一轮等待 / 或交接给设备码(`kind = 'callback_first_wait'`,**不是** `timeout`)。
1269
+ *
1270
+ * ⚠️ 0.2.2 起**默认不挂这个定时器**(旧版有代理时默认 180 秒,会"等太久自动变设备码",
1271
+ * 正是老板这次反馈要改掉的行为)。
1280
1272
  *
1281
1273
  * 为什么不是在这里直接改走设备码:设备码流程在 `device.mjs` / `commands/auth.mjs`,
1282
1274
  * 而 `runLogin` 的契约是"要么拿到凭据、要么抛一个说明原因的错误"。
@@ -1288,11 +1280,11 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1288
1280
  * ⚠️ 只在 `0 < firstWait < loginTimeoutMs` 时挂:否则先撞到的必然是整段超时,
1289
1281
  * 挂上去只会让两个定时器互相打乱,也让错误信息变得难以理解。
1290
1282
  */
1291
- const firstWaitMs = Number(deps.callbackFirstWaitMs ?? DEFAULT_CALLBACK_FIRST_WAIT_MS);
1283
+ const firstWaitMs = Number(deps.callbackFirstWaitMs ?? 0);
1292
1284
  if (Number.isFinite(firstWaitMs) && firstWaitMs > 0 && firstWaitMs < config.loginTimeoutMs) {
1293
1285
  const label = firstWaitMs >= 1000 ? `${Math.round(firstWaitMs / 1000)} 秒` : `${firstWaitMs} 毫秒`;
1294
- // 提前告知"不用干等",并说清"监听还会开着"(后者正是本次修复:用户之后点同意也来得及)
1295
- log(`(如果 ${label}内没收到回调,会自动改用设备码模式继续,不用一直干等;本地回调监听也会继续保留)`);
1286
+ // 提前告知"不用干等",并说清"监听还会开着"(后者正是 2026-10-03 修复:用户之后点同意也来得及)
1287
+ log(`(已按 --callback-first-wait 显式启用首轮等待:如果 ${label}内没收到回调,会自动改用设备码模式继续;本地回调监听也会继续保留)`);
1296
1288
  firstWaitTimer = setTimeout(() => {
1297
1289
  if (canHandoff && !deviceDone) {
1298
1290
  handOff('callback_first_wait');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wkgeo-cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "云站平台官方命令行客户端:授权登录一次,之后建站 / 下单 / 钱包支付 / 查余额都不用再登录(OAuth 授权码 + PKCE,公共客户端,零第三方依赖)",
5
5
  "keywords": [
6
6
  "wkgeo",