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.
@@ -5,7 +5,7 @@
5
5
  * 需要用户做的事(同意授权)表现为:打印网址 + 阻塞等待 + 超时给明确错误。
6
6
  */
7
7
 
8
- import { runLogin, DEFAULT_CALLBACK_FIRST_WAIT_MS } from '../login.mjs';
8
+ import { runLogin } from '../login.mjs';
9
9
  import { requestDeviceCode, announceDeviceCode, pollDeviceToken, devicePasteText } from '../device.mjs';
10
10
  import {
11
11
  pendingDeviceFile,
@@ -62,34 +62,36 @@ export function resolveAutoDevice(flags = {}, env = {}) {
62
62
  }
63
63
 
64
64
  /**
65
- * 「回调模式首轮等待」窗口(毫秒)的解析 —— ★ 第二条保险(见 login.mjs 的同名注释)。
65
+ * 「回调模式首轮等待」窗口(毫秒)的解析 —— **可选自愈,默认关闭**。
66
66
  *
67
- * ## ⚠️⚠️ 2026-10-03 老板真机反馈后改了触发条件(这是本次修复的第一条)
68
- * 旧逻辑:**无条件**返回默认 60 秒 ⇒ 用户机器上**没有任何代理/VPN**时也会自愈。
69
- * 而"把授权页送到用户面前、他读完再点【同意】"经常超过 60 秒 ⇒ 明明不需要设备码也切了过去。
70
- * 老板原话:「现在变成设备码了,我们因为没有开任何VPN,所以这个是不对的。」
71
- * ⇒ 现在:**只有 `proxyDetected === true`(真检测到生效中的代理)才用默认值启用**;
72
- * **没有代理时不启用**(返回 0,老老实实等满 `--timeout`)。
67
+ * ## ★★ 0.2.2(老板 2026-10-21 反馈):默认不再自动改设备码
68
+ * 老板原话:「默认的也不应该是设备授权,默认的就是那个传统的授权方式(浏览器打开授权页 +
69
+ * 本地 localhost 回调)。设备码是本地读不了/代理时才用的。」
70
+ * ⇒ 本函数**不再**按"有没有代理"给默认值:用户没显式指定时**一律返回 0**。
71
+ * 也就是说 `wkgeo login` 默认会一路等回调到 `--timeout`,**不会**因为等太久自动改设备码。
73
72
  *
74
- * ## 优先级(显式 > 自动判断)
75
- * `--callback-first-wait <时长>`(默认单位**秒**;`0` / `off` / `none` = 关掉自愈)
73
+ * 设备码只在这三种情况出现(见 loginCommand.run):
74
+ * 1. 用户显式 `--device` / `--no-wait` / `--device-continue`;
75
+ * 2. 检测到系统代理且判定回调不可用(`--no-auto-device` 可关);
76
+ * 3. 本地回调端口**真的起不来**(`kind = 'listen'`,真实失败兜底;`--no-auto-device` 可关)。
77
+ *
78
+ * ## 保留的显式开关(向后兼容,不参与默认判断)
79
+ * `--callback-first-wait <时长>`(默认单位**秒**;`0` / `off` / `none` = 关闭)
76
80
  * > `WKONGAI_CALLBACK_FIRST_WAIT_MS`(毫秒)
77
- * > **有代理**时的 `DEFAULT_CALLBACK_FIRST_WAIT_MS`(180 秒)/ **无代理**时的 0
78
- * ⇒ 用户**显式**指定时一律照旧尊重(哪怕没有代理)—— 显式指定优先于自动判断。
81
+ * > 默认 **0(关闭)**
82
+ * ⇒ 只有用户**显式**写了它,才会在首轮等待到点后并发启动设备码(两条路先到先得)。
83
+ * 显式指定是用户的明确选择,我们照旧尊重(但他不写就绝不会自动发生)。
79
84
  *
80
85
  * ⚠️ `--no-auto-device`(autoDevice=false)时**强制返回 0**:用户已经明确要求"只走回调",
81
- * 我们没有"改道"这个选项,就不能背着他偷偷改(那样比不给自愈更糟:他会以为
82
- * CLI 没听他的)。此时整段 `--timeout` 依然是兜底。
86
+ * 我们就连显式开关也不启用(不背着他改道)。此时整段 `--timeout` 依然是兜底。
83
87
  *
84
88
  * @param {object} flags 已解析的命令行标志(camelCase)
85
89
  * @param {object} env 环境变量
86
90
  * @param {{autoDevice?: boolean, proxyDetected?: boolean}} [opts]
87
- * `proxyDetected` **必须**由调用方用 `proxyInfo.detected` 显式传入;
88
- * 缺省按 `false`(= 没检测到代理)处理 —— 这是刻意的保守默认:
89
- * "信息不足时宁可不自愈",也避免有人忘了传就又回到"无代理也切设备码"的老毛病。
90
- * @returns {number} 毫秒;0 = 关闭自愈
91
+ * `proxyDetected` 仅为**兼容旧调用方**保留,**已不影响返回值**(0.2.2 起默认永不开启)。
92
+ * @returns {number} 毫秒;0 = 不启用(默认)
91
93
  */
92
- export function resolveCallbackFirstWaitMs(flags = {}, env = {}, { autoDevice = true, proxyDetected = false } = {}) {
94
+ export function resolveCallbackFirstWaitMs(flags = {}, env = {}, { autoDevice = true } = {}) {
93
95
  if (!autoDevice) return 0;
94
96
  const off = new Set(['0', 'off', 'none', 'false', 'no']);
95
97
  if (flags.callbackFirstWait !== undefined && flags.callbackFirstWait !== '') {
@@ -103,11 +105,11 @@ export function resolveCallbackFirstWaitMs(flags = {}, env = {}, { autoDevice =
103
105
  return parseDurationMs(envVal, { defaultUnit: 'ms', flag: 'WKONGAI_CALLBACK_FIRST_WAIT_MS' });
104
106
  }
105
107
  /**
106
- * ★ 走到这里 = 用户**没有**显式指定 ⇒ 只有"真检测到代理"才启用自愈。
107
- * 没有代理时返回 0:`auto_device.applied=true, reason='callback_first_wait'`
108
- * 因此**只可能**出现在有代理的机器上(老板真机那条反馈的口径)。
108
+ * ★ 走到这里 = 用户**没有**显式指定 ⇒ **默认不启用**(0.2.2 起)。
109
+ * 不再看 `proxyDetected`:有代理时命令层已经在第一跳直接走设备码了,
110
+ * 回调模式下的"等待超时"只该如实报错,不该偷偷改道。
109
111
  */
110
- return proxyDetected ? DEFAULT_CALLBACK_FIRST_WAIT_MS : 0;
112
+ return 0;
111
113
  }
112
114
 
113
115
  /**
@@ -190,7 +192,9 @@ function bothPathsFailedError(deviceError, reason, { loginTimeoutMs }) {
190
192
  const callbackSide =
191
193
  reason === 'callback_timeout'
192
194
  ? `等待授权超时(${loginTimeoutMs}ms,本机回调监听当时仍然开着)`
193
- : '首轮等待没收到回调(本机回调监听当时仍然开着)';
195
+ : reason === 'callback_listen_failed'
196
+ ? '本地回调端口起不来(根本没起监听)'
197
+ : '首轮等待没收到回调(本机回调监听当时仍然开着)';
194
198
  return new CliError(`本地回调没成功,改用设备码也失败了:${deviceError.message}`, {
195
199
  kind: deviceError.kind || 'device_authorization_failed',
196
200
  code: deviceError.code === undefined ? null : deviceError.code,
@@ -212,28 +216,28 @@ const TERMINAL_DEVICE_KINDS = new Set(['denied', 'expired', 'not_implemented', '
212
216
 
213
217
  export const loginCommand = {
214
218
  name: 'login',
215
- summary: '浏览器授权一次,把 30 天令牌存到本地(默认本地回调模式;--device 走设备码)',
219
+ summary: '浏览器授权一次,把 30 天令牌存到本地(默认本地回调模式;设备码仅在本地无法回调/检测到代理时兜底)',
216
220
  usage:
217
221
  'wkgeo login [--scope "site:read site:write billing:self"] [--no-browser] [--open-browser] [--client-id <id>] [--callback-port <n>] [--timeout <秒>]\n' +
218
- 'wkgeo login --device # 设备码模式:不需要回调地址/监听端口,给用户一个短码\n' +
222
+ 'wkgeo login --device # 设备码模式:不需要回调地址/监听端口,给用户一个短码(兜底路径)\n' +
219
223
  'wkgeo login --device --no-wait # ★ 只申请短码就退出(AI 端友好),稍后用下面这条领令牌\n' +
220
224
  'wkgeo login --device-continue # 用上次保存的设备码换票、写完凭据就完成登录\n' +
221
- 'wkgeo login --no-auto-device # **检测到系统代理也不**自动改设备码(强制走回调;CLI 会警告)\n' +
225
+ 'wkgeo login --no-auto-device # 检测到系统代理也不自动改设备码(强制回调;CLI 会警告;端口真起不来也不兜底)\n' +
222
226
  'wkgeo login --open-browser # 强制尝试打开系统浏览器(⚠️ 现在与默认行为等价,保留只为显式语义与兼容)\n' +
223
- 'wkgeo login --callback-first-wait 60 # 首轮等待 60 秒(★ 只在检测到代理时才有默认值 180;0/off = 关掉这个自愈)\n' +
227
+ 'wkgeo login --callback-first-wait 60 # ★ 可选:显式启用"首轮等待 60 秒没回调就并行改设备码"(默认关闭;0/off = 关闭)\n' +
224
228
  'wkgeo login --timeout 600 # 等待授权 600 秒(默认单位是秒,不是毫秒;也支持 600s / 500ms)',
225
229
  flags: {
226
- browser: { type: 'boolean', desc: '是否自动打开浏览器(★ 默认总是尝试打开:终端与 AI 场景都一样;--no-browser 关闭)' },
230
+ browser: { type: 'boolean', desc: '是否自动打开浏览器(★ 默认:人类终端打开;CI/管道/AI 子进程等非交互环境不打开;--no-browser 关闭)' },
227
231
  /**
228
- * ★ 老板最终拍板走**传统路线**(弹系统浏览器):CLI **默认总是尝试打开**,
229
- * 唯一的"不打开"开关是 `--no-browser`。
230
- * 本开关现在**与默认行为等价**,保留只为两件事:
231
- * 1. **显式语义**("我就是要弹,你别管");
232
+ * ★ 2026-10-21(老板反馈:CI 里弹了浏览器):默认**只在人类终端**自动打开;
233
+ * 非交互/CI 环境(stdout 非 TTY / CI 环境变量 / `WKONGAI_CLI_NO_BROWSER=1`)**一律不打开**。
234
+ * 本开关保留只为两件事:
235
+ * 1. **显式语义**(终端里明确表达"我就是要弹");
232
236
  * 2. **兼容**既有脚本(不许删)。
233
- * ⚠️ 与 `--no-browser` 同时出现时 **--no-browser 优先**(关掉就是关掉,见 resolveBrowserIntent)。
237
+ * ⚠️ 它**压不过**非交互判定(CI 里写它也不弹);与 `--no-browser` 同时出现时 `--no-browser` 优先。
234
238
  */
235
- 'open-browser': { type: 'boolean', desc: '强制尝试打开系统浏览器(⚠️ 现在与默认行为等价,保留只为显式语义与兼容)' },
236
- device: { type: 'boolean', desc: '用设备码模式(RFC 8628)代替本地回调:不需要任何回调地址,对代理/防火墙免疫' },
239
+ 'open-browser': { type: 'boolean', desc: '强制尝试打开系统浏览器(⚠️ 仅在人类终端生效;CI/非交互环境仍不打开)' },
240
+ device: { type: 'boolean', desc: '用设备码模式(RFC 8628)代替本地回调:不需要任何回调地址,对代理/防火墙免疫(本地读不了回调时的兜底)' },
237
241
  /**
238
242
  * ★ 老板 2026-10-02 要的"AI 端友好路径"。
239
243
  * 为什么做成"申请完就退出"而不是"申请完继续挂着轮询"(后者靠 JSONL 事件流也能做):
@@ -246,20 +250,20 @@ export const loginCommand = {
246
250
  'device-continue': { type: 'boolean', desc: '继续上一次 --no-wait/被中断的设备码登录(读取本地保存的设备码并轮询到出结果)' },
247
251
  'auto-device': {
248
252
  type: 'boolean',
249
- desc: '检测到系统代理时自动改用设备码(**默认开**;--no-auto-device 或 WKONGAI_AUTO_DEVICE=0 关闭,关闭时会警告"代理可能吃掉回调")',
253
+ desc: '检测到系统代理时自动改用设备码(**默认开**;--no-auto-device 或 WKONGAI_AUTO_DEVICE=0 关闭,关闭时会警告"代理可能吃掉回调",且端口真起不来时也不兜底)',
250
254
  },
251
255
  /**
252
- * ★ 第二条保险(见 login.mjs 的 DEFAULT_CALLBACK_FIRST_WAIT_MS)。
256
+ * ★ 可选自愈(默认**关闭**,0.2.2 起)。
253
257
  * 默认单位**秒**,与 `--timeout` 一致(人类写等待窗口都按秒想);
254
- * `0` / `off` / `none` = 关掉自愈,回到"只等整段 --timeout"的旧行为。
258
+ * `0` / `off` / `none` = 关闭(默认值就是关闭)。
255
259
  * 环境变量 `WKONGAI_CALLBACK_FIRST_WAIT_MS` 是毫秒(与 `WKONGAI_LOGIN_TIMEOUT_MS` 同口径)。
256
260
  *
257
- * ⚠️ 2026-10-03 起:**不传它时默认值是"看有没有代理"** —— 有代理才是 180 秒,没有代理是 0
258
- * (老板真机反馈:没有开任何 VPN 也被切成了设备码)。显式传值时照旧尊重用户。
261
+ * ⚠️ 2026-10-21(老板反馈):**默认不再自动改设备码** —— 不写它时无论等多久都只走回调,
262
+ * 超时只报错。只有显式写它,才会在首轮等待到点后并发启动设备码(显式优先)。
259
263
  */
260
264
  'callback-first-wait': {
261
265
  type: 'string',
262
- desc: '回调模式的**首轮等待**(默认单位秒;★ 只在检测到系统代理时默认 180 秒,无代理默认不启用):这段时间没等到回调就自动改用设备码继续(本地回调监听会同时保留);0/off 关闭该自愈',
266
+ desc: '★ 可选自愈(默认**关闭**,默认单位秒):显式写它才会在"首轮等待"到点后并行改用设备码继续(本地回调监听同时保留);不写它时**不会**因为等待超时自动改设备码;0/off 关闭',
263
267
  },
264
268
  /**
265
269
  * 显式声明一次(`timeout` 本来就在 GLOBAL_FLAGS 里),只为让 `wkgeo login --help`
@@ -320,6 +324,12 @@ export const loginCommand = {
320
324
  const proxyInfo = !wantsDevice && !wantsContinue ? ctx.proxyInfo() : null;
321
325
  const proxyPayload = proxyInfo ? proxyWarningPayload(proxyInfo, { cmdName }) : null;
322
326
  const autoDevice = resolveAutoDevice(flags, ctx.env);
327
+ /**
328
+ * ★ 可选首轮等待(默认 0 = 关闭,0.2.2 起)。
329
+ * 只有用户**显式**写 `--callback-first-wait` / `WKONGAI_CALLBACK_FIRST_WAIT_MS`
330
+ * 才会启用;`--no-auto-device` 强制 0。它**不参与**"默认走什么模式"的判断。
331
+ */
332
+ const callbackFirstWaitMs = resolveCallbackFirstWaitMs(flags, ctx.env, { autoDevice });
323
333
 
324
334
  /**
325
335
  * ★★ 核心规则(2026-10-02 第二版加固):**`detected === true` 就直接走设备码**。
@@ -335,7 +345,7 @@ export const loginCommand = {
335
345
  */
336
346
  const proxyDetected = !!(proxyInfo && proxyInfo.detected);
337
347
  let useDevice = wantsDevice;
338
- /** 为什么会走设备码:proxy_detected / callback_first_wait / callback_timeout / callback_listen_failed */
348
+ /** 为什么会走设备码:proxy_detected / callback_listen_failed / callback_first_wait(仅显式开启时) */
339
349
  let downgradeReason = null;
340
350
  if (!useDevice && autoDevice && proxyDetected) {
341
351
  useDevice = true;
@@ -364,31 +374,33 @@ export const loginCommand = {
364
374
  /** 真 spawn 的注入点(单测验证"AI 场景一次都不 spawn 浏览器";生产不传 = 真 spawn) */
365
375
  spawnImpl: ctx.spawnImpl,
366
376
  /**
367
- * ★★ 「要不要替用户弹**系统**浏览器」的四个事实(老板最终拍板走**传统路线**):
377
+ * ★★ 「要不要替用户弹**系统**浏览器」的输入(2026-10-21 起含 CI/非交互检测):
368
378
  * * `isTTY` —— stdout 是不是终端(由 runtime 从 stdout.isTTY 读出,命令层原样透传);
369
379
  * * `json` —— 是不是 JSON 输出(`--json` 或非 TTY 自动 JSON);
370
- * * `forceBrowser` —— 用户写了 `--open-browser`(现在与默认行为等价,保留显式语义);
371
- * * `noBrowser` —— 用户写了 `--no-browser`(**唯一**的"不打开"开关,优先级最高)。
372
- * 四个一起进 `resolveBrowserIntent()`(login.mjs 里的**唯一裁决点**):
373
- * **默认总是尝试打开系统浏览器**(人在终端 ✔ + AI 场景 ✔);
374
- * `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`"的提示。
375
386
  * ⚠️ 回调模式与设备码模式都吃这一份 deps —— 所以两条授权路径的行为天然一致。
376
387
  */
377
388
  isTTY: ctx.isTTY === true,
378
389
  json: ctx.out.json === true,
379
390
  forceBrowser: flags.openBrowser === true,
380
391
  noBrowser: flags.browser === false,
392
+ /** ★ CI / 非交互判定用(纯函数在 login.mjs 的 detectNonInteractive) */
393
+ env: ctx.env || {},
381
394
  platform: ctx.platform,
382
395
  /**
383
- * ★ 第二条保险:回调模式的**首轮等待**。
396
+ * ★ 可选首轮等待(见上面 `callbackFirstWaitMs`)。
384
397
  *
385
- * ⚠️⚠️ 2026-10-03 起默认值**取决于有没有代理**(老板真机反馈修的):
386
- * * 有代理(`proxyDetected === true`)→ `DEFAULT_CALLBACK_FIRST_WAIT_MS`(180 秒);
387
- * * **没有代理 → 0 = 不启用**(老老实实等满 `--timeout`);
388
- * * 用户显式写 `--callback-first-wait` / `WKONGAI_CALLBACK_FIRST_WAIT_MS` → 照旧尊重;
398
+ * ⚠️⚠️ 0.2.2 起**默认 0 = 关闭**(老板 2026-10-21 反馈):
399
+ * * 不写 `--callback-first-wait` / 环境变量 → 0,一直等满 `--timeout`,**不会**自动改设备码;
400
+ * * 显式写 `--callback-first-wait <秒>` / `WKONGAI_CALLBACK_FIRST_WAIT_MS` → 照旧启用;
389
401
  * * `--no-auto-device` → 强制 0(用户明确要求只走回调,就不能偷偷改道)。
390
402
  */
391
- callbackFirstWaitMs: resolveCallbackFirstWaitMs(flags, ctx.env, { autoDevice, proxyDetected }),
403
+ callbackFirstWaitMs,
392
404
  /**
393
405
  * ★★ 设备码先成功后,本地回调监听再多留多久(毫秒;0 = 立刻收尾)。
394
406
  * 生产不传 = `login.mjs` 的 `DEFAULT_POST_DEVICE_GRACE_MS`;
@@ -398,19 +410,23 @@ export const loginCommand = {
398
410
  };
399
411
 
400
412
  /**
401
- * ★ 无代理时的一句说明(老板真机反馈的直接回应)。
413
+ * ★ 默认模式的正面说明(老板 2026-10-21 反馈的直接回应)。
402
414
  *
403
- * 为什么值得打这一行:他这次的困惑正是"**我没有开代理,为什么变成设备码了**"。
404
- * 明说"这次不会因为首轮等待就自动改设备码"能直接消掉这个困惑;
405
- * 同时也要说清"整段 --timeout 用完仍可能改"(那是既有的兜底语义,本次没有改动)。
406
- * ⚠️ 只在"回调模式 + 没检测到代理 + 没开自愈"时打:其它情况一律不打(正常环境不要噪音)。
415
+ * 老板原话:「默认的也不应该是设备授权,默认的就是那个传统的授权方式(浏览器打开授权页 +
416
+ * 本地 localhost 回调)。设备码是本地读不了/代理时才用的。」
417
+ * ⇒ 这里明说"本次就是回调、等多久都不会自动改设备码",同时列出**唯一会走设备码**的三种情况,
418
+ * 并给出"主动提前切换"的显式开关,免得用户以为 CLI 背着他改道。
419
+ * ⚠️ 只在"回调模式 + 没检测到代理 + 没显式开首轮等待"时打:其它情况一律不打(正常环境不要噪音)。
407
420
  */
408
- if (!useDevice && !wantsContinue && !proxyDetected && loginDeps.callbackFirstWaitMs === 0) {
421
+ if (!useDevice && !wantsContinue && !proxyDetected && callbackFirstWaitMs === 0) {
409
422
  log(
410
- `ℹ️ 未检测到系统代理:本次会一直等你完成授权(最多 ${Math.round(config.loginTimeoutMs / 1000)} 秒),` +
411
- '不会因为"首轮等待"到点就自动改用设备码。',
423
+ `ℹ️ 未检测到系统代理:本次使用浏览器回调授权(默认),会一直等你完成授权(最多 ${Math.round(config.loginTimeoutMs / 1000)} 秒)。`,
412
424
  );
413
- log(' (想主动限制首轮等待:`--callback-first-wait <秒>`;想强制设备码:`--device`。)');
425
+ log(
426
+ ' 不会因为"首轮等待/等待超时"到点就自动改用设备码;只有显式 `--device`、检测到系统代理、' +
427
+ '或本地回调端口真的起不来时才会用设备码(`--no-auto-device` 可关掉后两种自动兜底)。',
428
+ );
429
+ log(' (想主动提前切换:`--callback-first-wait <秒>`;想直接走设备码:`--device`。)');
414
430
  }
415
431
 
416
432
  /**
@@ -561,10 +577,10 @@ export const loginCommand = {
561
577
  }
562
578
  }
563
579
 
564
- /* ---- 2c. 本地回调模式(含"回调没等到 → 并发改设备码",★ 两条路并存) ---- */
580
+ /* ---- 2c. 本地回调模式(默认路径;显式首轮等待时才与设备码两条路并存) ---- */
565
581
  if (!result && !useDevice) {
566
582
  /**
567
- * ★★ 2026-10-03(老板真机反馈 ⚠️):「改用设备码」**不再关掉本地回调监听**。
583
+ * ★★ 2026-10-03(老板真机反馈 ⚠️):显式开启的"首轮等待"到点改道时**不再关掉本地回调监听**。
568
584
  *
569
585
  * ## 旧行为(真机证据:ERR_CONNECTION_REFUSED)
570
586
  * 首轮等待到点 → `runLogin` 抛错,并在自己的 `finally` 里**关掉本地 server** → 命令层再走设备码。
@@ -572,15 +588,17 @@ export const loginCommand = {
572
588
  * `http://127.0.0.1:<port>/callback?code=…` → **ERR_CONNECTION_REFUSED**
573
589
  * (老板真机截图见过)→ 用户以为授权失败,再来一遍(而其实他马上就会登录成功)。
574
590
  *
575
- * ## 新行为:两条路**并存**,哪个先拿到令牌就用哪个(先到先得)
591
+ * ## 新行为(仅显式 `--callback-first-wait` 时):两条路**并存**,哪个先拿到令牌就用哪个
576
592
  * * 回调那条路:本地 server 一直开着(`runLogin` 收到 `handoff` 时到点**不结束等待**);
577
593
  * * 设备码那条路:由下面的 `handoff.onSwitch` 启动,轮询到出结果为止。
578
594
  * * 回调先成功 → 把设备码那条路**优雅停掉**(唤醒它挂着的 sleep + 删掉待完成状态文件);
579
595
  * * 设备码先成功 → `finishWithDevice()` 让回调那条路收尾(监听再多留几秒,
580
596
  * 迟到的回调会拿到"本次登录已经完成"的页面,见 login.mjs / page.mjs)。
597
+ * ★ 0.2.2 起默认**不启用**这套交接(`--callback-first-wait` 未显式给出时
598
+ * `callbackFirstWaitMs === 0`):默认就是一路等回调,超时只报错。
581
599
  */
582
- /** 允许"自动改道"吗:用户没写 --no-auto-device,也不是显式设备码模式 */
583
- const mayAutoSwitch = autoDevice && !wantsDevice && !wantsContinue;
600
+ /** 允许"等待到点改道"吗:只有用户**显式**开了首轮等待,且没写 --no-auto-device、不是显式设备码模式 */
601
+ const mayAutoSwitch = autoDevice && !wantsDevice && !wantsContinue && callbackFirstWaitMs > 0;
584
602
  /** 设备码那条路的可取消状态(回调先赢时要把它收拾干净) */
585
603
  const deviceSide = { reason: null, cancelled: false, promise: null, result: null, error: null };
586
604
  /** ★ 可被立刻叫醒的 sleep:回调先赢时,不能让设备码那个 5 秒定时器把进程吊住 */
@@ -648,13 +666,21 @@ export const loginCommand = {
648
666
  handoff,
649
667
  onAuthorizationUrl: (url, extra) => {
650
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');
651
675
  out.event({
652
676
  ok: true,
653
677
  command: 'login',
654
678
  mode: 'callback',
655
679
  stage: 'awaiting_callback',
656
680
  authorization_url: url,
657
- user_action_required: '请让用户在浏览器中打开上面的 authorization_url,登录并点「同意」',
681
+ user_action_required: nonInteractive
682
+ ? '本机是非交互环境(CI/管道/AI 子进程),不会自动打开浏览器:请把 authorization_url 转给有浏览器的用户打开;若本机无法接收回调,改用 `wkgeo login --device`(设备码模式)'
683
+ : '请让用户在浏览器中打开上面的 authorization_url,登录并点「同意」',
658
684
  proxy_warning: proxyPayload,
659
685
  ...extra,
660
686
  });
@@ -668,31 +694,25 @@ export const loginCommand = {
668
694
  /** 设备码那条路也失败了 ⇒ 优先报"两条路都不通"(哪怕回调那边先抛了自己的错) */
669
695
  if (deviceSide.combinedError) throw deviceSide.combinedError;
670
696
  /**
671
- * ★ 「回调失败也自动降级」(老板 2026-10-02 要求"不要直接失败退出")。
672
- * 走得到这里的是**没有启用交接**(`--no-auto-device`)或"连端口都起不来"的两种情况:
673
- * * `listen` —— 连回环端口都起不来(被安全软件/防火墙拦):
674
- * 此时**根本没有监听可保留**,只能老老实实改走设备码;
675
- * * `callback_first_wait` / `timeout` —— 只可能出现在没传 `handoff.onSwitch` 时(旧契约兜底)。
676
- * 其它错误(换票失败、被拒绝、state 不匹配…)**不降级**:那些是"授权已经走到下一步了",
677
- * 再来一遍设备码只会让用户做重复劳动(甚至以为被耍了)—— 这条语义**本次没有改动**。
697
+ * ★★ 0.2.2 起:**只有"本地回调端口真的起不来"(`kind = 'listen'`)才自动兜底设备码**。
698
+ * (老板:「设备码是本地读不了/代理时才用的」—— 连监听都起不来就是"本地读不了"。)
699
+ * * `listen` —— 回环端口起不来(被安全软件/防火墙拦 / 端口非法):
700
+ * 此时**根本没有监听可保留**,只能老老实实改走设备码;
701
+ * * `timeout` / `callback_first_wait` —— **不再自动改道**:
702
+ * 等待超时只如实报错(默认就是回调,不该因为等太久变设备码)。
703
+ * * `--no-auto-device` 时**连 listen 兜底也关闭**(`canSwitch=false`)。
704
+ * 其它错误(换票失败、被拒绝、state 不匹配…)本来就不降级。
678
705
  */
679
- const canSwitch =
680
- autoDevice && !wantsDevice && (e.kind === 'callback_first_wait' || e.kind === 'timeout' || e.kind === 'listen');
706
+ const canSwitch = autoDevice && !wantsDevice && e.kind === 'listen';
681
707
  if (!canSwitch) throw e;
682
708
  log('');
683
- const reason =
684
- e.kind === 'timeout' ? '等待超时' : e.kind === 'listen' ? '本地端口监听失败' : '首轮等待没收到回调';
685
- log(`⚠️ 本地回调没有等到结果(${reason}):${e.message}`);
686
- if (e.kind === 'callback_first_wait') {
687
- log('ℹ️ 等了这一小段时间没收到回调,已自动改用**设备码模式**继续(不需要本地回调)。');
688
- } else {
689
- log('ℹ️ 已自动改用**设备码模式**继续授权(不需要本地回调)—— 常见原因是代理/防火墙挡了 127.0.0.1。');
690
- }
709
+ log(`⚠️ 本地回调端口起不来(${e.message}):这是"本地读不了回调"的真实失败,改用【设备码模式】继续授权。`);
710
+ log('ℹ️ 设备码不需要本地监听(常见原因是端口被安全软件/防火墙拦);授权页照旧点「同意」即可。');
691
711
  let device;
692
712
  try {
693
713
  device = await requestDeviceCode({ config, deps: loginDeps });
694
714
  } catch (e2) {
695
- throw bothPathsFailedError(e2, e.kind === 'timeout' ? 'callback_timeout' : 'callback_first_wait', {
715
+ throw bothPathsFailedError(e2, 'callback_listen_failed', {
696
716
  loginTimeoutMs: config.loginTimeoutMs,
697
717
  });
698
718
  }
@@ -702,12 +722,7 @@ export const loginCommand = {
702
722
  result = await pollDeviceToken({ config, log, deps: loginDeps, device });
703
723
  deletePendingDevice(pendingFile);
704
724
  useDevice = true;
705
- downgradeReason =
706
- e.kind === 'callback_first_wait'
707
- ? 'callback_first_wait'
708
- : e.kind === 'timeout'
709
- ? 'callback_timeout'
710
- : 'callback_listen_failed';
725
+ downgradeReason = 'callback_listen_failed';
711
726
  }
712
727
 
713
728
  if (deviceSide.reason) {
@@ -722,7 +737,8 @@ export const loginCommand = {
722
737
  * 这里只要把"最终模式/原因"如实记下来。
723
738
  */
724
739
  useDevice = true;
725
- downgradeReason = deviceSide.reason === 'callback_timeout' ? 'callback_timeout' : 'callback_first_wait';
740
+ // 0.2.2 起整段超时不再交接,`deviceSide.reason` 只可能是显式首轮等待
741
+ downgradeReason = 'callback_first_wait';
726
742
  } else {
727
743
  /**
728
744
  * ★ 回调那条路**先**成功(本次修复的核心价值:用户后来点的那次【同意】真的换到了令牌)
@@ -775,9 +791,9 @@ export const loginCommand = {
775
791
  if (downgradeReason === 'proxy_detected')
776
792
  out.info(' (本次因"检测到系统代理"自动改用了设备码模式:不再依赖 127.0.0.1 回调,绕过列表写了什么也不作依据。)');
777
793
  if (downgradeReason === 'callback_first_wait')
778
- out.info(' (回调模式首轮等待没收到回调,已自动改用设备码模式完成登录。)');
779
- if (downgradeReason === 'callback_timeout' || downgradeReason === 'callback_listen_failed')
780
- out.info(' (本地回调没成功,已自动改用设备码模式完成登录。)');
794
+ out.info(' (已按显式 --callback-first-wait:首轮等待没收到回调,已自动改用设备码模式完成登录。)');
795
+ if (downgradeReason === 'callback_listen_failed')
796
+ out.info(' (本地回调端口起不来,已自动改用设备码模式完成登录。)');
781
797
  }
782
798
  return data;
783
799
  },
@@ -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
  }