wkgeo-cli 0.2.2 → 0.2.3

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
  ```
@@ -227,16 +227,16 @@ export const loginCommand = {
227
227
  'wkgeo login --callback-first-wait 60 # ★ 可选:显式启用"首轮等待 60 秒没回调就并行改设备码"(默认关闭;0/off = 关闭)\n' +
228
228
  'wkgeo login --timeout 600 # 等待授权 600 秒(默认单位是秒,不是毫秒;也支持 600s / 500ms)',
229
229
  flags: {
230
- browser: { type: 'boolean', desc: '是否自动打开浏览器(★ 默认总是尝试打开:终端与 AI 场景都一样;--no-browser 关闭)' },
230
+ browser: { type: 'boolean', desc: '是否自动打开浏览器(★ 默认:人类终端打开;CI/管道/AI 子进程等非交互环境不打开;--no-browser 关闭)' },
231
231
  /**
232
- * ★ 老板最终拍板走**传统路线**(弹系统浏览器):CLI **默认总是尝试打开**,
233
- * 唯一的"不打开"开关是 `--no-browser`。
234
- * 本开关现在**与默认行为等价**,保留只为两件事:
235
- * 1. **显式语义**("我就是要弹,你别管");
232
+ * ★ 2026-10-21(老板反馈:CI 里弹了浏览器):默认**只在人类终端**自动打开;
233
+ * 非交互/CI 环境(stdout 非 TTY / CI 环境变量 / `WKONGAI_CLI_NO_BROWSER=1`)**一律不打开**。
234
+ * 本开关保留只为两件事:
235
+ * 1. **显式语义**(终端里明确表达"我就是要弹");
236
236
  * 2. **兼容**既有脚本(不许删)。
237
- * ⚠️ 与 `--no-browser` 同时出现时 **--no-browser 优先**(关掉就是关掉,见 resolveBrowserIntent)。
237
+ * ⚠️ 它**压不过**非交互判定(CI 里写它也不弹);与 `--no-browser` 同时出现时 `--no-browser` 优先。
238
238
  */
239
- 'open-browser': { type: 'boolean', desc: '强制尝试打开系统浏览器(⚠️ 现在与默认行为等价,保留只为显式语义与兼容)' },
239
+ 'open-browser': { type: 'boolean', desc: '强制尝试打开系统浏览器(⚠️ 仅在人类终端生效;CI/非交互环境仍不打开)' },
240
240
  device: { type: 'boolean', desc: '用设备码模式(RFC 8628)代替本地回调:不需要任何回调地址,对代理/防火墙免疫(本地读不了回调时的兜底)' },
241
241
  /**
242
242
  * ★ 老板 2026-10-02 要的"AI 端友好路径"。
@@ -374,20 +374,23 @@ export const loginCommand = {
374
374
  /** 真 spawn 的注入点(单测验证"AI 场景一次都不 spawn 浏览器";生产不传 = 真 spawn) */
375
375
  spawnImpl: ctx.spawnImpl,
376
376
  /**
377
- * ★★ 「要不要替用户弹**系统**浏览器」的四个事实(老板最终拍板走**传统路线**):
377
+ * ★★ 「要不要替用户弹**系统**浏览器」的输入(2026-10-21 起含 CI/非交互检测):
378
378
  * * `isTTY` —— stdout 是不是终端(由 runtime 从 stdout.isTTY 读出,命令层原样透传);
379
379
  * * `json` —— 是不是 JSON 输出(`--json` 或非 TTY 自动 JSON);
380
- * * `forceBrowser` —— 用户写了 `--open-browser`(现在与默认行为等价,保留显式语义);
381
- * * `noBrowser` —— 用户写了 `--no-browser`(**唯一**的"不打开"开关,优先级最高)。
382
- * 四个一起进 `resolveBrowserIntent()`(login.mjs 里的**唯一裁决点**):
383
- * **默认总是尝试打开系统浏览器**(人在终端 ✔ + AI 场景 ✔);
384
- * `isTTY` / `json` 现在只影响 `mode` 这个诊断字段,**不再**决定"要不要弹"。
380
+ * * `forceBrowser` —— 用户写了 `--open-browser`(只在终端里保留显式语义);
381
+ * * `noBrowser` —— 用户写了 `--no-browser`(**最高优先级**的"不打开"开关);
382
+ * * `env` —— CI 环境变量判定(`CI` / `GITHUB_ACTIONS`… / `WKONGAI_CLI_NO_BROWSER=1`)。
383
+ * 全部进 `resolveBrowserIntent()`(login.mjs 里的**唯一裁决点**):
384
+ * **人在终端默认打开**;`--no-browser` 与**非交互/CI**都不打开(`attempted:false`);
385
+ * 非交互时还会打印"把链接转给有浏览器的用户 / 改用 `--device`"的提示。
385
386
  * ⚠️ 回调模式与设备码模式都吃这一份 deps —— 所以两条授权路径的行为天然一致。
386
387
  */
387
388
  isTTY: ctx.isTTY === true,
388
389
  json: ctx.out.json === true,
389
390
  forceBrowser: flags.openBrowser === true,
390
391
  noBrowser: flags.browser === false,
392
+ /** ★ CI / 非交互判定用(纯函数在 login.mjs 的 detectNonInteractive) */
393
+ env: ctx.env || {},
391
394
  platform: ctx.platform,
392
395
  /**
393
396
  * ★ 可选首轮等待(见上面 `callbackFirstWaitMs`)。
@@ -663,13 +666,21 @@ export const loginCommand = {
663
666
  handoff,
664
667
  onAuthorizationUrl: (url, extra) => {
665
668
  if (jsonMode) {
669
+ /**
670
+ * ★ 非交互(CI / 管道 / AI 子进程)时**不会**自动打开浏览器 ⇒ 给 AI/脚本的
671
+ * `user_action_required` 必须换成"把链接转给有浏览器的人 / 改用设备码",
672
+ * 否则调用方会以为"浏览器已经弹出来了",只让用户干等。
673
+ */
674
+ const nonInteractive = !!(extra && extra.open_browser && extra.open_browser.skipped_reason === 'non_interactive');
666
675
  out.event({
667
676
  ok: true,
668
677
  command: 'login',
669
678
  mode: 'callback',
670
679
  stage: 'awaiting_callback',
671
680
  authorization_url: url,
672
- user_action_required: '请让用户在浏览器中打开上面的 authorization_url,登录并点「同意」',
681
+ user_action_required: nonInteractive
682
+ ? '本机是非交互环境(CI/管道/AI 子进程),不会自动打开浏览器:请把 authorization_url 转给有浏览器的用户打开;若本机无法接收回调,改用 `wkgeo login --device`(设备码模式)'
683
+ : '请让用户在浏览器中打开上面的 authorization_url,登录并点「同意」',
673
684
  proxy_warning: proxyPayload,
674
685
  ...extra,
675
686
  });
@@ -16,7 +16,7 @@
16
16
  import { withTrailingSlash } from '../config.mjs';
17
17
  import { UsageError, CliError, EXIT } from '../errors.mjs';
18
18
  import { formatMoney, formatTime } from '../format.mjs';
19
- import { openUrlForUser, browserPasteText, browserOpenedText, browserFailedHintLines } from '../login.mjs';
19
+ import { openUrlForUser, browserPasteText, browserOpenedText, browserFailedHintLines, detectNonInteractive, nonInteractiveBrowserHintLines } from '../login.mjs';
20
20
  import { renderQrOptional } from '../qr.mjs';
21
21
 
22
22
  /**
@@ -266,13 +266,12 @@ export const walletRechargeCommand = {
266
266
  'amount-cents': { type: 'string', required: true, desc: '充值金额,单位**分**(最低 990 = ¥9.90;**不设上限**)' },
267
267
  'no-browser': { type: 'boolean', desc: '不自动打开浏览器(自动化/无桌面环境用;地址仍会打印并放在 pay_url 里)' },
268
268
  /**
269
- * ★ 老板最终拍板走**传统路线**:默认**总是尝试打开**系统浏览器(人在终端与 AI 场景都一样),
270
- * 所以本开关现在**与默认行为等价**。保留它只为两件事:
271
- * 1. **显式语义**(脚本里写"我就是要弹"比依赖默认值更清楚);
272
- * 2. **兼容**既有脚本(不许删)。
273
- * ⚠️ `--no-browser` 优先(关掉就是关掉,见 login.mjs 的 resolveBrowserIntent)。
269
+ * ★ 2026-10-21(老板反馈:CI 里弹了浏览器):默认**只在人类终端**自动打开;
270
+ * 非交互/CI 环境(stdout 非 TTY / CI 环境变量 / `WKONGAI_CLI_NO_BROWSER=1`)**一律不打开**。
271
+ * 本开关保留只为显式语义与兼容(⚠️ 它压不过非交互判定)。
272
+ * `--no-browser` 优先(关掉就是关掉,见 login.mjs 的 resolveBrowserIntent)。
274
273
  */
275
- 'open-browser': { type: 'boolean', desc: '强制尝试打开系统浏览器(⚠️ 现在与默认行为等价,保留只为显式语义与兼容)' },
274
+ 'open-browser': { type: 'boolean', desc: '强制尝试打开系统浏览器(⚠️ 仅在人类终端生效;CI/非交互环境仍不打开)' },
276
275
  'dry-run': { type: 'boolean', desc: '只打印将要做什么,不真的创建订单' },
277
276
  },
278
277
  async run(ctx) {
@@ -364,15 +363,20 @@ export const walletRechargeCommand = {
364
363
  spawnImpl: ctx.spawnImpl,
365
364
  platform: ctx.platform,
366
365
  verbose: !!(ctx.config && ctx.config.verbose),
367
- /** ★ 与 login 同源的四个事实(见 login.mjs 的 resolveBrowserIntent) */
366
+ /** ★ 与 login 同源的多个事实(见 login.mjs 的 resolveBrowserIntent / detectNonInteractive) */
368
367
  isTTY: ctx.isTTY === true,
369
368
  json: out.json === true,
370
369
  forceOpen: flags.openBrowser === true,
371
370
  noBrowser: flags.noBrowser === true,
371
+ /** CI / 非交互判定(`CI` / `GITHUB_ACTIONS`… / `WKONGAI_CLI_NO_BROWSER=1`) */
372
+ env: ctx.env || {},
372
373
  },
373
374
  log: (m) => out.info(m),
374
375
  });
375
- data.paste_text = browserPasteText(payUrl, { kind: 'pay' });
376
+ data.paste_text = browserPasteText(payUrl, {
377
+ kind: 'pay',
378
+ nonInteractive: data.open_browser.skipped_reason === 'non_interactive',
379
+ });
376
380
  }
377
381
 
378
382
  if (!out.json) {
@@ -386,6 +390,13 @@ export const walletRechargeCommand = {
386
390
  out.line(` ${payUrl}`);
387
391
  if (flags.noBrowser === true) {
388
392
  out.info(' (已按 --no-browser 跳过自动打开)请把上面这一整行复制到浏览器地址栏打开。');
393
+ } else if (data.open_browser.skipped_reason === 'non_interactive') {
394
+ /**
395
+ * ★ 2026-10-21:非交互/CI 里**不弹**,给出可照做的下一步(收银台链接转给用户)。
396
+ * 绝不能走下面的"打开失败"提示 —— 我们**根本没试**,报失败会误导排障。
397
+ */
398
+ const reason = detectNonInteractive({ isTTY: ctx.isTTY === true, env: ctx.env || {} }).reason;
399
+ for (const line of nonInteractiveBrowserHintLines({ kind: 'pay', reason, cmdName: 'wkgeo', url: payUrl })) out.info(line);
389
400
  } else if (data.open_browser.opened) {
390
401
  // ★ 成功:明说"已经打开了"(老板最终拍板走传统路线:弹系统浏览器)
391
402
  out.line(browserOpenedText({ kind: 'pay' }));
@@ -450,6 +461,10 @@ export const walletRechargeCommand = {
450
461
  */
451
462
  if (flags.noBrowser === true) {
452
463
  out.notice('(已按 --no-browser 跳过自动打开)请手动复制 pay_url 到浏览器地址栏打开。');
464
+ } else if (data.open_browser.skipped_reason === 'non_interactive') {
465
+ // ★ 非交互/CI:没弹 ≠ 失败。把"链接交给用户"说清楚(stdout 仍然只有 JSON)。
466
+ const reason = detectNonInteractive({ isTTY: ctx.isTTY === true, env: ctx.env || {} }).reason;
467
+ for (const line of nonInteractiveBrowserHintLines({ kind: 'pay', reason, cmdName: 'wkgeo', url: payUrl })) out.notice(line);
453
468
  } else if (data.open_browser.opened) {
454
469
  out.notice(browserOpenedText({ kind: 'pay' }));
455
470
  } else {
@@ -584,11 +599,13 @@ export const walletRechargeLinkCommand = {
584
599
  spawnImpl: ctx.spawnImpl,
585
600
  platform: ctx.platform,
586
601
  verbose: !!(ctx.config && ctx.config.verbose),
587
- /** 与 login / wallet recharge 同源的四个事实(见 resolveBrowserIntent) */
602
+ /** 与 login / wallet recharge 同源的多个事实(见 resolveBrowserIntent / detectNonInteractive) */
588
603
  isTTY: ctx.isTTY === true,
589
604
  json: out.json === true,
590
605
  forceOpen: true,
591
606
  noBrowser: flags.noBrowser === true,
607
+ /** ★ CI / 非交互判定:CI 里即使写了 --open-browser 也不弹(见 login.mjs) */
608
+ env: ctx.env || {},
592
609
  },
593
610
  log: (m) => out.info(m),
594
611
  });
@@ -607,6 +624,10 @@ export const walletRechargeLinkCommand = {
607
624
  out.info(' (不需要登录会员中心;本命令不会代替他付款,也不会模拟支付成功。)');
608
625
  if (flags.noBrowser === true) {
609
626
  out.info(' (已按 --no-browser 跳过自动打开)');
627
+ } else if (data.open_browser.skipped_reason === 'non_interactive') {
628
+ // ★ 非交互/CI:明确说"没弹",并让用户把链接发给有浏览器的人(不是打开失败)
629
+ const reason = detectNonInteractive({ isTTY: ctx.isTTY === true, env: ctx.env || {} }).reason;
630
+ for (const line of nonInteractiveBrowserHintLines({ kind: 'pay', reason, cmdName: 'wkgeo', url: payUrl })) out.info(line);
610
631
  } else if (data.open_browser.attempted === false) {
611
632
  out.info(' (默认不自动打开浏览器;需要在这台机器上弹出,请加 --open-browser)');
612
633
  } else if (data.open_browser.opened) {
@@ -626,6 +647,9 @@ export const walletRechargeLinkCommand = {
626
647
  out.notice(
627
648
  '(默认不自动打开浏览器;需要在这台机器上弹出请加 --open-browser。把上面的链接发给用户即可。)',
628
649
  );
650
+ } else if (data.open_browser.skipped_reason === 'non_interactive') {
651
+ const reason = detectNonInteractive({ isTTY: ctx.isTTY === true, env: ctx.env || {} }).reason;
652
+ for (const line of nonInteractiveBrowserHintLines({ kind: 'pay', reason, cmdName: 'wkgeo', url: payUrl })) out.notice(line);
629
653
  } else if (data.open_browser.opened) {
630
654
  out.notice(browserOpenedText({ kind: 'pay' }));
631
655
  } else {
package/lib/device.mjs CHANGED
@@ -52,7 +52,7 @@ import { writeCredentials, credentialsFromToken } from './credentials.mjs';
52
52
  import { withTrailingSlash } from './config.mjs';
53
53
  import { canonicalOauthUrl } from './oauth-urls.mjs';
54
54
  import { CliError, EXIT, loginHint } from './errors.mjs';
55
- import { defaultOpenBrowser, resolveBrowserIntent, browserOpenedText, browserFailedHintLines } from './login.mjs';
55
+ import { defaultOpenBrowser, resolveBrowserIntent, browserOpenedText, browserFailedHintLines, detectNonInteractive, nonInteractiveBrowserHintLines } from './login.mjs';
56
56
 
57
57
  /** RFC 8628 规定的 urn */
58
58
  export const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
@@ -266,15 +266,18 @@ export function announceDeviceCode({ config, log = () => {}, deps = {}, device,
266
266
  /**
267
267
  * ★ 走**同一个闸门**(`resolveBrowserIntent`,与 login 回调模式、钱包充值完全同一份逻辑)。
268
268
  *
269
- * ★ 老板最终拍板走**传统路线**:**默认总是尝试打开**系统浏览器(设备码模式同样如此,
270
- * 人在终端与 AI 场景都一样);唯一的"不打开"开关是 `--no-browser`。
271
- * (历史:2026-10-01 曾改成"只有人在终端才弹",那版已按老板最终决定回退。)
269
+ * ★ 2026-10-21(老板反馈:CI 里弹了浏览器):**人在终端默认打开**;
270
+ * 非交互/CI(stdout 非 TTY / CI 环境变量 / `WKONGAI_CLI_NO_BROWSER=1`)与 `--no-browser`
271
+ * 都不打开。设备码模式尤其适合非交互环境(不需要任何本地回调),所以这里只要
272
+ * 把链接/短码交给用户即可 —— 绝不能因为"没弹浏览器"中断流程。
273
+ * (历史:2026-10-01 曾改成"只有人在终端才弹";2026-10-02 又默认总弹;本次是第三版。)
272
274
  */
273
275
  const browserIntent = resolveBrowserIntent({
274
276
  isTTY: deps.isTTY === true,
275
277
  json: deps.json === true,
276
278
  forceOpen: deps.forceBrowser === true,
277
279
  noBrowser: deps.openBrowser === false || deps.noBrowser === true,
280
+ env: deps.env && typeof deps.env === 'object' ? deps.env : {},
278
281
  });
279
282
  if (browserIntent.allow) {
280
283
  // 传入函数时用注入的(测试/自定义);否则用**真·打开浏览器**
@@ -297,6 +300,23 @@ export function announceDeviceCode({ config, log = () => {}, deps = {}, device,
297
300
  } else if (browserIntent.skipped_reason === 'no_browser') {
298
301
  // 用户主动关掉的,不该报"失败"
299
302
  log('(已按 --no-browser 跳过自动打开)');
303
+ } else if (browserIntent.skipped_reason === 'non_interactive') {
304
+ /**
305
+ * ★ 非交互(CI / 管道 / AI 子进程):**不弹**,但把"下一步"说清楚。
306
+ * 设备码模式在 CI 里本来就能用(只要用户在自己的浏览器里确认),
307
+ * 所以这里只提示"把地址/短码交给用户",**不建议**再改模式。
308
+ */
309
+ const reason = detectNonInteractive({
310
+ isTTY: deps.isTTY === true,
311
+ env: deps.env && typeof deps.env === 'object' ? deps.env : {},
312
+ }).reason;
313
+ for (const line of nonInteractiveBrowserHintLines({
314
+ kind: 'auth',
315
+ reason,
316
+ cmdName: config.cmdName || 'wkgeo',
317
+ })) {
318
+ log(line);
319
+ }
300
320
  }
301
321
  return pasteText;
302
322
  }
package/lib/login.mjs CHANGED
@@ -388,34 +388,117 @@ function normalizeOpenStatus(raw, url, skippedReason = null) {
388
388
  }
389
389
 
390
390
  /**
391
- * ★★ 「要不要替用户弹**系统**浏览器」—— **全 CLI 唯一的裁决点**。
391
+ * ★★ CI / 非交互环境的判定因子(2026-10-21 老板反馈的直接产物)。
392
+ *
393
+ * ## 真实现场
394
+ * 「别人在 **CI** 里执行 `wkgeo-cli` 的 login,**默认弹出了系统默认浏览器**」
395
+ * —— CI 机器上没有显示器/桌面会话,`rundll32` / `open` / `xdg-open` 要么静默失败,
396
+ * 要么在装了 Xvfb / 桌面残留的 runner 上真的拉起一个窗口(占资源、拖慢、还可能卡住)。
397
+ * CI 里唯一正确的做法是:**只输出授权链接/JSON**,把链接交给有浏览器的人打开。
392
398
  *
393
- * ## ★ 老板最终拍板:走**传统路线**(弹系统浏览器),本函数因此回到"默认总是尝试打开"
394
- * 原话:「还是返回以前那种**打开跳到一个浏览器**,然后打开授权那个吧,**大家也都是那个的**。
395
- * 我们在右侧这个……能力还不会太完善,我们这样做也不太好,**还是走传统的路吧**。」
396
- * (A 路线 = CLI 自动弹系统浏览器 ✔ 采用;B 路线 = AI 客户端右侧内置浏览器 ✘ 暂时不做。)
399
+ * ## 判定(满足任一即"非交互")
400
+ * 1. `process.stdout.isTTY` 为假(管道 / 重定向 / AI 子进程 / CI 日志采集);
401
+ * 2. 存在任一 CI 环境变量(**存在即判定**,不看值;列表见 `CI_ENV_VARS`);
402
+ * 3. `WKONGAI_CLI_NO_BROWSER` 为 `1/true/yes/on`(调用方显式声明"我这没有/不要浏览器")。
397
403
  *
398
- * 为什么传统路线更好(写进代码里,免得下次又被"弹窗会不会吓到用户"带偏):
399
- * * **稳定**:各平台都有成熟的启动器(`rundll32` / `open` / `xdg-open`…),
400
- * 而"在 AI 客户端右侧打开"要靠宿主插件 + 客户端配合,能力还**不完善**;
401
- * * **`window.close()` 在独立浏览器里生效**:授权页/收银台完成后的那一屏能**自己关掉**,
402
- * 用户不会留下一个"我是不是没成功"的疑问窗口(内置侧边栏里关不掉,反而更别扭)。
404
+ * ⚠️ 这个函数是**纯函数**(只吃注入的 `isTTY` / `env`),单测可以直接构造任意组合,
405
+ * 不必去改真实进程的 TTY,也不会污染 `process.env`。
403
406
  *
404
- * ## 判定(三条,顺序即优先级)
405
- * 1. `--no-browser`(`noBrowser`)→ **不打开**(`skipped_reason: "no_browser"`)。
406
- * ⚠️ 这是 AI 端"我不想弹窗"的**唯一开关**,也压过 `--open-browser`;
407
- * 2. `--open-browser`(`forceOpen`)→ 打开(`mode: "forced"`)。
408
- * ⚠️ 它现在与**默认行为等价**,但**保留**:显式语义 + 兼容既有脚本;
409
- * 3. 默认(其它一切情况)→ **总是尝试打开系统浏览器**:
410
- * * 人在终端(`isTTY === true && json !== true`)→ `mode: "terminal"`;
411
- * * 其余(非 TTY / `--json` = **AI 场景**)→ `mode: "agent"`,★ **同样打开**。
412
- * ⇒ 这是本次的行为变更:`json` / `isTTY` **不再**是"不打开"的理由。
407
+ * @param {object} [opts]
408
+ * @param {boolean} [opts.isTTY] stdout 是不是终端(生产传 `!!process.stdout.isTTY`)
409
+ * @param {object} [opts.env] 环境变量(生产传 `process.env`;默认空对象)
410
+ * @returns {{nonInteractive:boolean, reason:(string|null)}} `reason` 形如
411
+ * `"stdout_not_tty"` / `"ci_env:GITHUB_ACTIONS"` / `"env:WKONGAI_CLI_NO_BROWSER"`
412
+ */
413
+ export function detectNonInteractive({ isTTY = false, env = {} } = {}) {
414
+ // ① 不是终端:管道、重定向、AI 子进程都走这条(CI 日志采集也通常不是 TTY)
415
+ if (isTTY !== true) return { nonInteractive: true, reason: 'stdout_not_tty' };
416
+ // ② CI 环境变量:存在即判定(值的含义各家不一,`CI=0` 也出现过,不猜)
417
+ const source = env && typeof env === 'object' ? env : {};
418
+ const ciVar = CI_ENV_VARS.find((name) => Object.prototype.hasOwnProperty.call(source, name) && source[name] !== undefined && source[name] !== null);
419
+ if (ciVar) return { nonInteractive: true, reason: `ci_env:${ciVar}` };
420
+ // ③ 调用方显式声明(给"没有 CI 变量但确实在无浏览器环境"的容器/远程场景用)
421
+ const raw = source[NO_BROWSER_ENV];
422
+ if (raw !== undefined && raw !== null && /^(1|true|yes|on)$/i.test(String(raw).trim())) {
423
+ return { nonInteractive: true, reason: `env:${NO_BROWSER_ENV}` };
424
+ }
425
+ return { nonInteractive: false, reason: null };
426
+ }
427
+
428
+ /** CI 环境变量清单(**存在即判定**;顺序只影响诊断文案里报哪一个) */
429
+ export const CI_ENV_VARS = Object.freeze([
430
+ 'CI',
431
+ 'GITHUB_ACTIONS',
432
+ 'GITLAB_CI',
433
+ 'JENKINS_URL',
434
+ 'TF_BUILD',
435
+ 'BUILDKITE',
436
+ 'TEAMCITY_VERSION',
437
+ ]);
438
+
439
+ /** 「显式关掉浏览器」的环境变量(值为 `1/true/yes/on` 时生效;与 `--no-browser` 等价的效果) */
440
+ export const NO_BROWSER_ENV = 'WKONGAI_CLI_NO_BROWSER';
441
+
442
+ /** 把 `detectNonInteractive().reason` 翻译成人话(只用于提示文案,不影响判定) */
443
+ export function nonInteractiveReasonText(reason = '') {
444
+ const text = String(reason || '');
445
+ if (text === 'stdout_not_tty') return 'stdout 不是终端(管道 / 重定向 / AI 子进程)';
446
+ if (text.startsWith('ci_env:')) return `检测到 CI 环境变量 ${text.slice('ci_env:'.length)}`;
447
+ if (text.startsWith('env:')) return `环境变量 ${text.slice('env:'.length)} 已开启`;
448
+ return '非交互环境';
449
+ }
450
+
451
+ /**
452
+ * 非交互时可照做的提示:**不会自动打开浏览器** + 两条出路(转给有浏览器的人 / 改用设备码)。
413
453
  *
414
- * ## ⚠️ 已弃用的取值:`skipped_reason = "non_tty_agent_mode"`(不再可能出现)
415
- * 2026-10-01 曾按"AI 场景默认不弹、改提示用户去右侧内置浏览器打开"做过一版,
416
- * 那时才会有这个取值。老板最终拍板走传统路线后**它已经消失** ——
417
- * 现在 `skipped_reason` 只有 `"no_browser"` 或 `null` 两种(老文档/老代码看到它就知道是历史值)。
418
- * ⚠️ `open_browser` 的**结构与取值口径没有变**:`{attempted, opened, tried, url, skipped_reason}`。
454
+ * 为什么必须打印:非交互场景(CI / 容器 / AI 子进程)里用户看不到任何窗口,
455
+ * 只拿到一行 URL 很容易以为"CLI 卡住了/什么都没发生"。这段提示把"下一步"说死,
456
+ * 而且明确建议 `--device`(CI 里最可靠:不需要 127.0.0.1 回调)。
457
+ *
458
+ * @param {object} [opts]
459
+ * @param {'auth'|'pay'} [opts.kind]
460
+ * @param {string} [opts.reason] `detectNonInteractive().reason`
461
+ * @param {string} [opts.cmdName]
462
+ * @param {string} [opts.url] 收银台/授权链接(`kind:"pay"` 时打印出来,方便直接复制)
463
+ * @returns {string[]}
464
+ */
465
+ export function nonInteractiveBrowserHintLines({ kind = 'auth', reason = '', cmdName = 'wkgeo', url = '' } = {}) {
466
+ const lead = `ℹ️ 检测到非交互环境(${nonInteractiveReasonText(reason)}):不会自动打开浏览器。`;
467
+ const link = url ? [` ${url}`] : [];
468
+ if (kind === 'pay') {
469
+ return [
470
+ lead,
471
+ ...link,
472
+ ' 本机(CI / 容器 / 远程)通常没有可用浏览器:请把上面的收银台链接交给用户,让他在自己的浏览器里打开并用微信扫码支付。',
473
+ ];
474
+ }
475
+ return [
476
+ lead,
477
+ ' 本机(CI / 容器 / 远程)通常没有可用浏览器:请把上面的授权链接转给有浏览器的用户打开。',
478
+ ` 若本机无法接收 127.0.0.1 回调(CI / 远程环境),改用 \`${cmdName} login --device\`(设备码模式,不需要回调)。`,
479
+ ];
480
+ }
481
+
482
+ /**
483
+ * ★★ 「要不要替用户弹**系统**浏览器」—— **全 CLI 唯一的裁决点**。
484
+ *
485
+ * ## 判定(顺序即优先级;2026-10-21 起加入非交互/CI 检测)
486
+ * 1. `--no-browser`(`noBrowser`,或 `deps.openBrowser === false`)
487
+ * → **不打开**,`skipped_reason: "no_browser"`(压过 `--open-browser`);
488
+ * 2. **非交互 / CI**(`detectNonInteractive`:stdout 非 TTY / CI 环境变量 / `WKONGAI_CLI_NO_BROWSER=1`)
489
+ * → **不打开**,`skipped_reason: "non_interactive"`。
490
+ * ⚠️ 这一条**压过 `--open-browser`**:CI 里绝不允许弹浏览器(老板 2026-10-21 反馈的原话场景)。
491
+ * 要弹就必须在**人类终端**里跑 —— `--open-browser` 只在终端里保留显式语义;
492
+ * 3. `--open-browser`(`forceOpen`)→ 打开(`mode: "forced"`;与终端默认行为等价,保留兼容);
493
+ * 4. 人在终端(`isTTY === true`)→ 打开(`mode: "terminal"`;`--json` 只影响 `mode` 诊断字段);
494
+ * 5. 其余(理论上到不了;兜底仍按 agent 诊断)→ 打开。
495
+ *
496
+ * ## 历史(别再翻回去)
497
+ * * 2026-10-01:曾"AI 场景默认不弹"(`skipped_reason:"non_tty_agent_mode"`)→ 已弃用;
498
+ * * 2026-10-02:老板拍板走传统路线(弹系统浏览器)→ 默认**总是**打开(含 AI 场景);
499
+ * * 2026-10-21:老板真机反馈"CI 里弹了浏览器" ⇒ 恢复"非交互不弹",但**人类终端**保持自动打开。
500
+ * ⇒ 现在 `skipped_reason` 的合法取值是 `"no_browser"` / `"non_interactive"` / `null`。
501
+ * ⚠️ `open_browser` 的**结构没变**:`{attempted, opened, tried, url, skipped_reason}`。
419
502
  *
420
503
  * ## ⚠️ 绝不允许因为"没打开浏览器"让命令失败
421
504
  * 返回 `allow:false` 只是"不弹窗";即使 `allow:true` 而打开失败(`attempted:true, opened:false`),
@@ -423,20 +506,25 @@ function normalizeOpenStatus(raw, url, skippedReason = null) {
423
506
  * 授权/支付流程**照旧继续等回调、继续轮询**,语义完全不变。
424
507
  *
425
508
  * @param {object} [opts]
426
- * @param {boolean} [opts.isTTY] stdout 是不是终端(命令层传 `!!stdout.isTTY`;现在只影响 `mode` 这个诊断字段)
427
- * @param {boolean} [opts.json] 是不是 JSON 输出(同上,只影响 `mode`)
509
+ * @param {boolean} [opts.isTTY] stdout 是不是终端(命令层传 `!!stdout.isTTY`)
510
+ * @param {boolean} [opts.json] 是不是 JSON 输出(只影响 `mode` 诊断字段)
428
511
  * @param {boolean} [opts.forceOpen] 用户写了 `--open-browser`
429
512
  * @param {boolean} [opts.noBrowser] 用户写了 `--no-browser`(或 deps.openBrowser === false)
430
- * @returns {{allow:boolean, mode:'off'|'forced'|'terminal'|'agent', skipped_reason:('no_browser'|null)}}
513
+ * @param {object} [opts.env] 环境变量(CI 判定用;生产传 `process.env`)
514
+ * @returns {{allow:boolean, mode:'off'|'forced'|'terminal'|'agent', skipped_reason:('no_browser'|'non_interactive'|null)}}
431
515
  */
432
- export function resolveBrowserIntent({ isTTY = false, json = false, forceOpen = false, noBrowser = false } = {}) {
516
+ export function resolveBrowserIntent({ isTTY = false, json = false, forceOpen = false, noBrowser = false, env = {} } = {}) {
433
517
  // ① 用户明确关掉:连 `--open-browser` 也不许把它顶回来(--no-browser 优先)
434
518
  if (noBrowser === true) return { allow: false, mode: 'off', skipped_reason: 'no_browser' };
435
- // ② 用户明确要求打开:保留(与默认行为等价,但显式语义与兼容性都要留住)
519
+ // ② 非交互 / CI:**只输出链接**,不 spawn 任何浏览器(压过 --open-browser)
520
+ if (detectNonInteractive({ isTTY, env }).nonInteractive) {
521
+ return { allow: false, mode: 'off', skipped_reason: 'non_interactive' };
522
+ }
523
+ // ③ 用户明确要求打开(已在终端里):保留显式语义与兼容
436
524
  if (forceOpen === true) return { allow: true, mode: 'forced', skipped_reason: null };
437
- // ③ 人在终端:保持 CLI 一直以来的体验(这条**不能**破坏)
525
+ // ④ 人在终端:保持 CLI 一直以来的体验(这条**不能**破坏)
438
526
  if (isTTY === true && json !== true) return { allow: true, mode: 'terminal', skipped_reason: null };
439
- // ④ AI 场景(非 TTY / --json):★ 本次改回来的那一条 —— **同样尝试打开系统浏览器**
527
+ // ⑤ TTY + --json:人还在终端,照旧打开(`json` 只影响 mode 诊断字段)
440
528
  return { allow: true, mode: 'agent', skipped_reason: null };
441
529
  }
442
530
 
@@ -491,8 +579,11 @@ export function browserFailedHintLines(url, { kind = 'auth', agent = false, caus
491
579
  /**
492
580
  * 「照着念」的一句话 —— 给 AI 端直接转述用(AI 不该自己组织语言,否则很容易漏掉网址)。
493
581
  *
494
- * ★ 老板最终拍板走传统路线(弹系统浏览器)之后,**两种场景共用同一句话术**:
495
- * 以"已为你打开浏览器"为主,并把地址**原样带上**(万一本机没弹出来,AI 还能把它交给用户手动打开)。
582
+ * ## 两种口径(**必须与"到底弹没弹"对得上**,否则 AI 会照着念错话)
583
+ * * 交互终端 / 已尝试打开 → "已为你打开浏览器,请在那里完成授权:<url>…";
584
+ * * 非交互(CI / 管道 / AI 子进程,即 `skipped_reason === "non_interactive"`)→
585
+ * "请打开以下链接完成授权:<url>…"
586
+ * —— 这时屏幕上**什么都没弹**,再说"已为你打开"会让用户以为链接坏了。
496
587
  *
497
588
  * @param {string} url
498
589
  * @param {object} [opts]
@@ -500,11 +591,17 @@ export function browserFailedHintLines(url, { kind = 'auth', agent = false, caus
500
591
  * @param {boolean} [opts.agent] ⚠️ **已弃用**(仅为兼容既有调用方而保留):
501
592
  * 以前用它切"请在右侧(内置浏览器)…"的口径;B 路线暂停后两种场景话术已统一,
502
593
  * 这个入参**不再影响输出**。
594
+ * @param {boolean} [opts.nonInteractive] 非交互环境(`skipped_reason === "non_interactive"`)
503
595
  */
504
- export function browserPasteText(url, { kind = 'auth', agent = false } = {}) {
596
+ export function browserPasteText(url, { kind = 'auth', agent = false, nonInteractive = false } = {}) {
505
597
  // 已弃用:见上面的参数说明(保留形参只是不想为了一个布尔值去改所有调用方)
506
598
  void agent;
507
599
  const closingHint = '(完成后页面会自动关闭;没关就手动关掉即可)';
600
+ if (nonInteractive === true) {
601
+ return kind === 'pay'
602
+ ? `请打开以下链接完成支付(微信扫码):${url}${closingHint}`
603
+ : `请打开以下链接完成授权:${url}${closingHint}`;
604
+ }
508
605
  return kind === 'pay'
509
606
  ? `已为你打开浏览器,请在那里完成支付(微信扫码):${url}${closingHint}`
510
607
  : `已为你打开浏览器,请在那里完成授权:${url}${closingHint}`;
@@ -588,15 +685,15 @@ export function defaultOpenBrowserAsync(deps = {}, log = () => {}) {
588
685
  * @param {string} url 要打开的完整网址
589
686
  * @param {object} [opts]
590
687
  * @param {object} [opts.deps] 运行期依赖 `{ openBrowser, platform, spawnImpl, browserStrategies, browserTimeoutMs, verbose,
591
- * isTTY, json, forceOpen, noBrowser }`
688
+ * isTTY, json, forceOpen, noBrowser, env }`
592
689
  * @param {(msg:string)=>void} [opts.log] 诊断日志(`--verbose` 用)
593
- * @returns {Promise<{attempted:boolean, opened:boolean, tried:string[], url:string, skipped_reason:(string|null)}>}
690
+ * @returns {Promise<{attempted:boolean, opened:boolean, tried:string[], url:string, skipped_reason:('no_browser'|'non_interactive'|null)}>}
594
691
  */
595
692
  export async function openUrlForUser(url, { deps = {}, log = () => {} } = {}) {
596
693
  const target = typeof url === 'string' ? url.trim() : '';
597
694
  /**
598
- * ★ 先过**唯一闸门**(见 resolveBrowserIntent):默认**总是尝试打开**系统浏览器
599
- * (人在终端 ✔ + AI 场景 ✔),只有 `--no-browser` 才不打开。
695
+ * ★ 先过**唯一闸门**(见 resolveBrowserIntent):**人在终端默认打开**;
696
+ * `--no-browser` 与 **非交互/CI**(非 TTY / CI 环境变量 / `WKONGAI_CLI_NO_BROWSER=1`)都不打开。
600
697
  * ⚠️ 这里是钱包充值那条链路(login 走 runLogin 里的同一个函数),所以两处行为天然一致 ——
601
698
  * **不允许**在任何命令里自己判断一遍 TTY 再决定要不要 spawn(那就是复制逻辑,
602
699
  * 迟早会出现"授权不弹、支付还弹"这种不一致)。
@@ -606,6 +703,8 @@ export async function openUrlForUser(url, { deps = {}, log = () => {} } = {}) {
606
703
  json: deps.json === true,
607
704
  forceOpen: deps.forceOpen === true,
608
705
  noBrowser: deps.noBrowser === true || deps.openBrowser === false,
706
+ /** ★ CI / 非交互判定(2026-10-21):管道、CI 环境变量、WKONGAI_CLI_NO_BROWSER 都不弹 */
707
+ env: deps.env && typeof deps.env === 'object' ? deps.env : {},
609
708
  });
610
709
  if (!intent.allow) {
611
710
  // 不弹窗 ≠ 失败:地址照常由调用方打印/放进 JSON,流程一丝不变
@@ -660,13 +759,13 @@ function sendHtml(res, status, page) {
660
759
  * @param {object} args.config 已生效的配置(base/clientId/scope/credentialsFile/callback*)
661
760
  * @param {(msg:string)=>void} args.log
662
761
  * @param {object} [args.deps] { fetchImpl, openBrowser, now, listenPort, callbackFirstWaitMs,
663
- * spawnImpl, platform, onAuthorizationUrl, proxyRisk,
762
+ * spawnImpl, platform, onAuthorizationUrl, proxyRisk, env,
664
763
  * ★ isTTY, json, forceBrowser, noBrowser —— 见 resolveBrowserIntent(),
665
764
  * ★ handoff, postDeviceGraceMs —— 见下 }
666
- * ★ `isTTY` / `json` / `forceBrowser`(`--open-browser`)/ `noBrowser`(`--no-browser`)四个字段
667
- * 决定"要不要替用户弹系统浏览器":★ **默认总是尝试打开**(人在终端与 AI 场景都一样),
668
- * 只有 `noBrowser`(`--no-browser`)才不打开;`isTTY` / `json` 现在只影响 `mode` 这个诊断字段
669
- * ——理由见 resolveBrowserIntent()。
765
+ * ★ `isTTY` / `json` / `forceBrowser`(`--open-browser`)/ `noBrowser`(`--no-browser`)/ `env`(CI 判定)
766
+ * 决定"要不要替用户弹系统浏览器":**人在终端默认打开**;`--no-browser` 与 **非交互/CI**
767
+ * (stdout 非 TTY / CI 环境变量 / `WKONGAI_CLI_NO_BROWSER=1`)都不打开;
768
+ * `--open-browser` 只在终端里保留显式语义 —— 理由见 detectNonInteractive() / resolveBrowserIntent()。
670
769
  * `callbackFirstWaitMs` —— 可选的「首轮等待」窗口,**默认 0 = 关闭**(0.2.2 起)。
671
770
  * 只有用户显式写了 `--callback-first-wait` / `WKONGAI_CALLBACK_FIRST_WAIT_MS` 时,
672
771
  * 命令层才会给非 0 值(见 `commands/auth.mjs` 的 resolveCallbackFirstWaitMs);
@@ -695,14 +794,16 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
695
794
  *
696
795
  * ★ 2026-10-01 起:`deps.openBrowser === false` 只是"用户主动关掉"这一种情况;
697
796
  * **要不要弹系统浏览器**这件事统一由 `resolveBrowserIntent()` 裁决 ——
698
- * ★ 老板最终拍板走**传统路线**:**默认总是尝试打开**(AI 场景也打开),
699
- * 只有 `--no-browser` 才不打开(见 resolveBrowserIntent 的说明)。
797
+ * ★ 2026-10-21 起:**人在终端默认打开**;`--no-browser` 与 **非交互/CI**(非 TTY / CI 环境变量 /
798
+ * `WKONGAI_CLI_NO_BROWSER=1`)都不打开(见 resolveBrowserIntent / detectNonInteractive 的说明)。
700
799
  */
701
800
  const browserIntent = resolveBrowserIntent({
702
801
  isTTY: deps.isTTY === true,
703
802
  json: deps.json === true,
704
803
  forceOpen: deps.forceBrowser === true,
705
804
  noBrowser: deps.openBrowser === false || deps.noBrowser === true,
805
+ /** ★ CI / 非交互判定(2026-10-21):命令层把 `ctx.env` 透传进来,纯函数在 login.mjs 里 */
806
+ env: deps.env && typeof deps.env === 'object' ? deps.env : {},
706
807
  });
707
808
  /**
708
809
  * ★★ 最终是否自动打开系统浏览器 = **唯一闸门**的结论。
@@ -1178,7 +1279,7 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1178
1279
  opened: false,
1179
1280
  tried: [],
1180
1281
  url: authUrl.toString(),
1181
- // ★ 现在只可能因为 `--no-browser` 不尝试(`non_tty_agent_mode` 已随老板拍板走传统路线而弃用)
1282
+ // ★ 只有两种"不尝试":`--no-browser`(no_browser)与非交互/CI(non_interactive)
1182
1283
  skipped_reason: browserIntent.skipped_reason,
1183
1284
  };
1184
1285
  if (shouldOpenBrowser) {
@@ -1195,8 +1296,24 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1195
1296
  }
1196
1297
  }
1197
1298
  if (!shouldOpenBrowser) {
1198
- // ⚠️ 只有"--no-browser"才打这一行(用户主动关掉的,不该报"失败")
1199
- if (browserIntent.skipped_reason === 'no_browser') log('(已按 --no-browser 跳过自动打开)');
1299
+ if (browserIntent.skipped_reason === 'no_browser') {
1300
+ // 用户主动关掉的,不该报"失败"(原有行为,保持兼容)
1301
+ log('(已按 --no-browser 跳过自动打开)');
1302
+ } else if (browserIntent.skipped_reason === 'non_interactive') {
1303
+ /**
1304
+ * ★ 2026-10-21(老板反馈:CI 里弹了浏览器):
1305
+ * 非交互环境**不弹**,但必须把"为什么没弹 + 下一步怎么走"说清楚,
1306
+ * 否则用户/CI 日志里只看到一行 URL,会以为 CLI 卡住了。
1307
+ * ⚠️ 这段走 `log`:`--json` 时命令层会把它转到 stderr,不污染 stdout 的 JSONL。
1308
+ */
1309
+ const reason = detectNonInteractive({
1310
+ isTTY: deps.isTTY === true,
1311
+ env: deps.env && typeof deps.env === 'object' ? deps.env : {},
1312
+ }).reason;
1313
+ for (const line of nonInteractiveBrowserHintLines({ kind: 'auth', reason, cmdName: config.cmdName || 'wkgeo' })) {
1314
+ log(line);
1315
+ }
1316
+ }
1200
1317
  } else if (openStatus.opened) {
1201
1318
  // ★ 成功:明说"已经打开了",并告诉用户去那里做什么(老板最终拍板走传统路线)
1202
1319
  log(browserOpenedText({ kind: 'auth' }));
@@ -1222,15 +1339,18 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1222
1339
  /**
1223
1340
  * ★ 机器可读的"自动打开"状态:AI 端据此判断"到底要不要催用户手动打开"。
1224
1341
  * `authorization_url` 仍然照旧单独给(绝不能因为多了这个字段就把它挤掉)。
1225
- * ★ 老板最终拍板走**传统路线**:CLI 默认**总是尝试**打开系统浏览器(AI 场景也一样),
1226
- * 所以 `skipped_reason` 现在只有两种取值:
1227
- * * `null` = 真的尝试过(成没成看 `opened`);
1228
- * * `"no_browser"` = 调用方/用户明确要求别打开(唯一开关)。
1342
+ * ★ 2026-10-21 起 `skipped_reason` 有三种取值:
1343
+ * * `null` = 真的尝试过(成没成看 `opened`);
1344
+ * * `"no_browser"` = 调用方/用户明确要求别打开;
1345
+ * * `"non_interactive"` = CI / 管道 / AI 子进程(**默认不弹**,见 detectNonInteractive)。
1229
1346
  * (历史取值 `"non_tty_agent_mode"` 已弃用,见 resolveBrowserIntent 的注释。)
1230
1347
  */
1231
1348
  open_browser: openStatus,
1232
1349
  /** ★ 给 AI 端"照着念"的一句话(AI 不该自己组织语言,否则容易漏掉网址) */
1233
- paste_text: browserPasteText(authUrl.toString(), { kind: 'auth' }),
1350
+ paste_text: browserPasteText(authUrl.toString(), {
1351
+ kind: 'auth',
1352
+ nonInteractive: browserIntent.skipped_reason === 'non_interactive',
1353
+ }),
1234
1354
  });
1235
1355
  }
1236
1356
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wkgeo-cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "云站平台官方命令行客户端:授权登录一次,之后建站 / 下单 / 钱包支付 / 查余额都不用再登录(OAuth 授权码 + PKCE,公共客户端,零第三方依赖)",
5
5
  "keywords": [
6
6
  "wkgeo",