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/commands/auth.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 需要用户做的事(同意授权)表现为:打印网址 + 阻塞等待 + 超时给明确错误。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { runLogin
|
|
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
|
-
* 「回调模式首轮等待」窗口(毫秒)的解析 ——
|
|
65
|
+
* 「回调模式首轮等待」窗口(毫秒)的解析 —— **可选自愈,默认关闭**。
|
|
66
66
|
*
|
|
67
|
-
* ##
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* **没有代理时不启用**(返回 0,老老实实等满 `--timeout`)。
|
|
67
|
+
* ## ★★ 0.2.2(老板 2026-10-21 反馈):默认不再自动改设备码
|
|
68
|
+
* 老板原话:「默认的也不应该是设备授权,默认的就是那个传统的授权方式(浏览器打开授权页 +
|
|
69
|
+
* 本地 localhost 回调)。设备码是本地读不了/代理时才用的。」
|
|
70
|
+
* ⇒ 本函数**不再**按"有没有代理"给默认值:用户没显式指定时**一律返回 0**。
|
|
71
|
+
* 也就是说 `wkgeo login` 默认会一路等回调到 `--timeout`,**不会**因为等太久自动改设备码。
|
|
73
72
|
*
|
|
74
|
-
*
|
|
75
|
-
* `--
|
|
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
|
-
* >
|
|
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`
|
|
88
|
-
*
|
|
89
|
-
* "信息不足时宁可不自愈",也避免有人忘了传就又回到"无代理也切设备码"的老毛病。
|
|
90
|
-
* @returns {number} 毫秒;0 = 关闭自愈
|
|
91
|
+
* `proxyDetected` 仅为**兼容旧调用方**保留,**已不影响返回值**(0.2.2 起默认永不开启)。
|
|
92
|
+
* @returns {number} 毫秒;0 = 不启用(默认)
|
|
91
93
|
*/
|
|
92
|
-
export function resolveCallbackFirstWaitMs(flags = {}, env = {}, { autoDevice = true
|
|
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
|
-
*
|
|
108
|
-
*
|
|
108
|
+
* ★ 走到这里 = 用户**没有**显式指定 ⇒ **默认不启用**(0.2.2 起)。
|
|
109
|
+
* 不再看 `proxyDetected`:有代理时命令层已经在第一跳直接走设备码了,
|
|
110
|
+
* 回调模式下的"等待超时"只该如实报错,不该偷偷改道。
|
|
109
111
|
*/
|
|
110
|
-
return
|
|
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
|
|
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 #
|
|
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 #
|
|
225
|
+
'wkgeo login --no-auto-device # 检测到系统代理也不自动改设备码(强制回调;CLI 会警告;端口真起不来也不兜底)\n' +
|
|
222
226
|
'wkgeo login --open-browser # 强制尝试打开系统浏览器(⚠️ 现在与默认行为等价,保留只为显式语义与兼容)\n' +
|
|
223
|
-
'wkgeo login --callback-first-wait 60 # 首轮等待 60
|
|
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: '是否自动打开浏览器(★
|
|
230
|
+
browser: { type: 'boolean', desc: '是否自动打开浏览器(★ 默认:人类终端打开;CI/管道/AI 子进程等非交互环境不打开;--no-browser 关闭)' },
|
|
227
231
|
/**
|
|
228
|
-
* ★
|
|
229
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* ★
|
|
256
|
+
* ★ 可选自愈(默认**关闭**,0.2.2 起)。
|
|
253
257
|
* 默认单位**秒**,与 `--timeout` 一致(人类写等待窗口都按秒想);
|
|
254
|
-
* `0` / `off` / `none` =
|
|
258
|
+
* `0` / `off` / `none` = 关闭(默认值就是关闭)。
|
|
255
259
|
* 环境变量 `WKONGAI_CALLBACK_FIRST_WAIT_MS` 是毫秒(与 `WKONGAI_LOGIN_TIMEOUT_MS` 同口径)。
|
|
256
260
|
*
|
|
257
|
-
* ⚠️ 2026-10-
|
|
258
|
-
*
|
|
261
|
+
* ⚠️ 2026-10-21(老板反馈):**默认不再自动改设备码** —— 不写它时无论等多久都只走回调,
|
|
262
|
+
* 超时只报错。只有显式写它,才会在首轮等待到点后并发启动设备码(显式优先)。
|
|
259
263
|
*/
|
|
260
264
|
'callback-first-wait': {
|
|
261
265
|
type: 'string',
|
|
262
|
-
desc: '
|
|
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 /
|
|
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
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
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-
|
|
386
|
-
* *
|
|
387
|
-
* *
|
|
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
|
|
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
|
-
*
|
|
406
|
-
*
|
|
415
|
+
* 老板原话:「默认的也不应该是设备授权,默认的就是那个传统的授权方式(浏览器打开授权页 +
|
|
416
|
+
* 本地 localhost 回调)。设备码是本地读不了/代理时才用的。」
|
|
417
|
+
* ⇒ 这里明说"本次就是回调、等多久都不会自动改设备码",同时列出**唯一会走设备码**的三种情况,
|
|
418
|
+
* 并给出"主动提前切换"的显式开关,免得用户以为 CLI 背着他改道。
|
|
419
|
+
* ⚠️ 只在"回调模式 + 没检测到代理 + 没显式开首轮等待"时打:其它情况一律不打(正常环境不要噪音)。
|
|
407
420
|
*/
|
|
408
|
-
if (!useDevice && !wantsContinue && !proxyDetected &&
|
|
421
|
+
if (!useDevice && !wantsContinue && !proxyDetected && callbackFirstWaitMs === 0) {
|
|
409
422
|
log(
|
|
410
|
-
`ℹ️
|
|
411
|
-
'不会因为"首轮等待"到点就自动改用设备码。',
|
|
423
|
+
`ℹ️ 未检测到系统代理:本次使用浏览器回调授权(默认),会一直等你完成授权(最多 ${Math.round(config.loginTimeoutMs / 1000)} 秒)。`,
|
|
412
424
|
);
|
|
413
|
-
log(
|
|
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
|
-
/** 允许"
|
|
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:
|
|
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
|
-
*
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
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
|
-
|
|
684
|
-
|
|
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,
|
|
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
|
-
|
|
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 === '
|
|
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
|
},
|
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
|
}
|