wkgeo-cli 0.2.2 → 0.2.4

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
@@ -87,7 +87,7 @@ cd cli && node scripts/build-exe.mjs --sea # 跑 Node SEA 的离线两步(
87
87
 
88
88
  ### 版本与升级策略
89
89
 
90
- * 当前版本 **`0.1.0`**(早期版;稳定后升 `1.0.0`)。
90
+ * 当前版本 **`0.2.3`**(2026-10-21:CI/非交互环境不再自动打开浏览器;稳定后升 `1.0.0`)。
91
91
  * **同一个版本号不能覆盖**:改了代码必须 `npm version patch|minor|major` 再发布。
92
92
  * AI 端统一用 **`npx -y wkgeo-cli@latest`** → 每次自动拿最新版;
93
93
  **全局安装的用户不会自动升级**(要手动 `npm i -g wkgeo-cli@latest`)。
@@ -135,7 +135,7 @@ wkgeo login
135
135
 
136
136
  会发生什么(**用户实际会看到的东西**):
137
137
 
138
- 1. 终端打印一条授权网址(同时**尽力自动打开浏览器**);
138
+ 1. 终端打印一条授权网址(**人在终端时**会尽力自动打开浏览器;**CI/管道/AI 子进程只输出链接**,见下);
139
139
  2. 浏览器里是**我们自己的登录页**:支持「账密登录 / 手机号登录 / 邮箱登录」三个页签
140
140
  (手机号/邮箱是**验证码登录**,验证码由平台下发到已绑定的手机/邮箱);
141
141
  3. 登录后进入**同意页**,显示应用名「官方 CLI」和将获得的权限,点「同意」;
@@ -155,8 +155,8 @@ wkgeo login
155
155
  | 选项 | 说明 |
156
156
  | --- | --- |
157
157
  | `--device` | 用**设备码模式**(RFC 8628):不需要本地回调地址/端口;本地读不了回调时的兜底(见 §3.2) |
158
- | `--no-browser` | **完全不尝试**自动打开浏览器(只打印网址,适合远程/无 GUI、自动化;★ 这是**唯一**的"不打开"开关) |
159
- | `--open-browser` | 强制尝试打开系统浏览器(⚠️ 现在与**默认行为等价** —— 默认就会尝试打开;保留只为显式语义与兼容,见下) |
158
+ | `--no-browser` | **不打开浏览器**(优先级最高,压过 `--open-browser`)。⚠️ 非交互/CI 环境本来就不会打开,这个开关主要用于终端/脚本显式表态 |
159
+ | `--open-browser` | 强制尝试打开系统浏览器(⚠️ **只在人类终端生效**;CI/非交互环境仍不打开;保留显式语义与兼容) |
160
160
  | `--scope "site:read site:write billing:self"` | 申请的能力范围(默认就是这三个,够建站+下单+支付) |
161
161
  | `--client-id <id>` | 换一个 OAuth 客户端(默认 `wkgeo-cli`) |
162
162
  | `--callback-port <n>` | 本地回调端口(默认 53682;被占用会自动退到随机端口) |
@@ -172,20 +172,25 @@ wkgeo login
172
172
  > `/admin/oauth/authorize`(页面显示「授权请求不完整」)。
173
173
  > 全部失败才提示,且**只 warn**(`⚠️ 浏览器没能自动打开…` 后面紧跟**完整 URL** 供复制),**绝不中断**授权流程。
174
174
  >
175
- > ★ **默认就是"总是尝试打开"**(2026-10 老板最终拍板走**传统路线**):
176
- > 「还是返回以前那种**打开跳到一个浏览器**,然后打开授权那个吧,**大家也都是那个的**……
177
- > 我们在右侧这个……能力还不会太完善……**还是走传统的路吧**。」
178
- > * **任何场景**(**人在终端**、**AI 客户端 / 管道 / 脚本(非 TTY 或 `--json`)**)→ **都会尝试**自动打开系统浏览器;
175
+ > ★ **2026-10-21 起:非交互 / CI 环境默认不弹浏览器**(老板真机反馈「别人在 CI 里执行
176
+ > `wkgeo-cli` 的 login,默认弹出了系统默认浏览器」):
177
+ > * **人类终端**(`stdout.isTTY` 为真、且不在 CI 里)→ 照旧**尝试自动打开**系统浏览器(老体验不变);
178
+ > * **非交互 / CI** → **只输出授权链接/JSON,一个浏览器进程都不 spawn**。判定(满足任一):
179
+ > * `stdout.isTTY` 为假(管道 / 重定向 / AI 子进程 / CI 日志采集);
180
+ > * 存在任一 CI 环境变量(**存在即判定**):`CI`、`GITHUB_ACTIONS`、`GITLAB_CI`、`JENKINS_URL`、`TF_BUILD`、`BUILDKITE`、`TEAMCITY_VERSION`;
181
+ > * `WKONGAI_CLI_NO_BROWSER=1`(`true/yes/on` 也认)。
182
+ > 此时 `open_browser.attempted=false`、`skipped_reason:"non_interactive"`(字段结构不变),
183
+ > 并打印「把授权链接转给有浏览器的用户 / 改用 `wkgeo login --device`」的可照做提示;
184
+ > * `--no-browser` → **不打开**(`skipped_reason:"no_browser"`,**优先级最高**,压过 `--open-browser`);
185
+ > * `--open-browser` **保留**,但只在终端里保留显式语义(**压不过**非交互判定:CI 里写它也不弹);
186
+ > * `--json` 的 `open_browser.skipped_reason` 合法值:`null`(真尝试过)/ `"no_browser"` / `"non_interactive"`
187
+ > (2026-10-01 那版的 `"non_tty_agent_mode"` **已弃用**);
179
188
  > * 打开成功 → `✅ 已为你打开浏览器,请在那里完成授权。`;打开失败 → ⚠️ 一段**含完整 URL** 的醒目提示
180
189
  > (AI 场景再多一句"客户端支持内置浏览器也可以在那里打开"的**可选**提示,不是主路径);
181
- > * **唯一**的"不打开"开关是 `--no-browser`(**优先级最高**,压过 `--open-browser`);
182
- > * `--open-browser` **保留**,但现在与默认行为**等价**(只为显式语义与兼容,别删);
183
- > * `--json` 的 `open_browser.skipped_reason` 现在只有 `"no_browser"` 或 `null` 两种
184
- > (2026-10-01 那版的 `"non_tty_agent_mode"` **已弃用**);
185
- > * 为什么走传统路线:**稳定**(各平台都有成熟的系统启动器)+ **`window.close()` 在独立浏览器里生效**
186
- > (授权页/收银台完成后的那一屏能自己关掉);
190
+ > * 非交互时 `paste_text` 是「请打开以下链接完成授权:<url>」**而不是**「已为你打开浏览器」——
191
+ > 屏幕上什么都没弹,说"已打开"会让用户以为链接坏了;
187
192
  > * ⚠️ **不弹浏览器 ≠ 失败**、**打开失败也 ≠ 失败**:地址照旧完整打印、流程照旧等回调/照旧返回 `pay_url`,
188
- > 命令**绝不会**因此失败。
193
+ > 命令**绝不会**因此失败(回调 / 设备码的判定逻辑也完全不受影响)。
189
194
 
190
195
  > ⚠️ 回调**只监听 `127.0.0.1`**(绝不 `0.0.0.0`);`state` 每次随机并在本地回调时**强校验**
191
196
  > (服务端只负责原样回传,防 CSRF 是客户端自己的责任)。
@@ -410,7 +415,7 @@ wkgeo login --device-continue --json
410
415
  | `import` | 老站导入(**默认 dry-run**;走本机运维脚本,需运维权限) | `--url`(必填)、`--apply`、`--site`、`--status`、`--out`、`--max-pages`、`--delay`、`--include`、`--exclude`、`--overwrite`、`--no-probe` |
411
416
  | `wallet` | 余额 + 最近流水 | `--limit`、`--entitlements` |
412
417
  | `wallet txns` | 钱包流水(普通用户自己的那份) | `--limit` |
413
- | `wallet recharge` | **充值下单**:返 `code_url` + **`pay_url`(自家收银台页面,带订单支付令牌 `token`;★ 默认会自动尝试用系统浏览器打开,`--no-browser` 可关;**支付成功后页面会自己关掉**)**(**不会自动扣款**) | `--amount-cents`(必填,单位分)、`--no-browser`、`--open-browser`、`--dry-run` |
418
+ | `wallet recharge` | **充值下单**:返 `code_url` + **`pay_url`(自家收银台页面,带订单支付令牌 `token`;★ 人类终端默认自动打开,CI/非交互只输出链接;**支付成功后页面会自己关掉**)**(**不会自动扣款**) | `--amount-cents`(必填,单位分)、`--no-browser`、`--open-browser`、`--dry-run` |
414
419
  | `products list` | 能买什么(找 `--product` 该填什么) | — |
415
420
  | `orders create` | 下单(**余额够就当场用钱包付清**) | `--product <代码>` + `--units <份数>`,或 `--quota <键>` + `--amount <数量>`;`--allow-wechat` |
416
421
  | `orders check` | 查支付结果(微信侧已付则就地履约)+ 最新余额 | `<订单ID>` |
@@ -606,7 +611,7 @@ wkgeo publish # ③ 看状态(见下)
606
611
  | --- | --- | --- |
607
612
  | 1 | `wkgeo wallet`(记下当前余额,例如 ¥100.00) | 打印余额与流水 |
608
613
  | 2 | `wkgeo wallet recharge --amount-cents 1000 --dry-run` | 只打印"将创建 ¥10.00 订单",**不产生任何订单** |
609
- | 3 | `wkgeo wallet recharge --amount-cents 1000` | 返回 `order_id` / `code_url` / **`pay_url`**,并**自动在浏览器打开 `pay_url`** —— 也就是我们自己的收银台 `https://pay.wkgeo.com/?order_id=…&token=…` |
614
+ | 3 | `wkgeo wallet recharge --amount-cents 1000` | 返回 `order_id` / `code_url` / **`pay_url`**;**人类终端**下会自动在浏览器打开 `pay_url` —— 也就是我们自己的收银台 `https://pay.wkgeo.com/?order_id=…&token=…`(CI/非交互只输出链接,不弹) |
610
615
  | 4 | 在**收银台页面**用手机微信扫码支付 ¥10.00(人工) | 页面自动检测到支付成功;**支付成功后页面会自动尝试关闭**(关不掉就手动关掉即可);`pay_url` 里的订单号在任何设备/浏览器打开都能看到同一笔单 |
611
616
  | 5 | `wkgeo orders check <order_id>` | `paid:true` + **最新余额 = 原余额 + 1000 分** |
612
617
  | 6 | `wkgeo wallet txns` | 出现一条 `kind=recharge`、金额 `+¥10.00` 的流水 |
@@ -615,12 +620,14 @@ wkgeo publish # ③ 看状态(见下)
615
620
  > **为什么是"打开我们网站的收银台"而不是"在终端画二维码"**:
616
621
  > 老板明确否掉了"本地生成一个二维码页面让别人扫"的做法 —— 充值必须走**我们网上的充值页面**。
617
622
  > 终端二维码只在"浏览器确实打不开"时作为**兜底**出现(文案会写明这一点)。
618
- > `--no-browser` 用于无桌面环境:**完全不尝试打开**,`pay_url` 照常输出。
619
- > ★ **默认会尝试弹系统浏览器**(2026-10 老板最终拍板走传统路线,人在终端与 AI 场景都一样):
620
- > 打开成功 → `✅ 已为你打开浏览器,请在那里完成支付(微信扫码)。`;
621
- > 打开失败 → ⚠️ 一段**含完整收银台地址**的醒目提示(走 stderr,stdout 仍然只有机器可读 JSON);
622
- > `--json` 里 `open_browser.skipped_reason` 只有 `"no_browser"` 或 `null`(`"non_tty_agent_mode"` 已弃用);
623
- > `--open-browser` 保留但已与默认行为**等价**(只为显式语义与兼容)。
623
+ > `--no-browser` 用于无桌面环境:**不打开**,`pay_url` 照常输出。
624
+ > ★ **2026-10-21 起:人类终端默认尝试弹系统浏览器;非交互/CI 只输出链接**(与 login 同一道闸门):
625
+ > * 非交互判定(同 §3.1):stdout 非 TTY / CI 环境变量 / `WKONGAI_CLI_NO_BROWSER=1`
626
+ > ⇒ `open_browser.skipped_reason = "non_interactive"`,**一个进程都不 spawn**,并提示"把收银台链接交给用户";
627
+ > * 打开成功 → `✅ 已为你打开浏览器,请在那里完成支付(微信扫码)。`;
628
+ > * 打开失败 → ⚠️ 一段**含完整收银台地址**的醒目提示(走 stderr,stdout 仍然只有机器可读 JSON);
629
+ > * `--no-browser` → `skipped_reason = "no_browser"`(最高优先级);
630
+ > * `--open-browser` 保留,但只在终端里生效(非交互仍不弹)。
624
631
  >
625
632
  > ★ **`pay_url` 上的 `token=`(2026-10-02 新增)**:服务端在**下单那一刻**把"这一单是谁的"
626
633
  > 签成一把**只对这一单有效**的令牌(`createRechargeOrder` 返回的 `pay_token`),
@@ -728,6 +735,8 @@ node tencent/functions/_tests/oauth-device-live-e2e.js # 结
728
735
  | `WKONGAI_LOGIN_TIMEOUT_MS` | `300000` | 等待授权超时(⚠️ 变量名里带 `MS`,所以这里填**毫秒**;命令行 `--timeout` 填的是**秒**) |
729
736
  | `WKONGAI_CALLBACK_FIRST_WAIT_MS` | 未设置 | ★ **可选**的"首轮等待"(毫秒,**默认关闭**):只有显式设了它(或命令行 `--callback-first-wait`),这段时间没等到回调才会自动改用设备码继续(**本地回调监听会同时保留**);`0` 关闭(等价于 `--callback-first-wait 0`)。⚠️ **未设置时一律不启用**(0.2.2 起:等待超时不再自动改设备码;老板 2026-10-21:「默认的就是那个传统的授权方式」) |
730
737
  | `WKONGAI_AUTO_DEVICE` | `1`(开) | 检测到系统代理时是否自动改用设备码;设 `0`/`false`/`no`/`off` 关闭(等价于 `--no-auto-device`,会打印"代理可能吃掉回调"的警告) |
738
+ | `WKONGAI_CLI_NO_BROWSER` | 未设置 | ★ **2026-10-21**:设 `1`/`true`/`yes`/`on` = **不自动打开浏览器**(`open_browser.skipped_reason = "non_interactive"`)。给"没有 CI 环境变量但确实在无浏览器环境"的容器/远程场景用 |
739
+ | `CI` / `GITHUB_ACTIONS` / `GITLAB_CI` / `JENKINS_URL` / `TF_BUILD` / `BUILDKITE` / `TEAMCITY_VERSION` | 未设置 | ★ **2026-10-21**:**存在任一即判定为非交互**(不看值)→ **不自动打开浏览器**,只输出链接/JSON |
731
740
  | `NO_COLOR` | — | 设了就不上色 |
732
741
 
733
742
  ---
package/SKILL.md CHANGED
@@ -22,7 +22,7 @@ version: 0.2.0
22
22
  * 用户说"**帮我建个站**"/"创建网站"/"开一个站点" → `sites create`
23
23
  * 用户说"**帮我下单**"/"买加量包"/"扩容空间"/"买流量" → `products list` → `orders create`
24
24
  * 用户说"**用钱包付**"/"余额支付"/"帮我付钱" → `orders create`(余额够会**当场付清**)+ `wallet` 复核
25
- * 用户说"**充值**"/"充 100 块" → `wallet recharge`(下单后**自动打开我们网站的收银台页面**,用户在那里扫码支付)
25
+ * 用户说"**充值**"/"充 100 块" → `wallet recharge`(下单后在**人类终端**里会自动打开我们网站的收银台页面,用户在那里扫码支付;CI/非交互只输出 `pay_url`)
26
26
  * 用户说"**充值**"但**没说金额**、或说"**我自己选金额**"/"给我个充值链接" → `wallet recharge-link`
27
27
  (生成**独立充值页**链接:`https://pay.wkgeo.com/recharge/?ticket=…`,用户在网页上自己选金额
28
28
  —— ¥10 / ¥20 快捷,或自定义,**最低 ¥9.90、不设上限** —— 然后扫码支付;**不需要登录会员中心**。
@@ -116,7 +116,7 @@ npx -y wkgeo-cli@latest skill > SKILL.md # 直接落一份到本地,离线
116
116
  | `sites delete` | 删站(软删进回收站) | `<站点ID>` |
117
117
  | `wallet` | 余额 + 最近流水 | `--limit` `--entitlements` |
118
118
  | `wallet txns` | 钱包流水 | `--limit` |
119
- | `wallet recharge` | 充值下单,**并打开我们网站的收银台页面**扫码支付(**不扣款**) | `--amount-cents`(分,必填)、`--no-browser`、`--open-browser`、`--dry-run` |
119
+ | `wallet recharge` | 充值下单,**人类终端会自动打开我们网站的收银台页面**扫码支付(CI/非交互只输出链接;**不扣款**) | `--amount-cents`(分,必填)、`--no-browser`、`--open-browser`、`--dry-run` |
120
120
  | `wallet recharge-link` | 生成**独立充值页**链接(用户在网页上**自己选金额**并扫码支付;**不扣款**、不建单) | `--open-browser`(默认**不**弹)、`--no-browser` |
121
121
  | `products list` | 能买什么 | — |
122
122
  | `orders create` | 下单(余额够**当场付清**) | `--product <代码>` `--units <份数>`(或 `--quota <键>` `--amount <数量>`)`--allow-wechat` |
@@ -232,17 +232,21 @@ npx -y wkgeo-cli@latest login --json --timeout 500ms # 也可以显式写毫
232
232
  > 不要自己换算成毫秒。非法值(`--timeout 十分钟` / `--timeout 0`)直接报用法错误(退出码 `2`),
233
233
  > 不会静默变成默认值。
234
234
 
235
- > 🌐 **自动打开浏览器**(⚠️ **本次有改动,务必看清楚**,详见 §5.5):
236
- > ★ **默认总是尝试打开系统浏览器**(老板最终拍板走**传统路线**:「还是返回以前那种打开跳到一个浏览器……
237
- > 大家也都是那个的……还是走传统的路吧」)——**人在终端**与 **AI 场景**(非 TTY / `--json`)**都一样**;
238
- > 实现是**多策略依次尝试**(每条都看退出码/错误/超时,失败静默试下一条):
239
- > * 成功 → `✅ 已为你打开浏览器,请在那里完成授权。`(紧跟着一句"授权成功后页面会自动尝试关闭");
240
- > * 全部失败 → `⚠️ 浏览器没能自动打开,请手动复制这个地址到浏览器完成授权:` + **紧跟一行完整 URL**
241
- > (**绝不中断**授权;AI 场景再多一句"客户端支持内置浏览器也可以在那里打开"的**可选**提示);
242
- > * `--no-browser` → **完全不尝试打开**(唯一开关,`open_browser.skipped_reason = "no_browser"`);
243
- > * `--open-browser` → 保留(现在与默认行为等价,只为显式语义与兼容)。
235
+ > 🌐 **自动打开浏览器**(⚠️ **2026-10-21 有改动**:CI/非交互环境**不再弹**,详见 §5.5):
236
+ > * **人类终端**(stdout 是 TTY、且不在 CI 里)→ **尝试自动打开**系统浏览器;
237
+ > * **非交互 / CI**(stdout 非 TTY / 管道 / AI 子进程;或存在 `CI`、`GITHUB_ACTIONS`、
238
+ > `GITLAB_CI`、`JENKINS_URL`、`TF_BUILD`、`BUILDKITE`、`TEAMCITY_VERSION`;或
239
+ > `WKONGAI_CLI_NO_BROWSER=1`)→ **只输出链接/JSON,一个浏览器进程都不 spawn**,
240
+ > `open_browser.attempted=false`、`skipped_reason="non_interactive"`;
241
+ > * 实现是**多策略依次尝试**(每条都看退出码/错误/超时,失败静默试下一条):
242
+ > * 成功 → `✅ 已为你打开浏览器,请在那里完成授权。`(紧跟着一句"授权成功后页面会自动尝试关闭");
243
+ > * 全部失败 → `⚠️ 浏览器没能自动打开,请手动复制这个地址到浏览器完成授权:` + **紧跟一行完整 URL**
244
+ > (**绝不中断**授权;AI 场景再多一句"客户端支持内置浏览器也可以在那里打开"的**可选**提示);
245
+ > * `--no-browser` → **不打开**(`open_browser.skipped_reason = "no_browser"`,最高优先级,
246
+ > 压过 `--open-browser`);
247
+ > * `--open-browser` → 只在终端里保留显式语义(**压不过**非交互判定,CI 里写它也不弹)。
244
248
  > 所以 AI **不需要**自己判断"该不该开浏览器",但**永远要**把 `authorization_url` 原文转述给用户
245
- > (万一没弹出来,他要能手动打开)。
249
+ > (CI/非交互环境里,这一步就是**唯一**的交付方式)。
246
250
 
247
251
  **AI 的正确做法**:
248
252
 
@@ -250,6 +254,8 @@ npx -y wkgeo-cli@latest login --json --timeout 500ms # 也可以显式写毫
250
254
  2. 读到 `stage:"awaiting_callback"` 后:
251
255
  * **先看 `open_browser`**:`opened:true` = CLI 已经把系统浏览器弹出来了 → 把 `paste_text`
252
256
  **原样转述给用户**("已为你打开浏览器,请在那里完成授权…")即可,**不要**自己再开一遍;
257
+ * `skipped_reason:"non_interactive"`(CI / 管道 / AI 子进程)→ **CLI 没弹、也不该弹**:
258
+ 把 `authorization_url` / `paste_text` 清楚地转给有浏览器的用户;本机收不到回调时改用 `--device`;
253
259
  * `opened:false`(打开失败)→ 把那段**含完整地址**的提示念给用户,或**可选**地
254
260
  在你自己客户端的内置浏览器里打开 `authorization_url`(见 §5.5,这只是备选);
255
261
  * `skipped_reason:"no_browser"` → 用户/调用方明确不要弹窗,那就**只**把完整网址清楚地给用户;
@@ -412,17 +418,17 @@ npx -y wkgeo-cli@latest login --device-continue --json # 用户说"点好
412
418
 
413
419
  ### 5.5 打开授权页 / 收银台(**给 AI 端**)
414
420
 
415
- > ★ **2026-10 老板最终拍板走传统路线**(原话:「还是返回以前那种**打开跳到一个浏览器**,然后打开授权
416
- > 那个吧,**大家也都是那个的**……我们在右侧这个……能力还不会太完善……**还是走传统的路吧**」):
417
- > **CLI 默认就会尝试弹系统浏览器**(人在终端与 AI 场景都一样)。所以你的第一动作**不是**去开内置浏览器,
418
- > 而是**看 `open_browser` 字段 + 把 `paste_text` 念给用户**:
421
+ > ★ **2026-10-21 起:人类终端会自动弹系统浏览器;CI/非交互环境只输出链接**(老板真机反馈:
422
+ > 「别人在 CI 里执行 `wkgeo-cli` 的 login,默认弹出了系统默认浏览器」)。所以你的第一动作**不是**
423
+ > 去开内置浏览器,而是**看 `open_browser` 字段 + 把 `paste_text` 念给用户**:
419
424
 
420
425
  | `open_browser` | 你该做什么 |
421
426
  | --- | --- |
422
427
  | `opened: true` | ✅ CLI 已经把浏览器弹出来了 → 把 `paste_text` **原样**念给用户即可,**不要**再自己开一遍 |
423
- | `opened: false`(`attempted: true`) | ⚠️ 没能自动打开 → 把那段**含完整地址**的提示念给用户(或可选地在你的内置浏览器里打开,见下) |
428
+ | `skipped_reason: "non_interactive"` | 🖥️ 这是 CI / 管道 / AI 子进程:CLI **没弹**(也不该弹)→ 把 `authorization_url` / `paste_text` 清楚地交给有浏览器的用户;本机收不到回调就用 `login --device` |
429
+ | `opened: false`(`attempted: true`) | ⚠️ 真的尝试过但没能自动打开 → 把那段**含完整地址**的提示念给用户(或可选地在你的内置浏览器里打开,见下) |
424
430
  | `skipped_reason: "no_browser"` | 调用方/用户明确不要弹窗 → **只**把完整网址清楚地给用户,不要自己开浏览器 |
425
- | `skipped_reason: null` | 真的尝试过打开(成没成看 `opened`)—— 这是默认情况 |
431
+ | `skipped_reason: null` | 真的尝试过打开(成没成看 `opened`)—— 人类终端的默认情况 |
426
432
 
427
433
  **兜底手段(可选,**只在 CLI 没打开成功**时用)**——按 ① → ② → ③ 判断:
428
434
 
@@ -432,8 +438,9 @@ npx -y wkgeo-cli@latest login --device-continue --json # 用户说"点好
432
438
  ③ 都不行 → 把**完整地址**交给用户复制(说清动作是"复制到浏览器地址栏打开")
433
439
  ```
434
440
 
435
- * ⚠️ **这只是备选**:CLI 已经默认弹系统浏览器了,**不要**再叠一层"请在右侧…"的话术 ——
436
- 那会让用户不知道该看哪个窗口(老板要的就是"回到以前那种,打开跳到一个浏览器")。
441
+ * ⚠️ **这只是备选**:人类终端里 CLI 已经自动弹系统浏览器了,**不要**再叠一层"请在右侧…"的话术 ——
442
+ 那会让用户不知道该看哪个窗口。**CI / 非交互环境**里 CLI 不会弹(`skipped_reason:"non_interactive"`),
443
+ 这时把链接清楚地交给用户才是主路径。
437
444
  * ① **原生就支持**:直接打开,**不需要装任何东西**;已经原生支持"应用内侧边栏"的客户端
438
445
  (DSH 自带"点链接进右栏"),**让用户点一下链接**也算数。
439
446
  * ② **不支持 → 装桥接插件**(DSH 的 `dsh-browser-open`):
@@ -461,18 +468,21 @@ Set-Content -LiteralPath "$env:USERPROFILE\.dsh\browser-open.txt" -Value "<完
461
468
 
462
469
  | 运行场景 | CLI 会不会自动弹**系统**浏览器 |
463
470
  | --- | --- |
464
- | **默认(任何场景,含非 TTY / `--json`)** | ✅ **会尝试打开**(老板最终拍板走传统路线) |
465
- | 加了 `--no-browser` | ❌ **完全不尝试打开**(**唯一**开关,**优先级最高**,压过 `--open-browser`) |
466
- | 加了 `--open-browser` | ✅ 会(⚠️ 现在与默认行为**等价**,保留只为显式语义与兼容,**别删也别依赖它去"开启"**) |
471
+ | **人类终端**(stdout 是 TTY、无 CI 信号) | ✅ **会尝试打开** |
472
+ | **CI / 非交互**(stdout 非 TTY / 管道 / AI 子进程;`CI`、`GITHUB_ACTIONS`、`GITLAB_CI`、`JENKINS_URL`、`TF_BUILD`、`BUILDKITE`、`TEAMCITY_VERSION` 任一存在;`WKONGAI_CLI_NO_BROWSER=1`) | ❌ **绝不弹**(`skipped_reason:"non_interactive"`,只输出链接/JSON) |
473
+ | 加了 `--no-browser` | ❌ **不打开**(**优先级最高**,压过 `--open-browser`;`skipped_reason:"no_browser"`) |
474
+ | 加了 `--open-browser` | ✅ 只在**终端**里会(⚠️ 压不过非交互判定;别依赖它去"开启") |
467
475
 
468
476
  * **`--json` 里的结构化字段 `open_browser`**:`{attempted, opened, tried, url, skipped_reason}`
469
- (结构与取值口径**没有变**)—— `skipped_reason` 只有两种取值:
477
+ (结构与取值口径**没有变**)—— `skipped_reason` 有三种取值:
470
478
  * `null` = 真的尝试过打开(成没成看 `opened`);
479
+ * `"non_interactive"` = CI / 管道 / AI 子进程,**没弹**(也不该弹);
471
480
  * `"no_browser"` = 调用方/用户明确要求别打开。
472
481
  * ⚠️ 历史取值 `"non_tty_agent_mode"`(2026-10-01 那版"AI 场景默认不弹")**已弃用,不会再出现**
473
482
  —— 看到它说明你在读旧文档/旧版本的输出。
474
483
  * 同一条事件/数据里还有 **`paste_text`**(`login` 的 `awaiting_callback` 事件、`wallet recharge` 的 `data`):
475
- **原样念给用户**,不要自己改写(里面已经含完整网址 + "已为你打开浏览器…"的口径)。
484
+ **原样念给用户**,不要自己改写。⚠️ 非交互时它是「请打开以下链接完成授权:<url>」,
485
+ **不会**说"已为你打开浏览器"(因为确实没弹)——照念即可,别自己补一句"已打开"。
476
486
  * ⚠️ **不弹浏览器 ≠ 失败**,**打开失败也 ≠ 失败**:流程照旧等回调、照旧返回 `pay_url`;
477
487
  CLI **绝不会**因为"没打开浏览器"而让命令失败 —— 你只要把地址送到用户眼前就对了。
478
488
 
@@ -552,9 +562,10 @@ npx -y wkgeo-cli@latest wallet recharge --amount-cents 10000 # 生
552
562
  # (老板:"以那个 token 为基础来进行支付,而不是还要他用用户名那一套")。
553
563
  # ⚠️ 老服务端没有这个字段时 `pay_url` 就只有 `order_id`,那条路仍然要求网页登录。
554
564
  # ⚠️ **原样打开 data.pay_url**,不要自己拆参/重拼(尾斜杠与编码都有讲究)。
555
- # ★ 打开方式见 §5.5:★ **CLI 默认就会尝试弹系统浏览器**(老板最终拍板走传统路线),
565
+ # ★ 打开方式见 §5.5:**人类终端**里 CLI 会尝试弹系统浏览器;**CI/非交互**只输出链接,
556
566
  # 你只要看 data.open_browser:opened:true → 把 data.paste_text(已含完整地址 +
557
- # "已为你打开浏览器…")原样念给用户即可;opened:false → 念那段**含完整地址**的提示,
567
+ # "已为你打开浏览器…")原样念给用户即可;skipped_reason:"non_interactive" → CLI 没弹,
568
+ # 把 data.pay_url 交给用户;opened:false → 念那段**含完整地址**的提示,
558
569
  # 或可选地在自己的内置浏览器里打开 data.pay_url。
559
570
  # ⚠️ `data.open_browser.skipped_reason = "no_browser"` = 调用方明确要求别弹(那就只给地址)。
560
571
  # ★ **支付成功后那个页面会自己关掉**(收银台会尝试 `window.close()`):
@@ -572,10 +583,10 @@ npx -y wkgeo-cli@latest orders check <order_id> # 用
572
583
  **原样打开**即可,**不要**把 `token` 单独摘出来展示、更不要放进对话正文里当"验证码"念给用户;
573
584
  * 它**只**授权"读这一单的支付参数":读不到钱包、读不到订单列表、不能下单、不能取消,
574
585
  而且 30 分钟(= 订单有效期)后自动失效(失效时页面会明确说"支付链接已过期,请重新下单")。
575
- ⚠️ 无桌面环境(容器/CI)加 `--no-browser`:**完全不尝试**打开浏览器(`open_browser.skipped_reason = "no_browser"`),`pay_url` 照常输出。
576
- ⚠️ `--open-browser` **保留但已与默认行为等价**(默认就会尝试弹系统浏览器);想"不弹"只能用 `--no-browser`。
577
- ⚠️ 打开 `pay_url` 的方式见 **§5.5**:CLI **默认就会尝试弹系统浏览器**(老板最终拍板走传统路线);
578
- 只有它**没打开成功**时,才考虑可选地在客户端**内置浏览器 / 侧边面板**里打开。
586
+ ⚠️ 非交互/CI 环境(容器、管道、AI 子进程)**本来就不会弹**:`open_browser.skipped_reason = "non_interactive"`,`pay_url` 照常输出;
587
+ ⚠️ 终端里 `--open-browser` 保留显式语义(**非交互/CI 仍不弹**);要显式不弹用 `--no-browser`(`skipped_reason = "no_browser"`,优先级最高)。
588
+ ⚠️ 打开 `pay_url` 的方式见 **§5.5**:人类终端会自动弹系统浏览器;
589
+ 只有它**没打开成功**(或非交互环境)时,才考虑可选地在客户端**内置浏览器 / 侧边面板**里打开。
579
590
 
580
591
  #### 7.2.1 另一种:**让用户自己选金额**(`wallet recharge-link`,独立充值页)
581
592
 
@@ -592,7 +603,7 @@ npx -y wkgeo-cli@latest wallet recharge-link # 默认不弹浏览器
592
603
  # ★ 有效期只有 10 分钟、且**用掉即失效**(一次性):过期/已用过时页面会明确提示"请重新获取",
593
604
  # 这时**重新跑一次本命令**拿一条新链接即可(不要反复让用户刷新旧链接)。
594
605
  # ★ 默认**不**打开浏览器(这条命令通常跑在服务器/容器里,且交接物是链接);
595
- # 确实要在本机弹出时加 --open-browser;--no-browser 优先级最高。
606
+ # 确实要在**人类终端**里弹出时加 --open-browser(CI/非交互仍不弹);--no-browser 优先级最高。
596
607
  # ★ ticket 只是"建一笔充值单"的窄凭证:⛔ 不要把它当登录凭据、不要拿它去调别的 action。
597
608
  npx -y wkgeo-cli@latest orders check <order_id> # 用户说付完了再查(order_id 从他那句"付完了"对应的单拿)
598
609
  ```
@@ -70,10 +70,11 @@ export function resolveAutoDevice(flags = {}, env = {}) {
70
70
  * ⇒ 本函数**不再**按"有没有代理"给默认值:用户没显式指定时**一律返回 0**。
71
71
  * 也就是说 `wkgeo login` 默认会一路等回调到 `--timeout`,**不会**因为等太久自动改设备码。
72
72
  *
73
- * 设备码只在这三种情况出现(见 loginCommand.run):
73
+ * 设备码只在这两种情况出现(见 loginCommand.run):
74
74
  * 1. 用户显式 `--device` / `--no-wait` / `--device-continue`;
75
- * 2. 检测到系统代理且判定回调不可用(`--no-auto-device` 可关);
76
- * 3. 本地回调端口**真的起不来**(`kind = 'listen'`,真实失败兜底;`--no-auto-device` 可关)。
75
+ * 2. 检测到系统代理且判定回调不可用(`--no-auto-device` 可关)。
76
+ * ⚠️ 0.2.4 起:**本地回调端口起不来不再自动改设备码**(老板反馈"服务没起来却换了模式"),
77
+ * 而是如实报 `kind:"listen"` 并在 hint 里明确建议用户显式加 `--device`。
77
78
  *
78
79
  * ## 保留的显式开关(向后兼容,不参与默认判断)
79
80
  * `--callback-first-wait <时长>`(默认单位**秒**;`0` / `off` / `none` = 关闭)
@@ -192,9 +193,7 @@ function bothPathsFailedError(deviceError, reason, { loginTimeoutMs }) {
192
193
  const callbackSide =
193
194
  reason === 'callback_timeout'
194
195
  ? `等待授权超时(${loginTimeoutMs}ms,本机回调监听当时仍然开着)`
195
- : reason === 'callback_listen_failed'
196
- ? '本地回调端口起不来(根本没起监听)'
197
- : '首轮等待没收到回调(本机回调监听当时仍然开着)';
196
+ : '首轮等待没收到回调(本机回调监听当时仍然开着)';
198
197
  return new CliError(`本地回调没成功,改用设备码也失败了:${deviceError.message}`, {
199
198
  kind: deviceError.kind || 'device_authorization_failed',
200
199
  code: deviceError.code === undefined ? null : deviceError.code,
@@ -216,28 +215,28 @@ const TERMINAL_DEVICE_KINDS = new Set(['denied', 'expired', 'not_implemented', '
216
215
 
217
216
  export const loginCommand = {
218
217
  name: 'login',
219
- summary: '浏览器授权一次,把 30 天令牌存到本地(默认本地回调模式;设备码仅在本地无法回调/检测到代理时兜底)',
218
+ summary: '浏览器授权一次,把 30 天令牌存到本地(默认本地回调模式;设备码仅在显式 --device / 检测到系统代理时使用)',
220
219
  usage:
221
220
  'wkgeo login [--scope "site:read site:write billing:self"] [--no-browser] [--open-browser] [--client-id <id>] [--callback-port <n>] [--timeout <秒>]\n' +
222
221
  'wkgeo login --device # 设备码模式:不需要回调地址/监听端口,给用户一个短码(兜底路径)\n' +
223
222
  'wkgeo login --device --no-wait # ★ 只申请短码就退出(AI 端友好),稍后用下面这条领令牌\n' +
224
223
  'wkgeo login --device-continue # 用上次保存的设备码换票、写完凭据就完成登录\n' +
225
- 'wkgeo login --no-auto-device # 检测到系统代理也不自动改设备码(强制回调;CLI 会警告;端口真起不来也不兜底)\n' +
224
+ 'wkgeo login --no-auto-device # 检测到系统代理也不自动改设备码(强制回调;CLI 会警告)\n' +
226
225
  'wkgeo login --open-browser # 强制尝试打开系统浏览器(⚠️ 现在与默认行为等价,保留只为显式语义与兼容)\n' +
227
226
  'wkgeo login --callback-first-wait 60 # ★ 可选:显式启用"首轮等待 60 秒没回调就并行改设备码"(默认关闭;0/off = 关闭)\n' +
228
227
  'wkgeo login --timeout 600 # 等待授权 600 秒(默认单位是秒,不是毫秒;也支持 600s / 500ms)',
229
228
  flags: {
230
- browser: { type: 'boolean', desc: '是否自动打开浏览器(★ 默认总是尝试打开:终端与 AI 场景都一样;--no-browser 关闭)' },
229
+ browser: { type: 'boolean', desc: '是否自动打开浏览器(★ 默认:人类终端打开;CI/管道/AI 子进程等非交互环境不打开;--no-browser 关闭)' },
231
230
  /**
232
- * ★ 老板最终拍板走**传统路线**(弹系统浏览器):CLI **默认总是尝试打开**,
233
- * 唯一的"不打开"开关是 `--no-browser`。
234
- * 本开关现在**与默认行为等价**,保留只为两件事:
235
- * 1. **显式语义**("我就是要弹,你别管");
231
+ * ★ 2026-10-21(老板反馈:CI 里弹了浏览器):默认**只在人类终端**自动打开;
232
+ * 非交互/CI 环境(stdout 非 TTY / CI 环境变量 / `WKONGAI_CLI_NO_BROWSER=1`)**一律不打开**。
233
+ * 本开关保留只为两件事:
234
+ * 1. **显式语义**(终端里明确表达"我就是要弹");
236
235
  * 2. **兼容**既有脚本(不许删)。
237
- * ⚠️ 与 `--no-browser` 同时出现时 **--no-browser 优先**(关掉就是关掉,见 resolveBrowserIntent)。
236
+ * ⚠️ 它**压不过**非交互判定(CI 里写它也不弹);与 `--no-browser` 同时出现时 `--no-browser` 优先。
238
237
  */
239
- 'open-browser': { type: 'boolean', desc: '强制尝试打开系统浏览器(⚠️ 现在与默认行为等价,保留只为显式语义与兼容)' },
240
- device: { type: 'boolean', desc: '用设备码模式(RFC 8628)代替本地回调:不需要任何回调地址,对代理/防火墙免疫(本地读不了回调时的兜底)' },
238
+ 'open-browser': { type: 'boolean', desc: '强制尝试打开系统浏览器(⚠️ 仅在人类终端生效;CI/非交互环境仍不打开)' },
239
+ device: { type: 'boolean', desc: '用设备码模式(RFC 8628)代替本地回调:不需要任何回调地址,对代理/防火墙免疫(显式兜底;CLI 不会在端口失败时自动切换过来)' },
241
240
  /**
242
241
  * ★ 老板 2026-10-02 要的"AI 端友好路径"。
243
242
  * 为什么做成"申请完就退出"而不是"申请完继续挂着轮询"(后者靠 JSONL 事件流也能做):
@@ -250,7 +249,7 @@ export const loginCommand = {
250
249
  'device-continue': { type: 'boolean', desc: '继续上一次 --no-wait/被中断的设备码登录(读取本地保存的设备码并轮询到出结果)' },
251
250
  'auto-device': {
252
251
  type: 'boolean',
253
- desc: '检测到系统代理时自动改用设备码(**默认开**;--no-auto-device 或 WKONGAI_AUTO_DEVICE=0 关闭,关闭时会警告"代理可能吃掉回调",且端口真起不来时也不兜底)',
252
+ desc: '检测到系统代理时自动改用设备码(**默认开**;--no-auto-device 或 WKONGAI_AUTO_DEVICE=0 关闭,关闭时会警告"代理可能吃掉回调")',
254
253
  },
255
254
  /**
256
255
  * ★ 可选自愈(默认**关闭**,0.2.2 起)。
@@ -345,7 +344,7 @@ export const loginCommand = {
345
344
  */
346
345
  const proxyDetected = !!(proxyInfo && proxyInfo.detected);
347
346
  let useDevice = wantsDevice;
348
- /** 为什么会走设备码:proxy_detected / callback_listen_failed / callback_first_wait(仅显式开启时) */
347
+ /** 为什么会走设备码:proxy_detected / callback_first_wait(仅显式开启时) */
349
348
  let downgradeReason = null;
350
349
  if (!useDevice && autoDevice && proxyDetected) {
351
350
  useDevice = true;
@@ -374,20 +373,23 @@ export const loginCommand = {
374
373
  /** 真 spawn 的注入点(单测验证"AI 场景一次都不 spawn 浏览器";生产不传 = 真 spawn) */
375
374
  spawnImpl: ctx.spawnImpl,
376
375
  /**
377
- * ★★ 「要不要替用户弹**系统**浏览器」的四个事实(老板最终拍板走**传统路线**):
376
+ * ★★ 「要不要替用户弹**系统**浏览器」的输入(2026-10-21 起含 CI/非交互检测):
378
377
  * * `isTTY` —— stdout 是不是终端(由 runtime 从 stdout.isTTY 读出,命令层原样透传);
379
378
  * * `json` —— 是不是 JSON 输出(`--json` 或非 TTY 自动 JSON);
380
- * * `forceBrowser` —— 用户写了 `--open-browser`(现在与默认行为等价,保留显式语义);
381
- * * `noBrowser` —— 用户写了 `--no-browser`(**唯一**的"不打开"开关,优先级最高)。
382
- * 四个一起进 `resolveBrowserIntent()`(login.mjs 里的**唯一裁决点**):
383
- * **默认总是尝试打开系统浏览器**(人在终端 ✔ + AI 场景 ✔);
384
- * `isTTY` / `json` 现在只影响 `mode` 这个诊断字段,**不再**决定"要不要弹"。
379
+ * * `forceBrowser` —— 用户写了 `--open-browser`(只在终端里保留显式语义);
380
+ * * `noBrowser` —— 用户写了 `--no-browser`(**最高优先级**的"不打开"开关);
381
+ * * `env` —— CI 环境变量判定(`CI` / `GITHUB_ACTIONS`… / `WKONGAI_CLI_NO_BROWSER=1`)。
382
+ * 全部进 `resolveBrowserIntent()`(login.mjs 里的**唯一裁决点**):
383
+ * **人在终端默认打开**;`--no-browser` 与**非交互/CI**都不打开(`attempted:false`);
384
+ * 非交互时还会打印"把链接转给有浏览器的用户 / 改用 `--device`"的提示。
385
385
  * ⚠️ 回调模式与设备码模式都吃这一份 deps —— 所以两条授权路径的行为天然一致。
386
386
  */
387
387
  isTTY: ctx.isTTY === true,
388
388
  json: ctx.out.json === true,
389
389
  forceBrowser: flags.openBrowser === true,
390
390
  noBrowser: flags.browser === false,
391
+ /** ★ CI / 非交互判定用(纯函数在 login.mjs 的 detectNonInteractive) */
392
+ env: ctx.env || {},
391
393
  platform: ctx.platform,
392
394
  /**
393
395
  * ★ 可选首轮等待(见上面 `callbackFirstWaitMs`)。
@@ -411,7 +413,7 @@ export const loginCommand = {
411
413
  *
412
414
  * 老板原话:「默认的也不应该是设备授权,默认的就是那个传统的授权方式(浏览器打开授权页 +
413
415
  * 本地 localhost 回调)。设备码是本地读不了/代理时才用的。」
414
- * ⇒ 这里明说"本次就是回调、等多久都不会自动改设备码",同时列出**唯一会走设备码**的三种情况,
416
+ * ⇒ 这里明说"本次就是回调、等多久都不会自动改设备码",同时列出**唯一会走设备码**的两种情况,
415
417
  * 并给出"主动提前切换"的显式开关,免得用户以为 CLI 背着他改道。
416
418
  * ⚠️ 只在"回调模式 + 没检测到代理 + 没显式开首轮等待"时打:其它情况一律不打(正常环境不要噪音)。
417
419
  */
@@ -420,10 +422,12 @@ export const loginCommand = {
420
422
  `ℹ️ 未检测到系统代理:本次使用浏览器回调授权(默认),会一直等你完成授权(最多 ${Math.round(config.loginTimeoutMs / 1000)} 秒)。`,
421
423
  );
422
424
  log(
423
- ' 不会因为"首轮等待/等待超时"到点就自动改用设备码;只有显式 `--device`、检测到系统代理、' +
424
- '或本地回调端口真的起不来时才会用设备码(`--no-auto-device` 可关掉后两种自动兜底)。',
425
+ ' 不会因为"首轮等待/等待超时"到点就自动改用设备码;只有显式 `--device` 或检测到系统代理时才会用设备码' +
426
+ '(`--no-auto-device` 可关掉代理这一种自动切换)。',
427
+ );
428
+ log(
429
+ ' (本地回调端口起不来会**如实报错**并建议 `--device`,不会静默换模式;想主动提前切换:`--callback-first-wait <秒>`。)',
425
430
  );
426
- log(' (想主动提前切换:`--callback-first-wait <秒>`;想直接走设备码:`--device`。)');
427
431
  }
428
432
 
429
433
  /**
@@ -663,13 +667,21 @@ export const loginCommand = {
663
667
  handoff,
664
668
  onAuthorizationUrl: (url, extra) => {
665
669
  if (jsonMode) {
670
+ /**
671
+ * ★ 非交互(CI / 管道 / AI 子进程)时**不会**自动打开浏览器 ⇒ 给 AI/脚本的
672
+ * `user_action_required` 必须换成"把链接转给有浏览器的人 / 改用设备码",
673
+ * 否则调用方会以为"浏览器已经弹出来了",只让用户干等。
674
+ */
675
+ const nonInteractive = !!(extra && extra.open_browser && extra.open_browser.skipped_reason === 'non_interactive');
666
676
  out.event({
667
677
  ok: true,
668
678
  command: 'login',
669
679
  mode: 'callback',
670
680
  stage: 'awaiting_callback',
671
681
  authorization_url: url,
672
- user_action_required: '请让用户在浏览器中打开上面的 authorization_url,登录并点「同意」',
682
+ user_action_required: nonInteractive
683
+ ? '本机是非交互环境(CI/管道/AI 子进程),不会自动打开浏览器:请把 authorization_url 转给有浏览器的用户打开;若本机无法接收回调,改用 `wkgeo login --device`(设备码模式)'
684
+ : '请让用户在浏览器中打开上面的 authorization_url,登录并点「同意」',
673
685
  proxy_warning: proxyPayload,
674
686
  ...extra,
675
687
  });
@@ -683,35 +695,18 @@ export const loginCommand = {
683
695
  /** 设备码那条路也失败了 ⇒ 优先报"两条路都不通"(哪怕回调那边先抛了自己的错) */
684
696
  if (deviceSide.combinedError) throw deviceSide.combinedError;
685
697
  /**
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 不匹配…)本来就不降级。
698
+ * ★★ 0.2.4(2026-10-21 老板真机反馈):**bind 失败不再静默改走设备码**。
699
+ *
700
+ * 旧行为(0.2.2~0.2.3):本地回调端口起不来 → 命令层自动申请设备码继续授权。
701
+ * 现场问题是:用户明明要的是"本地服务 + 浏览器回调",CLI 却在他不知情时换了
702
+ * 另一套授权协议(设备码),127.0.0.1 回调页始终没有出现,看起来就是"本地服务
703
+ * 没启动、浏览器也没东西可打开"。
704
+ *
705
+ * 新口径:**如实失败**,把 `--device` 作为明确的下一步建议交给用户/调用方
706
+ * (`runLogin` 抛的 `kind:"listen"` 错误 hint 里已经写了)。
707
+ * 设备码只在这两种情况下出现:用户显式 `--device` / 检测到系统代理。
694
708
  */
695
- const canSwitch = autoDevice && !wantsDevice && e.kind === 'listen';
696
- if (!canSwitch) throw e;
697
- log('');
698
- log(`⚠️ 本地回调端口起不来(${e.message}):这是"本地读不了回调"的真实失败,改用【设备码模式】继续授权。`);
699
- log('ℹ️ 设备码不需要本地监听(常见原因是端口被安全软件/防火墙拦);授权页照旧点「同意」即可。');
700
- let device;
701
- try {
702
- device = await requestDeviceCode({ config, deps: loginDeps });
703
- } catch (e2) {
704
- throw bothPathsFailedError(e2, 'callback_listen_failed', {
705
- loginTimeoutMs: config.loginTimeoutMs,
706
- });
707
- }
708
- savePending(device);
709
- announceDeviceCode({ config, log, deps: deviceDeps({ no_wait: false, proxy_warning: proxyPayload }), device });
710
- // 这一次再失败就如实抛出(不无限重试:两条路都失败,用户该看到真原因了)
711
- result = await pollDeviceToken({ config, log, deps: loginDeps, device });
712
- deletePendingDevice(pendingFile);
713
- useDevice = true;
714
- downgradeReason = 'callback_listen_failed';
709
+ throw e;
715
710
  }
716
711
 
717
712
  if (deviceSide.reason) {
@@ -781,8 +776,6 @@ export const loginCommand = {
781
776
  out.info(' (本次因"检测到系统代理"自动改用了设备码模式:不再依赖 127.0.0.1 回调,绕过列表写了什么也不作依据。)');
782
777
  if (downgradeReason === 'callback_first_wait')
783
778
  out.info(' (已按显式 --callback-first-wait:首轮等待没收到回调,已自动改用设备码模式完成登录。)');
784
- if (downgradeReason === 'callback_listen_failed')
785
- out.info(' (本地回调端口起不来,已自动改用设备码模式完成登录。)');
786
779
  }
787
780
  return data;
788
781
  },