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/README.md +74 -47
- package/SKILL.md +85 -57
- package/lib/commands/auth.mjs +114 -98
- package/lib/commands/billing.mjs +34 -10
- package/lib/device.mjs +24 -4
- package/lib/login.mjs +228 -116
- package/package.json +1 -1
package/lib/login.mjs
CHANGED
|
@@ -19,16 +19,23 @@
|
|
|
19
19
|
* 依赖注入(deps)是为了**可测**:offline 单测里把 fetch / 开浏览器 / 端口都替换掉,
|
|
20
20
|
* 就成了一台"假浏览器",可以逐条验证 state 校验、超时、错误页这些分支。
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
* (详见 `
|
|
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
|
-
*
|
|
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
|
-
*
|
|
156
|
-
*
|
|
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
|
-
*
|
|
161
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* ★★
|
|
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
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
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
|
|
423
|
-
*
|
|
424
|
-
* 2.
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
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
|
-
* ##
|
|
432
|
-
* 2026-10-01
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
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
|
|
444
|
-
* @param {boolean} [opts.json] 是不是 JSON
|
|
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
|
-
* @
|
|
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
|
-
//
|
|
525
|
+
// ④ 人在终端:保持 CLI 一直以来的体验(这条**不能**破坏)
|
|
455
526
|
if (isTTY === true && json !== true) return { allow: true, mode: 'terminal', skipped_reason: null };
|
|
456
|
-
//
|
|
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
|
-
*
|
|
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:(
|
|
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
|
-
*
|
|
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
|
-
* 决定"要不要替用户弹系统浏览器"
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
* `callbackFirstWaitMs` ——
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
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'
|
|
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` 不是函数)=
|
|
700
|
-
* `
|
|
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
|
-
* ★
|
|
714
|
-
*
|
|
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
|
-
/** ★
|
|
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
|
-
// ★
|
|
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
|
-
|
|
1214
|
-
|
|
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
|
-
* ★
|
|
1241
|
-
*
|
|
1242
|
-
* * `
|
|
1243
|
-
* * `"
|
|
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(), {
|
|
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
|
-
*
|
|
1261
|
-
*
|
|
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
|
-
*
|
|
1279
|
-
* →
|
|
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 ??
|
|
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(
|
|
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');
|