wkgeo-cli 0.2.1 → 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/lib/login.mjs CHANGED
@@ -19,16 +19,23 @@
19
19
  * 依赖注入(deps)是为了**可测**:offline 单测里把 fetch / 开浏览器 / 端口都替换掉,
20
20
  * 就成了一台"假浏览器",可以逐条验证 state 校验、超时、错误页这些分支。
21
21
  *
22
- * ⚠️ 两个等待窗口(别搞混,`commands/auth.mjs` 靠 `kind` 区分"要不要自动改设备码"):
23
- * * `DEFAULT_CALLBACK_FIRST_WAIT_MS`(**180 秒**,`--callback-first-wait` 可调/可关,
24
- * ★ **只在检测到系统代理时**才由命令层启用)
25
- * → 到点抛 `kind = 'callback_first_wait'` → **自动改用设备码继续,不退出**(第二条保险);
22
+ * ★★ 2026-10-21(老板反馈):**默认就是这条本地回调授权**,`wkgeo login` 不会因为
23
+ * "等了一会儿"自动变成设备码。设备码只在**这几种情况**出现(裁决点在 `commands/auth.mjs`):
24
+ * * 用户显式 `--device` / `--no-wait` / `--device-continue`;
25
+ * * 检测到系统代理且判定回调不可用(默认开,`--no-auto-device` 可关);
26
+ * * 本地回调端口**真的起不来**(`kind = 'listen'`,真实失败兜底;`--no-auto-device` 可关)。
27
+ * 整段等待超时(`kind = 'timeout'`)**只报错**,不再自动改设备码(0.2.2 起取消)。
28
+ *
29
+ * 两个等待窗口(别搞混,`commands/auth.mjs` 靠 `kind` 区分"要不要自动改设备码"):
30
+ * * 可选首轮等待(`--callback-first-wait` / `WKONGAI_CALLBACK_FIRST_WAIT_MS`,
31
+ * ★ **默认关闭**,只有用户显式指定才启用)
32
+ * → 到点抛 `kind = 'callback_first_wait'`;命令层在场时会**并发**启动设备码(仅显式开启时);
26
33
  * * `config.loginTimeoutMs`(默认 300 秒,`--timeout` 可调)
27
34
  * → 到点抛 `kind = 'timeout'`(整段等待的兜底,也是老板报的那个故障的现场)。
28
35
  *
29
- * ★★ 2026-10-03(老板真机反馈)起,这两条"到点"都**不再关掉本地回调监听**:
36
+ * ★★ 2026-10-03(老板真机反馈)起,"首轮等待"到点改道时**不再关掉本地回调监听**:
30
37
  * 命令层会通过 `deps.handoff` 拿到通知,**并发**去跑设备码那条路,**哪条先成功就用哪条**
31
- * (详见 `DEFAULT_CALLBACK_FIRST_WAIT_MS` 与 `deps.handoff` 的注释)。
38
+ * (详见 `deps.handoff` 的注释;该自愈现在只有显式 `--callback-first-wait` 才会触发)。
32
39
  */
33
40
 
34
41
  import http from 'node:http';
@@ -135,47 +142,23 @@ const FALLBACK_STRATEGIES = BROWSER_STRATEGY_TABLE.linux;
135
142
  export const BROWSER_LAUNCH_TIMEOUT_MS = 4000;
136
143
 
137
144
  /**
138
- * ★ 「回调模式首轮等待」窗口(默认 **180 秒**)—— 第二条保险,**只在检测到系统代理时启用**。
139
- *
140
- * ## ⚠️⚠️ 2026-10-03 老板真机反馈:这条自愈**被用错了场景**(本次修的就是它)
141
- * 老板**没有开任何 VPN / 代理**,但连着重跑几次 `wkgeo login` 都**自动变成了设备码**,
142
- * 原话:「现在变成设备码了,我们因为没有开任何VPN,所以这个是不对的。」
143
- * 现场:旧逻辑把这个窗口**无条件**挂在 60 秒上,而"把授权页送到用户面前、用户读完再点
144
- * 【同意】"经常超过 60 秒 —— 于是明明不需要设备码,也切了过去。
145
- * ⇒ 现在:**只有 `proxy_warning.detected === true`(真检测到生效中的代理)才启用**;
146
- * **没有代理时一律不启用**(老老实实等满 `--timeout`)。
147
- * 用户**显式**写 `--callback-first-wait <秒>` 或 `WKONGAI_CALLBACK_FIRST_WAIT_MS` 时照旧尊重
148
- * —— **显式指定优先于自动判断**(他怎么要求就怎么做)。
149
- * ⇒ 连带结论:`auto_device.applied=true, reason='callback_first_wait'` **只可能出现在有代理的机器上**;
150
- * 没有代理、用户也没显式指定时,**绝不会**发生。
151
- * (判定点在 `commands/auth.mjs` 的 `resolveCallbackFirstWaitMs()`,那边有一份对应的说明与单测钉子。)
145
+ * 历史常量:回调模式「首轮等待」的**旧默认窗口**(180 秒)。
152
146
  *
153
- * ## 为什么还需要它(什么时候它是对的)
154
- * "代理把回调吃掉了"这种故障里,用户其实早就点完【同意授权】了,只是结果回不来;
155
- * 而设备码**不需要任何入站回调**,几分钟内就能拿到令牌 —— 比让他干等 5~15 分钟强得多。
156
- * 放宽到 180 秒(原来是 60 秒)就是给"用户真在认真读授权页"留足余量:
157
- * 它只在"有代理、回调本来就可能回不来"的机器上生效,宁可多等一会儿再改道。
147
+ * ★ 0.2.2 起**不再作为默认值生效**(老板 2026-10-21 反馈):
148
+ * 「默认的也不应该是设备授权,默认的就是那个传统的授权方式(浏览器打开授权页 +
149
+ * 本地 localhost 回调)。设备码是本地读不了/代理时才用的。」
150
+ * ⇒ `wkgeo login` 默认一路等回调(最多 `--timeout`),**不会**因为等太久自动改设备码;
151
+ * 只有用户**显式**写 `--callback-first-wait <秒>` / `WKONGAI_CALLBACK_FIRST_WAIT_MS`
152
+ * 才启用这条可选自愈(显式优先)。
158
153
  *
159
- * ## ★★ 到点之后**不关本地回调监听**(同一次修复的第二条,真机证据见下)
160
- * 旧行为:到点 → 抛 `callback_first_wait` → 命令层改走设备码,而 `runLogin` 的 `finally`
161
- * **已经把本地 server 关掉了**。于是用户随后才点【同意授权】时,浏览器跳到
162
- * `http://127.0.0.1:<port>/callback?code=…` 得到 **ERR_CONNECTION_REFUSED**
163
- * (老板真机截图见过)—— 用户以为授权失败,又重来一次(其实他马上就会登录成功)。
164
- * 现在的契约:调用方传了 `deps.handoff` 时,到点**只通知、不结束、不关监听**(见 `runLogin`),
165
- * 由命令层**并发**跑设备码,两条路**先到先得**。
154
+ * ⚠️ 保留这个导出只为**兼容**仍 import 它的外部脚本/旧文档;产品行为以
155
+ * `commands/auth.mjs` 的 `resolveCallbackFirstWaitMs()` 为准(无显式指定时返回 0)。
156
+ * 这个常量本身**不参与任何默认判断**。
166
157
  *
167
- * ## 覆盖方式(优先级:命令行 > 环境变量 > 这个默认值)
168
- * * `--callback-first-wait <时长>`:默认单位**秒**(`--callback-first-wait 90` = 90 秒),
169
- * 也支持 `90s` / `90000ms`;写 `0` / `off` / `none` 表示**关掉这个自愈窗口**
170
- * (那就只剩整段 `--timeout` 窗口)。
171
- * * `WKONGAI_CALLBACK_FIRST_WAIT_MS`:毫秒(沿用 `WKONGAI_LOGIN_TIMEOUT_MS` 的口径)。
172
- * * `--no-auto-device` / `WKONGAI_AUTO_DEVICE=0` 时**强制关闭**:用户明确要求只走回调,
173
- * 我们就没有"改道"这个选项,不能偷偷改(见 `commands/auth.mjs` 的 resolveCallbackFirstWaitMs)。
158
+ * 历史背景(仍适用于"显式开启"的场景):到点后**不关本地回调监听**,由命令层并发跑设备码,
159
+ * 两条路先到先得(旧版关监听会让用户之后点【同意】看到 ERR_CONNECTION_REFUSED)。
174
160
  *
175
- * ## 约束
176
- * * 必须**小于** `config.loginTimeoutMs` 才有意义(否则只会先撞整段超时);
177
- * * 到点后抛 `kind = 'callback_first_wait'`(**不是** `timeout`),命令层据此自动改设备码、
178
- * **不退出**;换票失败/被拒绝等其他错误**照旧不降级**。
161
+ * @deprecated 0.2.2 起默认关闭;需要"等待到点改设备码"请显式传 `--callback-first-wait`。
179
162
  */
180
163
  export const DEFAULT_CALLBACK_FIRST_WAIT_MS = 180_000;
181
164
 
@@ -405,34 +388,117 @@ function normalizeOpenStatus(raw, url, skippedReason = null) {
405
388
  }
406
389
 
407
390
  /**
408
- * ★★ 「要不要替用户弹**系统**浏览器」—— **全 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**,把链接交给有浏览器的人打开。
398
+ *
399
+ * ## 判定(满足任一即"非交互")
400
+ * 1. `process.stdout.isTTY` 为假(管道 / 重定向 / AI 子进程 / CI 日志采集);
401
+ * 2. 存在任一 CI 环境变量(**存在即判定**,不看值;列表见 `CI_ENV_VARS`);
402
+ * 3. `WKONGAI_CLI_NO_BROWSER` 为 `1/true/yes/on`(调用方显式声明"我这没有/不要浏览器")。
403
+ *
404
+ * ⚠️ 这个函数是**纯函数**(只吃注入的 `isTTY` / `env`),单测可以直接构造任意组合,
405
+ * 不必去改真实进程的 TTY,也不会污染 `process.env`。
406
+ *
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
+ * 非交互时可照做的提示:**不会自动打开浏览器** + 两条出路(转给有浏览器的人 / 改用设备码)。
409
453
  *
410
- * ## ★ 老板最终拍板:走**传统路线**(弹系统浏览器),本函数因此回到"默认总是尝试打开"
411
- * 原话:「还是返回以前那种**打开跳到一个浏览器**,然后打开授权那个吧,**大家也都是那个的**。
412
- * 我们在右侧这个……能力还不会太完善,我们这样做也不太好,**还是走传统的路吧**。」
413
- * (A 路线 = CLI 自动弹系统浏览器 ✔ 采用;B 路线 = AI 客户端右侧内置浏览器 ✘ 暂时不做。)
454
+ * 为什么必须打印:非交互场景(CI / 容器 / AI 子进程)里用户看不到任何窗口,
455
+ * 只拿到一行 URL 很容易以为"CLI 卡住了/什么都没发生"。这段提示把"下一步"说死,
456
+ * 而且明确建议 `--device`(CI 里最可靠:不需要 127.0.0.1 回调)。
414
457
  *
415
- * 为什么传统路线更好(写进代码里,免得下次又被"弹窗会不会吓到用户"带偏):
416
- * * **稳定**:各平台都有成熟的启动器(`rundll32` / `open` / `xdg-open`…),
417
- * 而"在 AI 客户端右侧打开"要靠宿主插件 + 客户端配合,能力还**不完善**;
418
- * * **`window.close()` 在独立浏览器里生效**:授权页/收银台完成后的那一屏能**自己关掉**,
419
- * 用户不会留下一个"我是不是没成功"的疑问窗口(内置侧边栏里关不掉,反而更别扭)。
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 唯一的裁决点**。
420
484
  *
421
- * ## 判定(三条,顺序即优先级)
422
- * 1. `--no-browser`(`noBrowser`)→ **不打开**(`skipped_reason: "no_browser"`)。
423
- * ⚠️ 这是 AI 端"我不想弹窗"的**唯一开关**,也压过 `--open-browser`;
424
- * 2. `--open-browser`(`forceOpen`)→ 打开(`mode: "forced"`)。
425
- * ⚠️ 它现在与**默认行为等价**,但**保留**:显式语义 + 兼容既有脚本;
426
- * 3. 默认(其它一切情况)→ **总是尝试打开系统浏览器**:
427
- * * 人在终端(`isTTY === true && json !== true`)→ `mode: "terminal"`;
428
- * * 其余(非 TTY / `--json` = **AI 场景**)→ `mode: "agent"`,★ **同样打开**。
429
- * ⇒ 这是本次的行为变更:`json` / `isTTY` **不再**是"不打开"的理由。
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 诊断)→ 打开。
430
495
  *
431
- * ## ⚠️ 已弃用的取值:`skipped_reason = "non_tty_agent_mode"`(不再可能出现)
432
- * 2026-10-01 曾按"AI 场景默认不弹、改提示用户去右侧内置浏览器打开"做过一版,
433
- * 那时才会有这个取值。老板最终拍板走传统路线后**它已经消失** ——
434
- * 现在 `skipped_reason` 只有 `"no_browser"` 或 `null` 两种(老文档/老代码看到它就知道是历史值)。
435
- * ⚠️ `open_browser` 的**结构与取值口径没有变**:`{attempted, opened, tried, url, skipped_reason}`。
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}`。
436
502
  *
437
503
  * ## ⚠️ 绝不允许因为"没打开浏览器"让命令失败
438
504
  * 返回 `allow:false` 只是"不弹窗";即使 `allow:true` 而打开失败(`attempted:true, opened:false`),
@@ -440,20 +506,25 @@ function normalizeOpenStatus(raw, url, skippedReason = null) {
440
506
  * 授权/支付流程**照旧继续等回调、继续轮询**,语义完全不变。
441
507
  *
442
508
  * @param {object} [opts]
443
- * @param {boolean} [opts.isTTY] stdout 是不是终端(命令层传 `!!stdout.isTTY`;现在只影响 `mode` 这个诊断字段)
444
- * @param {boolean} [opts.json] 是不是 JSON 输出(同上,只影响 `mode`)
509
+ * @param {boolean} [opts.isTTY] stdout 是不是终端(命令层传 `!!stdout.isTTY`)
510
+ * @param {boolean} [opts.json] 是不是 JSON 输出(只影响 `mode` 诊断字段)
445
511
  * @param {boolean} [opts.forceOpen] 用户写了 `--open-browser`
446
512
  * @param {boolean} [opts.noBrowser] 用户写了 `--no-browser`(或 deps.openBrowser === false)
447
- * @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)}}
448
515
  */
449
- export function resolveBrowserIntent({ isTTY = false, json = false, forceOpen = false, noBrowser = false } = {}) {
516
+ export function resolveBrowserIntent({ isTTY = false, json = false, forceOpen = false, noBrowser = false, env = {} } = {}) {
450
517
  // ① 用户明确关掉:连 `--open-browser` 也不许把它顶回来(--no-browser 优先)
451
518
  if (noBrowser === true) return { allow: false, mode: 'off', skipped_reason: 'no_browser' };
452
- // ② 用户明确要求打开:保留(与默认行为等价,但显式语义与兼容性都要留住)
519
+ // ② 非交互 / CI:**只输出链接**,不 spawn 任何浏览器(压过 --open-browser)
520
+ if (detectNonInteractive({ isTTY, env }).nonInteractive) {
521
+ return { allow: false, mode: 'off', skipped_reason: 'non_interactive' };
522
+ }
523
+ // ③ 用户明确要求打开(已在终端里):保留显式语义与兼容
453
524
  if (forceOpen === true) return { allow: true, mode: 'forced', skipped_reason: null };
454
- // ③ 人在终端:保持 CLI 一直以来的体验(这条**不能**破坏)
525
+ // ④ 人在终端:保持 CLI 一直以来的体验(这条**不能**破坏)
455
526
  if (isTTY === true && json !== true) return { allow: true, mode: 'terminal', skipped_reason: null };
456
- // ④ AI 场景(非 TTY / --json):★ 本次改回来的那一条 —— **同样尝试打开系统浏览器**
527
+ // ⑤ TTY + --json:人还在终端,照旧打开(`json` 只影响 mode 诊断字段)
457
528
  return { allow: true, mode: 'agent', skipped_reason: null };
458
529
  }
459
530
 
@@ -508,8 +579,11 @@ export function browserFailedHintLines(url, { kind = 'auth', agent = false, caus
508
579
  /**
509
580
  * 「照着念」的一句话 —— 给 AI 端直接转述用(AI 不该自己组织语言,否则很容易漏掉网址)。
510
581
  *
511
- * ★ 老板最终拍板走传统路线(弹系统浏览器)之后,**两种场景共用同一句话术**:
512
- * 以"已为你打开浏览器"为主,并把地址**原样带上**(万一本机没弹出来,AI 还能把它交给用户手动打开)。
582
+ * ## 两种口径(**必须与"到底弹没弹"对得上**,否则 AI 会照着念错话)
583
+ * * 交互终端 / 已尝试打开 → "已为你打开浏览器,请在那里完成授权:<url>…";
584
+ * * 非交互(CI / 管道 / AI 子进程,即 `skipped_reason === "non_interactive"`)→
585
+ * "请打开以下链接完成授权:<url>…"
586
+ * —— 这时屏幕上**什么都没弹**,再说"已为你打开"会让用户以为链接坏了。
513
587
  *
514
588
  * @param {string} url
515
589
  * @param {object} [opts]
@@ -517,11 +591,17 @@ export function browserFailedHintLines(url, { kind = 'auth', agent = false, caus
517
591
  * @param {boolean} [opts.agent] ⚠️ **已弃用**(仅为兼容既有调用方而保留):
518
592
  * 以前用它切"请在右侧(内置浏览器)…"的口径;B 路线暂停后两种场景话术已统一,
519
593
  * 这个入参**不再影响输出**。
594
+ * @param {boolean} [opts.nonInteractive] 非交互环境(`skipped_reason === "non_interactive"`)
520
595
  */
521
- export function browserPasteText(url, { kind = 'auth', agent = false } = {}) {
596
+ export function browserPasteText(url, { kind = 'auth', agent = false, nonInteractive = false } = {}) {
522
597
  // 已弃用:见上面的参数说明(保留形参只是不想为了一个布尔值去改所有调用方)
523
598
  void agent;
524
599
  const closingHint = '(完成后页面会自动关闭;没关就手动关掉即可)';
600
+ if (nonInteractive === true) {
601
+ return kind === 'pay'
602
+ ? `请打开以下链接完成支付(微信扫码):${url}${closingHint}`
603
+ : `请打开以下链接完成授权:${url}${closingHint}`;
604
+ }
525
605
  return kind === 'pay'
526
606
  ? `已为你打开浏览器,请在那里完成支付(微信扫码):${url}${closingHint}`
527
607
  : `已为你打开浏览器,请在那里完成授权:${url}${closingHint}`;
@@ -605,15 +685,15 @@ export function defaultOpenBrowserAsync(deps = {}, log = () => {}) {
605
685
  * @param {string} url 要打开的完整网址
606
686
  * @param {object} [opts]
607
687
  * @param {object} [opts.deps] 运行期依赖 `{ openBrowser, platform, spawnImpl, browserStrategies, browserTimeoutMs, verbose,
608
- * isTTY, json, forceOpen, noBrowser }`
688
+ * isTTY, json, forceOpen, noBrowser, env }`
609
689
  * @param {(msg:string)=>void} [opts.log] 诊断日志(`--verbose` 用)
610
- * @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)}>}
611
691
  */
612
692
  export async function openUrlForUser(url, { deps = {}, log = () => {} } = {}) {
613
693
  const target = typeof url === 'string' ? url.trim() : '';
614
694
  /**
615
- * ★ 先过**唯一闸门**(见 resolveBrowserIntent):默认**总是尝试打开**系统浏览器
616
- * (人在终端 ✔ + AI 场景 ✔),只有 `--no-browser` 才不打开。
695
+ * ★ 先过**唯一闸门**(见 resolveBrowserIntent):**人在终端默认打开**;
696
+ * `--no-browser` 与 **非交互/CI**(非 TTY / CI 环境变量 / `WKONGAI_CLI_NO_BROWSER=1`)都不打开。
617
697
  * ⚠️ 这里是钱包充值那条链路(login 走 runLogin 里的同一个函数),所以两处行为天然一致 ——
618
698
  * **不允许**在任何命令里自己判断一遍 TTY 再决定要不要 spawn(那就是复制逻辑,
619
699
  * 迟早会出现"授权不弹、支付还弹"这种不一致)。
@@ -623,6 +703,8 @@ export async function openUrlForUser(url, { deps = {}, log = () => {} } = {}) {
623
703
  json: deps.json === true,
624
704
  forceOpen: deps.forceOpen === true,
625
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 : {},
626
708
  });
627
709
  if (!intent.allow) {
628
710
  // 不弹窗 ≠ 失败:地址照常由调用方打印/放进 JSON,流程一丝不变
@@ -677,29 +759,31 @@ function sendHtml(res, status, page) {
677
759
  * @param {object} args.config 已生效的配置(base/clientId/scope/credentialsFile/callback*)
678
760
  * @param {(msg:string)=>void} args.log
679
761
  * @param {object} [args.deps] { fetchImpl, openBrowser, now, listenPort, callbackFirstWaitMs,
680
- * spawnImpl, platform, onAuthorizationUrl, proxyRisk,
762
+ * spawnImpl, platform, onAuthorizationUrl, proxyRisk, env,
681
763
  * ★ isTTY, json, forceBrowser, noBrowser —— 见 resolveBrowserIntent(),
682
764
  * ★ handoff, postDeviceGraceMs —— 见下 }
683
- * ★ `isTTY` / `json` / `forceBrowser`(`--open-browser`)/ `noBrowser`(`--no-browser`)四个字段
684
- * 决定"要不要替用户弹系统浏览器":★ **默认总是尝试打开**(人在终端与 AI 场景都一样),
685
- * 只有 `noBrowser`(`--no-browser`)才不打开;`isTTY` / `json` 现在只影响 `mode` 这个诊断字段
686
- * ——理由见 resolveBrowserIntent()。
687
- * `callbackFirstWaitMs` —— ★ 首轮等待窗口(默认 `DEFAULT_CALLBACK_FIRST_WAIT_MS` = 180 秒;
688
- * 传 0 或负数 = 关掉自愈,只等整段 `loginTimeoutMs`)。命令层会把它透传进来
689
- * (**只有检测到系统代理时才给非 0**,见 `commands/auth.mjs` 的 resolveCallbackFirstWaitMs),
690
- * 单测则用短窗口(例如 400ms)来验证"自动改设备码"这条分支,不必真的等 180 秒。
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()。
769
+ * `callbackFirstWaitMs` —— 可选的「首轮等待」窗口,**默认 0 = 关闭**(0.2.2 起)。
770
+ * 只有用户显式写了 `--callback-first-wait` / `WKONGAI_CALLBACK_FIRST_WAIT_MS` 时,
771
+ * 命令层才会给非 0 值(见 `commands/auth.mjs` 的 resolveCallbackFirstWaitMs);
772
+ * 传 0 / 不传 / 负数 = 不自愈,一直等满整段 `loginTimeoutMs`。
773
+ * 单测用短窗口(例如 400ms)验证"显式开启后自动改设备码"这条分支。
691
774
  * ★★ `handoff` —— **「回调 / 设备码两条路并存」的交接面**(2026-10-03 修 ERR_CONNECTION_REFUSED 时加的)。
692
775
  * 调用方(命令层)传一个**可变对象**,两边各填一半:
693
- * * 调用方**先**填 `handoff.onSwitch(reason)` —— 首轮等待/整段超时到点时会被调用
694
- * (`reason` 是 `'callback_first_wait'` 或 `'callback_timeout'`),它应当去启动设备码那条路,
776
+ * * 调用方**先**填 `handoff.onSwitch(reason)` —— **显式首轮等待**到点时会被调用
777
+ * (`reason` 是 `'callback_first_wait'`),它应当去启动设备码那条路,
695
778
  * **不要**在这里结束回调这条路:本地监听由本函数继续持有;
779
+ * ⚠️ 整段 `--timeout` 到点**不会**再调用它(0.2.2 起取消"等太久自动改道")。
696
780
  * * 本函数填 `handoff.finishWithDevice(result)` / `handoff.abort(error)` ——
697
781
  * 设备码那条路先拿到令牌时调前者(本函数据此收尾,并给"迟到的回调"回一张已完成页),
698
782
  * 设备码那条路也失败时调后者(本函数用这个错误结束等待)。
699
- * ⚠️ **没传 `handoff`(或 `onSwitch` 不是函数)= 旧契约**:到点直接抛 `callback_first_wait` /
700
- * `timeout` 并在 `finally` 里关掉监听(既有的 runLogin 级单测走的就是这条,语义不许变)。
783
+ * ⚠️ **没传 `handoff`(或 `onSwitch` 不是函数)= 旧契约**:显式首轮等待到点直接抛
784
+ * `callback_first_wait` 并在 `finally` 里关掉监听(既有 runLogin 级单测走的就是这条)。
701
785
  * `postDeviceGraceMs` —— 设备码先成功后,本地监听再多留多久(默认 `DEFAULT_POST_DEVICE_GRACE_MS`;
702
- * 只对"首轮等待"这条自愈生效;单测注入小值即可确定性地验证"迟到的回调拿到已完成页")。
786
+ * 只对"显式首轮等待"这条自愈生效;单测注入小值即可确定性地验证"迟到的回调拿到已完成页")。
703
787
  */
704
788
  export async function runLogin({ config, log = () => {}, deps = {} }) {
705
789
  const fetchImpl = deps.fetchImpl || globalThis.fetch;
@@ -710,14 +794,16 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
710
794
  *
711
795
  * ★ 2026-10-01 起:`deps.openBrowser === false` 只是"用户主动关掉"这一种情况;
712
796
  * **要不要弹系统浏览器**这件事统一由 `resolveBrowserIntent()` 裁决 ——
713
- * ★ 老板最终拍板走**传统路线**:**默认总是尝试打开**(AI 场景也打开),
714
- * 只有 `--no-browser` 才不打开(见 resolveBrowserIntent 的说明)。
797
+ * ★ 2026-10-21 起:**人在终端默认打开**;`--no-browser` 与 **非交互/CI**(非 TTY / CI 环境变量 /
798
+ * `WKONGAI_CLI_NO_BROWSER=1`)都不打开(见 resolveBrowserIntent / detectNonInteractive 的说明)。
715
799
  */
716
800
  const browserIntent = resolveBrowserIntent({
717
801
  isTTY: deps.isTTY === true,
718
802
  json: deps.json === true,
719
803
  forceOpen: deps.forceBrowser === true,
720
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 : {},
721
807
  });
722
808
  /**
723
809
  * ★★ 最终是否自动打开系统浏览器 = **唯一闸门**的结论。
@@ -843,7 +929,7 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
843
929
  let settled = false;
844
930
  let server;
845
931
  let timer = null;
846
- /** ★ 首轮等待定时器(见 DEFAULT_CALLBACK_FIRST_WAIT_MS):到点 → 让命令层改走设备码 */
932
+ /** ★ 可选首轮等待定时器(仅显式 `--callback-first-wait` 才有):到点 → 让命令层并发跑设备码 */
847
933
  let firstWaitTimer = null;
848
934
  /**
849
935
  * ★★ 交接面(见 deps.handoff 的说明):命令层传进来的可变对象,两边各填一半。
@@ -1193,7 +1279,7 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1193
1279
  opened: false,
1194
1280
  tried: [],
1195
1281
  url: authUrl.toString(),
1196
- // ★ 现在只可能因为 `--no-browser` 不尝试(`non_tty_agent_mode` 已随老板拍板走传统路线而弃用)
1282
+ // ★ 只有两种"不尝试":`--no-browser`(no_browser)与非交互/CI(non_interactive)
1197
1283
  skipped_reason: browserIntent.skipped_reason,
1198
1284
  };
1199
1285
  if (shouldOpenBrowser) {
@@ -1210,8 +1296,24 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1210
1296
  }
1211
1297
  }
1212
1298
  if (!shouldOpenBrowser) {
1213
- // ⚠️ 只有"--no-browser"才打这一行(用户主动关掉的,不该报"失败")
1214
- 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
+ }
1215
1317
  } else if (openStatus.opened) {
1216
1318
  // ★ 成功:明说"已经打开了",并告诉用户去那里做什么(老板最终拍板走传统路线)
1217
1319
  log(browserOpenedText({ kind: 'auth' }));
@@ -1237,15 +1339,18 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1237
1339
  /**
1238
1340
  * ★ 机器可读的"自动打开"状态:AI 端据此判断"到底要不要催用户手动打开"。
1239
1341
  * `authorization_url` 仍然照旧单独给(绝不能因为多了这个字段就把它挤掉)。
1240
- * ★ 老板最终拍板走**传统路线**:CLI 默认**总是尝试**打开系统浏览器(AI 场景也一样),
1241
- * 所以 `skipped_reason` 现在只有两种取值:
1242
- * * `null` = 真的尝试过(成没成看 `opened`);
1243
- * * `"no_browser"` = 调用方/用户明确要求别打开(唯一开关)。
1342
+ * ★ 2026-10-21 起 `skipped_reason` 有三种取值:
1343
+ * * `null` = 真的尝试过(成没成看 `opened`);
1344
+ * * `"no_browser"` = 调用方/用户明确要求别打开;
1345
+ * * `"non_interactive"` = CI / 管道 / AI 子进程(**默认不弹**,见 detectNonInteractive)。
1244
1346
  * (历史取值 `"non_tty_agent_mode"` 已弃用,见 resolveBrowserIntent 的注释。)
1245
1347
  */
1246
1348
  open_browser: openStatus,
1247
1349
  /** ★ 给 AI 端"照着念"的一句话(AI 不该自己组织语言,否则容易漏掉网址) */
1248
- paste_text: browserPasteText(authUrl.toString(), { kind: 'auth' }),
1350
+ paste_text: browserPasteText(authUrl.toString(), {
1351
+ kind: 'auth',
1352
+ nonInteractive: browserIntent.skipped_reason === 'non_interactive',
1353
+ }),
1249
1354
  });
1250
1355
  }
1251
1356
 
@@ -1257,17 +1362,21 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1257
1362
  * (⚠️ 注意:有代理时命令层通常**已经**走设备码了,根本走不到这里;
1258
1363
  * 能走到这里的情况是"用户在代理下强制回调"或"探测没发现代理但回调仍被别的
1259
1364
  * 东西挡了 —— 防火墙/安全软件/浏览器插件"。)
1260
- * ★ 传了 `handoff` 时**不再抛错**:改成通知命令层"并发去跑设备码",本地监听**保持开着**
1261
- * (用户随后才点的那次【同意】照样能成功;见 DEFAULT_CALLBACK_FIRST_WAIT_MS 的真机证据)。
1365
+ * ★★ 0.2.2 起:整段超时**不再自动改设备码**(老板:默认就是回调,设备码只在
1366
+ * 本地读不了/代理时才用)。这里只报 `kind = 'timeout'`,并在 hint 里明确给出
1367
+ * "显式改用 `--device`"的下一步。
1368
+ * ⚠️ 唯一例外:用户**显式**开了 `--callback-first-wait`,到点已经把设备码那条路
1369
+ * 并发点起来了(`switchReason` 非空)。此时整段超时到点不能再把设备码那条路判死,
1370
+ * 交给它自己跑到设备码过期/成功为止(与 2026-10-03 的"两条路并存"契约一致)。
1262
1371
  */
1263
- if (canHandoff && !deviceDone) {
1264
- handOff('callback_timeout');
1372
+ if (canHandoff && switchReason && !deviceDone) {
1265
1373
  return;
1266
1374
  }
1267
1375
  finish(new CliError(`等待授权超时(${config.loginTimeoutMs}ms)`, {
1268
1376
  kind: 'timeout',
1269
1377
  hint:
1270
- '授权码本身只有 5 分钟有效期。请重新运行 `wkgeo login`(浏览器里通常还有登录态,点一下同意即可)' +
1378
+ '授权码本身只有 5 分钟有效期。请重新运行 `wkgeo login`(浏览器里通常还有登录态,点一下同意即可);' +
1379
+ '若本机确实无法接收 127.0.0.1 回调(代理/防火墙/远程环境),请显式使用 `wkgeo login --device`(设备码模式)' +
1271
1380
  (proxyHint ? `。另外:${proxyHint}` : ''),
1272
1381
  exitCode: EXIT.AUTH,
1273
1382
  }));
@@ -1275,8 +1384,11 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1275
1384
  if (timer && typeof timer.unref === 'function') timer.unref();
1276
1385
 
1277
1386
  /**
1278
- * ★ 第二条保险:**首轮等待**(默认 180 秒,且只由命令层在"检测到代理"时给非 0 值)没等到回调
1279
- * → 主动结束这一轮等待,让命令层自动改用设备码继续(`kind = 'callback_first_wait'`,**不是** `timeout`)。
1387
+ * 可选自愈:**首轮等待**(★ 默认关闭;只有用户显式 `--callback-first-wait` 才会 > 0)
1388
+ * 没等到回调 → 主动结束这一轮等待 / 或交接给设备码(`kind = 'callback_first_wait'`,**不是** `timeout`)。
1389
+ *
1390
+ * ⚠️ 0.2.2 起**默认不挂这个定时器**(旧版有代理时默认 180 秒,会"等太久自动变设备码",
1391
+ * 正是老板这次反馈要改掉的行为)。
1280
1392
  *
1281
1393
  * 为什么不是在这里直接改走设备码:设备码流程在 `device.mjs` / `commands/auth.mjs`,
1282
1394
  * 而 `runLogin` 的契约是"要么拿到凭据、要么抛一个说明原因的错误"。
@@ -1288,11 +1400,11 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1288
1400
  * ⚠️ 只在 `0 < firstWait < loginTimeoutMs` 时挂:否则先撞到的必然是整段超时,
1289
1401
  * 挂上去只会让两个定时器互相打乱,也让错误信息变得难以理解。
1290
1402
  */
1291
- const firstWaitMs = Number(deps.callbackFirstWaitMs ?? DEFAULT_CALLBACK_FIRST_WAIT_MS);
1403
+ const firstWaitMs = Number(deps.callbackFirstWaitMs ?? 0);
1292
1404
  if (Number.isFinite(firstWaitMs) && firstWaitMs > 0 && firstWaitMs < config.loginTimeoutMs) {
1293
1405
  const label = firstWaitMs >= 1000 ? `${Math.round(firstWaitMs / 1000)} 秒` : `${firstWaitMs} 毫秒`;
1294
- // 提前告知"不用干等",并说清"监听还会开着"(后者正是本次修复:用户之后点同意也来得及)
1295
- log(`(如果 ${label}内没收到回调,会自动改用设备码模式继续,不用一直干等;本地回调监听也会继续保留)`);
1406
+ // 提前告知"不用干等",并说清"监听还会开着"(后者正是 2026-10-03 修复:用户之后点同意也来得及)
1407
+ log(`(已按 --callback-first-wait 显式启用首轮等待:如果 ${label}内没收到回调,会自动改用设备码模式继续;本地回调监听也会继续保留)`);
1296
1408
  firstWaitTimer = setTimeout(() => {
1297
1409
  if (canHandoff && !deviceDone) {
1298
1410
  handOff('callback_first_wait');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wkgeo-cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "云站平台官方命令行客户端:授权登录一次,之后建站 / 下单 / 钱包支付 / 查余额都不用再登录(OAuth 授权码 + PKCE,公共客户端,零第三方依赖)",
5
5
  "keywords": [
6
6
  "wkgeo",