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 +32 -23
- package/SKILL.md +43 -32
- package/lib/commands/auth.mjs +25 -14
- package/lib/commands/billing.mjs +34 -10
- package/lib/device.mjs +24 -4
- package/lib/login.mjs +173 -53
- package/package.json +1 -1
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.
|
|
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` |
|
|
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
|
-
> ★
|
|
176
|
-
>
|
|
177
|
-
>
|
|
178
|
-
> *
|
|
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
|
-
> *
|
|
182
|
-
>
|
|
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`;★
|
|
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
|
|
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`
|
|
619
|
-
> ★
|
|
620
|
-
>
|
|
621
|
-
>
|
|
622
|
-
>
|
|
623
|
-
>
|
|
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` |
|
|
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
|
-
> 🌐 **自动打开浏览器**(⚠️
|
|
236
|
-
>
|
|
237
|
-
>
|
|
238
|
-
>
|
|
239
|
-
>
|
|
240
|
-
>
|
|
241
|
-
>
|
|
242
|
-
>
|
|
243
|
-
>
|
|
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
|
-
>
|
|
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
|
-
| `
|
|
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
|
-
* ⚠️
|
|
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
|
-
|
|
|
465
|
-
|
|
|
466
|
-
| 加了 `--
|
|
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
|
|
565
|
+
# ★ 打开方式见 §5.5:**人类终端**里 CLI 会尝试弹系统浏览器;**CI/非交互**只输出链接,
|
|
556
566
|
# 你只要看 data.open_browser:opened:true → 把 data.paste_text(已含完整地址 +
|
|
557
|
-
# "已为你打开浏览器…")原样念给用户即可;
|
|
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
|
-
⚠️
|
|
576
|
-
⚠️ `--open-browser`
|
|
577
|
-
⚠️ 打开 `pay_url` 的方式见 **§5.5
|
|
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
|
-
#
|
|
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
|
```
|
package/lib/commands/auth.mjs
CHANGED
|
@@ -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: '是否自动打开浏览器(★
|
|
230
|
+
browser: { type: 'boolean', desc: '是否自动打开浏览器(★ 默认:人类终端打开;CI/管道/AI 子进程等非交互环境不打开;--no-browser 关闭)' },
|
|
231
231
|
/**
|
|
232
|
-
* ★
|
|
233
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
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:
|
|
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
|
});
|
package/lib/commands/billing.mjs
CHANGED
|
@@ -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
|
-
* ★
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
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
|
|
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, {
|
|
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
|
|
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
|
-
*
|
|
271
|
-
*
|
|
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
|
-
* ★★
|
|
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
|
-
*
|
|
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
|
-
*
|
|
400
|
-
* 而"在 AI 客户端右侧打开"要靠宿主插件 + 客户端配合,能力还**不完善**;
|
|
401
|
-
* * **`window.close()` 在独立浏览器里生效**:授权页/收银台完成后的那一屏能**自己关掉**,
|
|
402
|
-
* 用户不会留下一个"我是不是没成功"的疑问窗口(内置侧边栏里关不掉,反而更别扭)。
|
|
404
|
+
* ⚠️ 这个函数是**纯函数**(只吃注入的 `isTTY` / `env`),单测可以直接构造任意组合,
|
|
405
|
+
* 不必去改真实进程的 TTY,也不会污染 `process.env`。
|
|
403
406
|
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
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
|
|
427
|
-
* @param {boolean} [opts.json] 是不是 JSON
|
|
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
|
-
* @
|
|
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
|
-
//
|
|
525
|
+
// ④ 人在终端:保持 CLI 一直以来的体验(这条**不能**破坏)
|
|
438
526
|
if (isTTY === true && json !== true) return { allow: true, mode: 'terminal', skipped_reason: null };
|
|
439
|
-
//
|
|
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
|
-
*
|
|
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:(
|
|
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
|
-
*
|
|
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
|
-
* 决定"要不要替用户弹系统浏览器"
|
|
668
|
-
*
|
|
669
|
-
*
|
|
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
|
-
* ★
|
|
699
|
-
*
|
|
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
|
-
// ★
|
|
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
|
-
|
|
1199
|
-
|
|
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
|
-
* ★
|
|
1226
|
-
*
|
|
1227
|
-
* * `
|
|
1228
|
-
* * `"
|
|
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(), {
|
|
1350
|
+
paste_text: browserPasteText(authUrl.toString(), {
|
|
1351
|
+
kind: 'auth',
|
|
1352
|
+
nonInteractive: browserIntent.skipped_reason === 'non_interactive',
|
|
1353
|
+
}),
|
|
1234
1354
|
});
|
|
1235
1355
|
}
|
|
1236
1356
|
|