wkgeo-cli 0.2.0
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 +681 -0
- package/SKILL.md +598 -0
- package/lib/api.mjs +178 -0
- package/lib/args.mjs +196 -0
- package/lib/cli.mjs +134 -0
- package/lib/commands/apps.mjs +116 -0
- package/lib/commands/auth.mjs +974 -0
- package/lib/commands/billing.mjs +421 -0
- package/lib/commands/index.mjs +160 -0
- package/lib/commands/orders.mjs +444 -0
- package/lib/commands/sites.mjs +140 -0
- package/lib/commands/skill.mjs +119 -0
- package/lib/config.mjs +260 -0
- package/lib/credentials.mjs +160 -0
- package/lib/device-store.mjs +132 -0
- package/lib/device.mjs +427 -0
- package/lib/errors.mjs +268 -0
- package/lib/format.mjs +204 -0
- package/lib/http.mjs +71 -0
- package/lib/index.mjs +39 -0
- package/lib/login.mjs +1332 -0
- package/lib/page.mjs +396 -0
- package/lib/pkce.mjs +48 -0
- package/lib/proxy.mjs +454 -0
- package/lib/qr.mjs +31 -0
- package/lib/runtime.mjs +287 -0
- package/package.json +51 -0
- package/wkgeo.mjs +17 -0
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 账号类命令:`login` / `logout` / `whoami` / `config`
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ 非交互铁律:这些命令**任何情况下都不提问**(不用 AI 去"按 y")。
|
|
5
|
+
* 需要用户做的事(同意授权)表现为:打印网址 + 阻塞等待 + 超时给明确错误。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { runLogin, DEFAULT_CALLBACK_FIRST_WAIT_MS } from '../login.mjs';
|
|
9
|
+
import { requestDeviceCode, announceDeviceCode, pollDeviceToken, devicePasteText } from '../device.mjs';
|
|
10
|
+
import {
|
|
11
|
+
pendingDeviceFile,
|
|
12
|
+
savePendingDevice,
|
|
13
|
+
readPendingDevice,
|
|
14
|
+
deletePendingDevice,
|
|
15
|
+
isPendingExpired,
|
|
16
|
+
pendingExpiredError,
|
|
17
|
+
} from '../device-store.mjs';
|
|
18
|
+
import { proxyWarningPayload, proxyDeviceSwitchLines, proxyForcedCallbackLines } from '../proxy.mjs';
|
|
19
|
+
import { deleteCredentials, readCredentials, checkFileMode, isExpired } from '../credentials.mjs';
|
|
20
|
+
import { formatRemaining, maskToken, formatTime } from '../format.mjs';
|
|
21
|
+
import { CliError, EXIT, loginHint, UsageError } from '../errors.mjs';
|
|
22
|
+
import { describeConfigSources, parseDurationMs } from '../config.mjs';
|
|
23
|
+
import { GLOBAL_FLAGS } from '../args.mjs';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 「检测到系统代理时自动改用设备码」这个开关的解析(默认 **开**)。
|
|
27
|
+
*
|
|
28
|
+
* ## 为什么默认开,而不是 opt-in(老板 2026-10-02 明确要求)
|
|
29
|
+
* 老板原话:「如果能检测到有代理,可不可以**直接返回设备码**,然后用户把它输入到 AI 端的
|
|
30
|
+
* 对话框里……你那个让用户检查代理设置太不方便了,**很多用户也不懂**。」
|
|
31
|
+
* → 主路径必须是"用户什么都不用做就能成功"。三条"改代理设置"的提示保留为**补充说明**,
|
|
32
|
+
* 但绝不能是唯一出路(不懂代理的用户会直接卡死)。
|
|
33
|
+
*
|
|
34
|
+
* ## ⚠️ 2026-10-02 第二版加固:判定依据从 `risk` 改成 `detected`
|
|
35
|
+
* 第一版是"有代理 **且** 绕过列表没写回环(`risk`)才降级"。真机上彻底失效:
|
|
36
|
+
* 老板的机器绕过列表里**写着** `127.*;localhost`,于是判成"不风险"、走了回调,
|
|
37
|
+
* 结果他点了【同意授权】之后 CLI **一直拿不到令牌**
|
|
38
|
+
* (独立证据:`curl -x http://127.0.0.1:7890 http://127.0.0.1:1/x` 返回 502,
|
|
39
|
+
* 说明那台机器上的代理**会尝试接管回环请求**)。
|
|
40
|
+
* ⇒ 现在:**只要 `proxyInfo.detected === true` 就直接走设备码**,
|
|
41
|
+
* 宁可多一步(用户按一次短码),也绝不让用户卡在"点了同意却没反应"。
|
|
42
|
+
* `loopback_bypassed` / `risk` 只进诊断字段。
|
|
43
|
+
*
|
|
44
|
+
* ## 什么情况下关掉它
|
|
45
|
+
* `--no-auto-device` 或 `WKONGAI_AUTO_DEVICE=0`:
|
|
46
|
+
* * 你确认回调一定通(例如内网、代理已正确绕过回环),不想被自动改道;
|
|
47
|
+
* * 探测本身有误报(例如命中 PAC:绕过规则写在脚本里,我们无法零依赖求值)。
|
|
48
|
+
* ⚠️ 关掉时 CLI **必须**警告"代理可能吃掉回调"(见 proxyForcedCallbackLines)——
|
|
49
|
+
* 否则用户就只剩"点了同意干等到超时"这一种结局。
|
|
50
|
+
*
|
|
51
|
+
* ## 优先级(显式 > 环境变量 > 默认)
|
|
52
|
+
* `--auto-device` / `--no-auto-device`(命令行)> `WKONGAI_AUTO_DEVICE`(0/false/no/off = 关)> 默认开
|
|
53
|
+
*/
|
|
54
|
+
export function resolveAutoDevice(flags = {}, env = {}) {
|
|
55
|
+
if (flags.autoDevice === false) return false;
|
|
56
|
+
if (flags.autoDevice === true) return true;
|
|
57
|
+
const v = String((env && env.WKONGAI_AUTO_DEVICE) || '')
|
|
58
|
+
.trim()
|
|
59
|
+
.toLowerCase();
|
|
60
|
+
if (['0', 'false', 'no', 'off'].includes(v)) return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 「回调模式首轮等待」窗口(毫秒)的解析 —— ★ 第二条保险(见 login.mjs 的同名注释)。
|
|
66
|
+
*
|
|
67
|
+
* ## ⚠️⚠️ 2026-10-03 老板真机反馈后改了触发条件(这是本次修复的第一条)
|
|
68
|
+
* 旧逻辑:**无条件**返回默认 60 秒 ⇒ 用户机器上**没有任何代理/VPN**时也会自愈。
|
|
69
|
+
* 而"把授权页送到用户面前、他读完再点【同意】"经常超过 60 秒 ⇒ 明明不需要设备码也切了过去。
|
|
70
|
+
* 老板原话:「现在变成设备码了,我们因为没有开任何VPN,所以这个是不对的。」
|
|
71
|
+
* ⇒ 现在:**只有 `proxyDetected === true`(真检测到生效中的代理)才用默认值启用**;
|
|
72
|
+
* **没有代理时不启用**(返回 0,老老实实等满 `--timeout`)。
|
|
73
|
+
*
|
|
74
|
+
* ## 优先级(显式 > 自动判断)
|
|
75
|
+
* `--callback-first-wait <时长>`(默认单位**秒**;`0` / `off` / `none` = 关掉自愈)
|
|
76
|
+
* > `WKONGAI_CALLBACK_FIRST_WAIT_MS`(毫秒)
|
|
77
|
+
* > **有代理**时的 `DEFAULT_CALLBACK_FIRST_WAIT_MS`(180 秒)/ **无代理**时的 0
|
|
78
|
+
* ⇒ 用户**显式**指定时一律照旧尊重(哪怕没有代理)—— 显式指定优先于自动判断。
|
|
79
|
+
*
|
|
80
|
+
* ⚠️ `--no-auto-device`(autoDevice=false)时**强制返回 0**:用户已经明确要求"只走回调",
|
|
81
|
+
* 我们没有"改道"这个选项,就不能背着他偷偷改(那样比不给自愈更糟:他会以为
|
|
82
|
+
* CLI 没听他的)。此时整段 `--timeout` 依然是兜底。
|
|
83
|
+
*
|
|
84
|
+
* @param {object} flags 已解析的命令行标志(camelCase)
|
|
85
|
+
* @param {object} env 环境变量
|
|
86
|
+
* @param {{autoDevice?: boolean, proxyDetected?: boolean}} [opts]
|
|
87
|
+
* `proxyDetected` **必须**由调用方用 `proxyInfo.detected` 显式传入;
|
|
88
|
+
* 缺省按 `false`(= 没检测到代理)处理 —— 这是刻意的保守默认:
|
|
89
|
+
* "信息不足时宁可不自愈",也避免有人忘了传就又回到"无代理也切设备码"的老毛病。
|
|
90
|
+
* @returns {number} 毫秒;0 = 关闭自愈
|
|
91
|
+
*/
|
|
92
|
+
export function resolveCallbackFirstWaitMs(flags = {}, env = {}, { autoDevice = true, proxyDetected = false } = {}) {
|
|
93
|
+
if (!autoDevice) return 0;
|
|
94
|
+
const off = new Set(['0', 'off', 'none', 'false', 'no']);
|
|
95
|
+
if (flags.callbackFirstWait !== undefined && flags.callbackFirstWait !== '') {
|
|
96
|
+
const raw = String(flags.callbackFirstWait).trim().toLowerCase();
|
|
97
|
+
if (off.has(raw)) return 0;
|
|
98
|
+
return parseDurationMs(flags.callbackFirstWait, { defaultUnit: 's', flag: '--callback-first-wait' });
|
|
99
|
+
}
|
|
100
|
+
const envVal = env && env.WKONGAI_CALLBACK_FIRST_WAIT_MS;
|
|
101
|
+
if (envVal !== undefined && envVal !== '') {
|
|
102
|
+
if (off.has(String(envVal).trim().toLowerCase())) return 0;
|
|
103
|
+
return parseDurationMs(envVal, { defaultUnit: 'ms', flag: 'WKONGAI_CALLBACK_FIRST_WAIT_MS' });
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* ★ 走到这里 = 用户**没有**显式指定 ⇒ 只有"真检测到代理"才启用自愈。
|
|
107
|
+
* 没有代理时返回 0:`auto_device.applied=true, reason='callback_first_wait'`
|
|
108
|
+
* 因此**只可能**出现在有代理的机器上(老板真机那条反馈的口径)。
|
|
109
|
+
*/
|
|
110
|
+
return proxyDetected ? DEFAULT_CALLBACK_FIRST_WAIT_MS : 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* ★ 「回调 / 设备码两条路并存」时用的**可被立刻叫醒的 sleep**。
|
|
115
|
+
*
|
|
116
|
+
* 为什么需要它:`pollDeviceToken()` 每轮之间会 `await sleep(interval)`(默认 5 秒)。
|
|
117
|
+
* 回调那条路先拿到令牌时,设备码那条路必须**马上**停下来 —— 如果只是置一个"别再轮询"的标志,
|
|
118
|
+
* 那个**已经挂上**的 `setTimeout` 会把 Node 的事件循环继续吊住(用户已经看到"登录成功",
|
|
119
|
+
* 进程却迟迟不退出)。所以这里把 sleep 做成可唤醒的:谁赢了就 `wakeAll()`,
|
|
120
|
+
* 挂着的那一轮立刻醒来,醒来后由 `shouldStop()`(见 device.mjs)抛错退出。
|
|
121
|
+
*
|
|
122
|
+
* ⚠️ 测试注入的 `sleep`(例如 `async () => {}`)**照旧透传**:那种实现立刻返回,
|
|
123
|
+
* 不需要(也无法)被打断 —— 只有生产路径(真 `setTimeout`)才由这里持有并清理。
|
|
124
|
+
*/
|
|
125
|
+
export function createCancelGate(baseSleep) {
|
|
126
|
+
const waiters = new Set();
|
|
127
|
+
let cancelled = false;
|
|
128
|
+
const delegated = typeof baseSleep === 'function';
|
|
129
|
+
return {
|
|
130
|
+
sleep: (ms) => {
|
|
131
|
+
if (cancelled) return Promise.resolve();
|
|
132
|
+
if (delegated) return baseSleep(ms);
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
const done = () => {
|
|
135
|
+
waiters.delete(done);
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
resolve();
|
|
138
|
+
};
|
|
139
|
+
const timer = setTimeout(done, ms);
|
|
140
|
+
waiters.add(done);
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
wakeAll() {
|
|
144
|
+
cancelled = true;
|
|
145
|
+
for (const done of [...waiters]) done();
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 回调那条路先成功时用来**安静地**叫停设备码那条路(不是给用户看的错误,命令层会吞掉它) */
|
|
151
|
+
function deviceStoppedError() {
|
|
152
|
+
return new CliError('设备码轮询已停止(本地回调那条路已经拿到令牌)', {
|
|
153
|
+
kind: 'cancelled',
|
|
154
|
+
hint: '不需要做任何事:登录已经完成',
|
|
155
|
+
exitCode: EXIT.AUTH,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 把"设备码那条路"**等它退出**(失败/被叫停都不许把主流程带下水)。
|
|
161
|
+
*
|
|
162
|
+
* ⚠️ 这里刻意用 `Promise.race` 加一个 1 秒上限:`deviceSide.promise` 的结束速度取决于注入的
|
|
163
|
+
* `sleep` 能不能被打断 —— 生产路径由 `createCancelGate()` 自己持有定时器,`wakeAll()`
|
|
164
|
+
* 一叫就醒(毫秒级);而单测可能注入一个不可打断的假 sleep。
|
|
165
|
+
* 宁可让那次后台轮询在下一轮自己发现 `shouldStop()` 并退出,也不能把用户的登录卡在这里。
|
|
166
|
+
* ⚠️ `deviceSide.promise` 内部两个分支都已 catch(不会 reject),这里 await 它不会抛。
|
|
167
|
+
*/
|
|
168
|
+
async function 停掉设备码那条路(deviceSide, cancelGate) {
|
|
169
|
+
if (!deviceSide.promise) return;
|
|
170
|
+
deviceSide.cancelled = true;
|
|
171
|
+
cancelGate.wakeAll();
|
|
172
|
+
await Promise.race([
|
|
173
|
+
deviceSide.promise,
|
|
174
|
+
new Promise((resolve) => {
|
|
175
|
+
const t = setTimeout(resolve, 1000);
|
|
176
|
+
if (t && typeof t.unref === 'function') t.unref();
|
|
177
|
+
}),
|
|
178
|
+
]);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 两条路都没走通时的报错 —— 回调没等到 + 设备码又失败。
|
|
183
|
+
*
|
|
184
|
+
* ⚠️ 必须把**两件事一起说清楚**:只报设备码那条错,用户会以为"回调其实没问题",
|
|
185
|
+
* 于是又回去重跑 `login`,白等第二个 5 分钟(这是老板真机踩过的那类坑)。
|
|
186
|
+
* ⚠️ 文案里的 `本地回调没成功` / `回调侧的报错` / `设备码侧的报错` 是**契约字样**
|
|
187
|
+
* (`test/proxy-login.test.mjs`「两条路都不通」那条用例按它们断言),不要改写。
|
|
188
|
+
*/
|
|
189
|
+
function bothPathsFailedError(deviceError, reason, { loginTimeoutMs }) {
|
|
190
|
+
const callbackSide =
|
|
191
|
+
reason === 'callback_timeout'
|
|
192
|
+
? `等待授权超时(${loginTimeoutMs}ms,本机回调监听当时仍然开着)`
|
|
193
|
+
: '首轮等待没收到回调(本机回调监听当时仍然开着)';
|
|
194
|
+
return new CliError(`本地回调没成功,改用设备码也失败了:${deviceError.message}`, {
|
|
195
|
+
kind: deviceError.kind || 'device_authorization_failed',
|
|
196
|
+
code: deviceError.code === undefined ? null : deviceError.code,
|
|
197
|
+
hint: `回调侧的报错:${callbackSide};设备码侧的报错:${deviceError.message}。两条路都没走通时,先按提示修掉代理/防火墙,或让服务端确认设备码端点可用`,
|
|
198
|
+
exitCode: deviceError.exitCode === undefined ? EXIT.ERROR : deviceError.exitCode,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 这些错误意味着"这个设备码已经没救了"(过期/被拒/服务端没有这个能力),
|
|
204
|
+
* 此时磁盘上的待完成状态应该删掉 —— 否则用户下次 `--device-continue` 会拿着一个
|
|
205
|
+
* 注定失败的码去轮询,白等一轮。**网络类错误不在此列**(码还是好的,可以再试)。
|
|
206
|
+
*/
|
|
207
|
+
const TERMINAL_DEVICE_KINDS = new Set(['denied', 'expired', 'not_implemented', 'token_exchange']);
|
|
208
|
+
|
|
209
|
+
/* ------------------------------------------------------------------ */
|
|
210
|
+
/* login */
|
|
211
|
+
/* ------------------------------------------------------------------ */
|
|
212
|
+
|
|
213
|
+
export const loginCommand = {
|
|
214
|
+
name: 'login',
|
|
215
|
+
summary: '浏览器授权一次,把 30 天令牌存到本地(默认本地回调模式;--device 走设备码)',
|
|
216
|
+
usage:
|
|
217
|
+
'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' +
|
|
219
|
+
'wkgeo login --device --no-wait # ★ 只申请短码就退出(AI 端友好),稍后用下面这条领令牌\n' +
|
|
220
|
+
'wkgeo login --device-continue # 用上次保存的设备码换票、写完凭据就完成登录\n' +
|
|
221
|
+
'wkgeo login --no-auto-device # **检测到系统代理也不**自动改设备码(强制走回调;CLI 会警告)\n' +
|
|
222
|
+
'wkgeo login --open-browser # 强制尝试打开系统浏览器(⚠️ 现在与默认行为等价,保留只为显式语义与兼容)\n' +
|
|
223
|
+
'wkgeo login --callback-first-wait 60 # 首轮等待 60 秒(★ 只在检测到代理时才有默认值 180;0/off = 关掉这个自愈)\n' +
|
|
224
|
+
'wkgeo login --timeout 600 # 等待授权 600 秒(默认单位是秒,不是毫秒;也支持 600s / 500ms)',
|
|
225
|
+
flags: {
|
|
226
|
+
browser: { type: 'boolean', desc: '是否自动打开浏览器(★ 默认总是尝试打开:终端与 AI 场景都一样;--no-browser 关闭)' },
|
|
227
|
+
/**
|
|
228
|
+
* ★ 老板最终拍板走**传统路线**(弹系统浏览器):CLI **默认总是尝试打开**,
|
|
229
|
+
* 唯一的"不打开"开关是 `--no-browser`。
|
|
230
|
+
* 本开关现在**与默认行为等价**,保留只为两件事:
|
|
231
|
+
* 1. **显式语义**("我就是要弹,你别管");
|
|
232
|
+
* 2. **兼容**既有脚本(不许删)。
|
|
233
|
+
* ⚠️ 与 `--no-browser` 同时出现时 **--no-browser 优先**(关掉就是关掉,见 resolveBrowserIntent)。
|
|
234
|
+
*/
|
|
235
|
+
'open-browser': { type: 'boolean', desc: '强制尝试打开系统浏览器(⚠️ 现在与默认行为等价,保留只为显式语义与兼容)' },
|
|
236
|
+
device: { type: 'boolean', desc: '用设备码模式(RFC 8628)代替本地回调:不需要任何回调地址,对代理/防火墙免疫' },
|
|
237
|
+
/**
|
|
238
|
+
* ★ 老板 2026-10-02 要的"AI 端友好路径"。
|
|
239
|
+
* 为什么做成"申请完就退出"而不是"申请完继续挂着轮询"(后者靠 JSONL 事件流也能做):
|
|
240
|
+
* AI 端是**一轮一轮**工作的 —— 一次工具调用里挂着等 10 分钟会把用户晾住、也容易撞超时。
|
|
241
|
+
* 拆成"申请"和"领取"两条命令后,中间隔多久都行,且**不需要任何长驻进程**。
|
|
242
|
+
* 两种用法都保留:交互式终端里 `login --device` 一口气跑完更省事;
|
|
243
|
+
* AI/脚本里用 `--device --no-wait` + `--device-continue`(见 README)。
|
|
244
|
+
*/
|
|
245
|
+
'no-wait': { type: 'boolean', desc: '只申请设备码并打印(短码/网址),**立即退出不轮询**;稍后用 --device-continue 领取令牌' },
|
|
246
|
+
'device-continue': { type: 'boolean', desc: '继续上一次 --no-wait/被中断的设备码登录(读取本地保存的设备码并轮询到出结果)' },
|
|
247
|
+
'auto-device': {
|
|
248
|
+
type: 'boolean',
|
|
249
|
+
desc: '检测到系统代理时自动改用设备码(**默认开**;--no-auto-device 或 WKONGAI_AUTO_DEVICE=0 关闭,关闭时会警告"代理可能吃掉回调")',
|
|
250
|
+
},
|
|
251
|
+
/**
|
|
252
|
+
* ★ 第二条保险(见 login.mjs 的 DEFAULT_CALLBACK_FIRST_WAIT_MS)。
|
|
253
|
+
* 默认单位**秒**,与 `--timeout` 一致(人类写等待窗口都按秒想);
|
|
254
|
+
* `0` / `off` / `none` = 关掉自愈,回到"只等整段 --timeout"的旧行为。
|
|
255
|
+
* 环境变量 `WKONGAI_CALLBACK_FIRST_WAIT_MS` 是毫秒(与 `WKONGAI_LOGIN_TIMEOUT_MS` 同口径)。
|
|
256
|
+
*
|
|
257
|
+
* ⚠️ 2026-10-03 起:**不传它时默认值是"看有没有代理"** —— 有代理才是 180 秒,没有代理是 0
|
|
258
|
+
* (老板真机反馈:没有开任何 VPN 也被切成了设备码)。显式传值时照旧尊重用户。
|
|
259
|
+
*/
|
|
260
|
+
'callback-first-wait': {
|
|
261
|
+
type: 'string',
|
|
262
|
+
desc: '回调模式的**首轮等待**(默认单位秒;★ 只在检测到系统代理时默认 180 秒,无代理默认不启用):这段时间没等到回调就自动改用设备码继续(本地回调监听会同时保留);0/off 关闭该自愈',
|
|
263
|
+
},
|
|
264
|
+
/**
|
|
265
|
+
* 显式声明一次(`timeout` 本来就在 GLOBAL_FLAGS 里),只为让 `wkgeo login --help`
|
|
266
|
+
* 把**单位**打在用户眼前 —— 这条命令是最需要等超时的地方。
|
|
267
|
+
* 描述直接引用 `GLOBAL_FLAGS.timeout.desc`,**单一来源**,不会两处口径漂移。
|
|
268
|
+
*/
|
|
269
|
+
timeout: { type: 'string', desc: GLOBAL_FLAGS.timeout.desc },
|
|
270
|
+
},
|
|
271
|
+
/**
|
|
272
|
+
* JSON 模式下的输出形状与其它命令**不同**:它是 JSONL 事件流,因为这条命令会长时间阻塞。
|
|
273
|
+
* {"ok":true,"command":"login","stage":"awaiting_callback","authorization_url":"…"}
|
|
274
|
+
* {"ok":true,"command":"login","stage":"done","data":{…}}
|
|
275
|
+
* {"ok":false,"command":"login","stage":"failed","error":{…}}
|
|
276
|
+
* 设备码模式:
|
|
277
|
+
* {"ok":true,"command":"login","stage":"awaiting_user","user_code":"ABCD-1234","verification_uri":"…",
|
|
278
|
+
* "verification_uri_complete":"…","expires_in":600,"interval":5,"paste_text":"…"}
|
|
279
|
+
* ★ `--no-wait` 时**最后一行就是这个 `stage=awaiting_user`**(进程随即退出,不再有 done);
|
|
280
|
+
* 该行里带 `no_wait:true` / `resume_command` / `pending_file`,AI 端据此转达用户,
|
|
281
|
+
* 稍后再跑 `wkgeo login --device-continue`。
|
|
282
|
+
* 为什么不做成"一条最终 JSON":AI 端需要在**等待期间**就把网址/短码交给用户,
|
|
283
|
+
* 等 5 分钟拿到最终结果时用户早就走了。
|
|
284
|
+
*/
|
|
285
|
+
async run(ctx) {
|
|
286
|
+
const { out, config, flags } = ctx;
|
|
287
|
+
const jsonMode = out.json;
|
|
288
|
+
/** JSON 模式:人类可读文本一律走 stderr,stdout 只放机器可读事件 */
|
|
289
|
+
const log = jsonMode ? (m) => out.raw(m) : (m) => out.line(m);
|
|
290
|
+
const cmdName = config.cmdName || 'wkgeo';
|
|
291
|
+
|
|
292
|
+
/* ---- 0. 三种模式的互斥检查(用法错误 exit 2,脚本里好分辨) ---- */
|
|
293
|
+
const noWait = flags.noWait === true;
|
|
294
|
+
const wantsContinue = flags.deviceContinue === true;
|
|
295
|
+
if (noWait && wantsContinue) {
|
|
296
|
+
throw new UsageError('--no-wait 与 --device-continue 不能同时用:前者是"只申请短码就退出",后者是"去领取上次申请的短码"');
|
|
297
|
+
}
|
|
298
|
+
if (wantsContinue && flags.device === true) {
|
|
299
|
+
throw new UsageError('--device-continue 与 --device 不能同时用:--device-continue 会自动读回上次保存的设备码');
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* `--no-wait` **隐含**设备码模式:回调模式没有"先返回、稍后再领"这回事
|
|
303
|
+
* (授权码 5 分钟失效,而且必须由浏览器打回本地端口)。
|
|
304
|
+
* 把隐含关系写死在实现里,比要求用户同时记住两个开关更不容易用错。
|
|
305
|
+
*/
|
|
306
|
+
const wantsDevice = flags.device === true || noWait;
|
|
307
|
+
|
|
308
|
+
/** 回调模式的安全底线:只能监听回环(设备码模式不经过回调,跳过这条) */
|
|
309
|
+
if (!wantsDevice && !wantsContinue) {
|
|
310
|
+
if (config.callbackHost !== '127.0.0.1' && config.callbackHost !== 'localhost' && config.callbackHost !== '[::1]') {
|
|
311
|
+
throw new CliError(`回调地址必须监听回环(127.0.0.1 / localhost / [::1]),当前是 ${config.callbackHost}`, {
|
|
312
|
+
kind: 'usage',
|
|
313
|
+
hint: '不要用 --callback-host 绑 0.0.0.0:授权码会暴露给同网段所有人',
|
|
314
|
+
exitCode: EXIT.USAGE,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/* ---- 1. 代理检测(只有回调模式才关心:设备码不需要任何入站回调) ---- */
|
|
320
|
+
const proxyInfo = !wantsDevice && !wantsContinue ? ctx.proxyInfo() : null;
|
|
321
|
+
const proxyPayload = proxyInfo ? proxyWarningPayload(proxyInfo, { cmdName }) : null;
|
|
322
|
+
const autoDevice = resolveAutoDevice(flags, ctx.env);
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* ★★ 核心规则(2026-10-02 第二版加固):**`detected === true` 就直接走设备码**。
|
|
326
|
+
*
|
|
327
|
+
* ⚠️ 为什么不再看 `loopback_bypassed` / `risk`:真机上老板的绕过列表里**写着**
|
|
328
|
+
* `localhost;127.*`,第一版据此判"没风险"→ 走回调 → 他点了【同意授权】之后
|
|
329
|
+
* 回调请求从没到过本机,CLI 一直拿不到令牌
|
|
330
|
+
* (独立证据:`curl -x http://127.0.0.1:7890 http://127.0.0.1:1/x` → 502,
|
|
331
|
+
* 证明那台机器上的代理**会尝试接管回环请求**,绕过列表并没有真正生效)。
|
|
332
|
+
* ⇒ 结论:**"绕过列表里有 127.*"不等于绕过生效**,不同代理软件/浏览器对 bypass
|
|
333
|
+
* 的解释并不一致。宁可让用户多走一步设备码,也不能让他卡在"点了同意没反应"。
|
|
334
|
+
* 这两个字段仍然照样上报(`proxy_warning`),只是**不再作为放行回调的依据**。
|
|
335
|
+
*/
|
|
336
|
+
const proxyDetected = !!(proxyInfo && proxyInfo.detected);
|
|
337
|
+
let useDevice = wantsDevice;
|
|
338
|
+
/** 为什么会走设备码:proxy_detected / callback_first_wait / callback_timeout / callback_listen_failed */
|
|
339
|
+
let downgradeReason = null;
|
|
340
|
+
if (!useDevice && autoDevice && proxyDetected) {
|
|
341
|
+
useDevice = true;
|
|
342
|
+
downgradeReason = 'proxy_detected';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (downgradeReason === 'proxy_detected') {
|
|
346
|
+
// 人类模式:说清楚"为什么改了、怎么改回去"(AI 端看 proxy_warning 结构化字段)
|
|
347
|
+
for (const line of proxyDeviceSwitchLines(proxyInfo, { cmdName })) log(line);
|
|
348
|
+
} else if (!wantsDevice && !wantsContinue && !useDevice && proxyDetected) {
|
|
349
|
+
/**
|
|
350
|
+
* 走到这里只有一种情况:用户**显式**要求坚持回调模式(--no-auto-device / WKONGAI_AUTO_DEVICE=0)。
|
|
351
|
+
* ⚠️ 必须明确警告:否则他唯一的下场就是"点了同意,然后干等到超时",
|
|
352
|
+
* 而他会以为是 CLI 坏了(这正是老板真机上遇到的那个现象)。
|
|
353
|
+
*/
|
|
354
|
+
for (const line of proxyForcedCallbackLines(proxyInfo, { cmdName })) log(line);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** 把运行期注入的依赖透传给两条登录流程(测试要能离线跑,见 runtime.mjs 的说明) */
|
|
358
|
+
const loginDeps = {
|
|
359
|
+
fetchImpl: ctx.fetchImpl,
|
|
360
|
+
now: ctx.now,
|
|
361
|
+
sleep: ctx.sleep,
|
|
362
|
+
// `--no-browser` → false;否则用注入的(测试)或默认的真·打开浏览器
|
|
363
|
+
openBrowser: flags.browser === false ? false : ctx.openBrowser,
|
|
364
|
+
/** 真 spawn 的注入点(单测验证"AI 场景一次都不 spawn 浏览器";生产不传 = 真 spawn) */
|
|
365
|
+
spawnImpl: ctx.spawnImpl,
|
|
366
|
+
/**
|
|
367
|
+
* ★★ 「要不要替用户弹**系统**浏览器」的四个事实(老板最终拍板走**传统路线**):
|
|
368
|
+
* * `isTTY` —— stdout 是不是终端(由 runtime 从 stdout.isTTY 读出,命令层原样透传);
|
|
369
|
+
* * `json` —— 是不是 JSON 输出(`--json` 或非 TTY 自动 JSON);
|
|
370
|
+
* * `forceBrowser` —— 用户写了 `--open-browser`(现在与默认行为等价,保留显式语义);
|
|
371
|
+
* * `noBrowser` —— 用户写了 `--no-browser`(**唯一**的"不打开"开关,优先级最高)。
|
|
372
|
+
* 四个一起进 `resolveBrowserIntent()`(login.mjs 里的**唯一裁决点**):
|
|
373
|
+
* **默认总是尝试打开系统浏览器**(人在终端 ✔ + AI 场景 ✔);
|
|
374
|
+
* `isTTY` / `json` 现在只影响 `mode` 这个诊断字段,**不再**决定"要不要弹"。
|
|
375
|
+
* ⚠️ 回调模式与设备码模式都吃这一份 deps —— 所以两条授权路径的行为天然一致。
|
|
376
|
+
*/
|
|
377
|
+
isTTY: ctx.isTTY === true,
|
|
378
|
+
json: ctx.out.json === true,
|
|
379
|
+
forceBrowser: flags.openBrowser === true,
|
|
380
|
+
noBrowser: flags.browser === false,
|
|
381
|
+
platform: ctx.platform,
|
|
382
|
+
/**
|
|
383
|
+
* ★ 第二条保险:回调模式的**首轮等待**。
|
|
384
|
+
*
|
|
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` → 照旧尊重;
|
|
389
|
+
* * `--no-auto-device` → 强制 0(用户明确要求只走回调,就不能偷偷改道)。
|
|
390
|
+
*/
|
|
391
|
+
callbackFirstWaitMs: resolveCallbackFirstWaitMs(flags, ctx.env, { autoDevice, proxyDetected }),
|
|
392
|
+
/**
|
|
393
|
+
* ★★ 设备码先成功后,本地回调监听再多留多久(毫秒;0 = 立刻收尾)。
|
|
394
|
+
* 生产不传 = `login.mjs` 的 `DEFAULT_POST_DEVICE_GRACE_MS`;
|
|
395
|
+
* 单测可注入一个小值,确定性地验证"迟到的回调拿到已完成页"。
|
|
396
|
+
*/
|
|
397
|
+
postDeviceGraceMs: ctx.postDeviceGraceMs,
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* ★ 无代理时的一句说明(老板真机反馈的直接回应)。
|
|
402
|
+
*
|
|
403
|
+
* 为什么值得打这一行:他这次的困惑正是"**我没有开代理,为什么变成设备码了**"。
|
|
404
|
+
* 明说"这次不会因为首轮等待就自动改设备码"能直接消掉这个困惑;
|
|
405
|
+
* 同时也要说清"整段 --timeout 用完仍可能改"(那是既有的兜底语义,本次没有改动)。
|
|
406
|
+
* ⚠️ 只在"回调模式 + 没检测到代理 + 没开自愈"时打:其它情况一律不打(正常环境不要噪音)。
|
|
407
|
+
*/
|
|
408
|
+
if (!useDevice && !wantsContinue && !proxyDetected && loginDeps.callbackFirstWaitMs === 0) {
|
|
409
|
+
log(
|
|
410
|
+
`ℹ️ 未检测到系统代理:本次会一直等你完成授权(最多 ${Math.round(config.loginTimeoutMs / 1000)} 秒),` +
|
|
411
|
+
'不会因为"首轮等待"到点就自动改用设备码。',
|
|
412
|
+
);
|
|
413
|
+
log(' (想主动限制首轮等待:`--callback-first-wait <秒>`;想强制设备码:`--device`。)');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 设备码事件的统一构造(人类提示在 announceDeviceCode 里打;这里只负责机器可读那一份)。
|
|
418
|
+
* ⚠️ `user_action_required` 是**给 AI 端直接用的话术**:AI 不该自己组织语言,
|
|
419
|
+
* 照着念就不会漏掉网址或短码。
|
|
420
|
+
*/
|
|
421
|
+
const deviceEvent = (info, extra = {}) => {
|
|
422
|
+
if (!jsonMode) return;
|
|
423
|
+
out.event({
|
|
424
|
+
ok: true,
|
|
425
|
+
command: 'login',
|
|
426
|
+
mode: 'device',
|
|
427
|
+
stage: 'awaiting_user',
|
|
428
|
+
...info,
|
|
429
|
+
...extra,
|
|
430
|
+
user_action_required: `请让用户访问 ${info.open_url}${info.verification_uri_complete ? '' : ` 并输入短码 ${info.user_code}`}`,
|
|
431
|
+
});
|
|
432
|
+
};
|
|
433
|
+
const deviceDeps = (extraEvent = {}) => ({ ...loginDeps, onDeviceCode: (info) => deviceEvent(info, extraEvent) });
|
|
434
|
+
|
|
435
|
+
const pendingFile = pendingDeviceFile(config.credentialsFile);
|
|
436
|
+
/** 申请到码之后立刻落盘:进程被 Ctrl+C / 终端被关掉,用户还能用 --device-continue 续上 */
|
|
437
|
+
const 保存待完成 = (device) => {
|
|
438
|
+
try {
|
|
439
|
+
savePendingDevice(pendingFile, { ...device, base: config.base, client_id: config.clientId });
|
|
440
|
+
return true;
|
|
441
|
+
} catch (e) {
|
|
442
|
+
// 落盘失败**不影响本次授权**(只是失去"稍后续跑"的能力),所以只提示不抛
|
|
443
|
+
log(`⚠️ 无法保存待完成的设备码状态(不影响本次授权,只是不能稍后用 --device-continue 续跑):${e.message}`);
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
let result = null;
|
|
449
|
+
|
|
450
|
+
/* ---- 2a. `--device-continue`:续跑上次没等完的设备码 ---- */
|
|
451
|
+
if (wantsContinue) {
|
|
452
|
+
const pending = readPendingDevice(pendingFile);
|
|
453
|
+
if (!pending) {
|
|
454
|
+
throw new CliError('本地没有待完成的设备码登录', {
|
|
455
|
+
kind: 'device_state',
|
|
456
|
+
hint: `先运行 \`${cmdName} login --device --no-wait\`(或直接 \`${cmdName} login --device\`)申请一个短码`,
|
|
457
|
+
exitCode: EXIT.USAGE,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* ⚠️ 设备码只对**当初申请它的那个服务端**有效。用户中途换了 `--base`(换了环境)时,
|
|
462
|
+
* 拿旧码去新环境轮询必然失败,不如在这里把话说明白。
|
|
463
|
+
*/
|
|
464
|
+
if (pending.base && pending.base !== config.base) {
|
|
465
|
+
throw new CliError(`这份设备码是为 \`${pending.base}\` 申请的,与当前的 \`${config.base}\` 不一致`, {
|
|
466
|
+
kind: 'device_state',
|
|
467
|
+
hint: `用同一个 --base 重试,或重新运行 \`${cmdName} login --device\``,
|
|
468
|
+
exitCode: EXIT.USAGE,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
if (isPendingExpired(pending, { now: ctx.now() })) {
|
|
472
|
+
deletePendingDevice(pendingFile);
|
|
473
|
+
throw pendingExpiredError(pendingFile);
|
|
474
|
+
}
|
|
475
|
+
log(`继续上一次的设备码登录:短码 ${pending.user_code}`);
|
|
476
|
+
log(` (如果浏览器已经关掉了:${pending.open_url})`);
|
|
477
|
+
try {
|
|
478
|
+
result = await pollDeviceToken({ config, log, deps: loginDeps, device: pending });
|
|
479
|
+
deletePendingDevice(pendingFile);
|
|
480
|
+
useDevice = true;
|
|
481
|
+
} catch (e) {
|
|
482
|
+
if (TERMINAL_DEVICE_KINDS.has(e.kind)) {
|
|
483
|
+
try {
|
|
484
|
+
deletePendingDevice(pendingFile);
|
|
485
|
+
} catch {
|
|
486
|
+
/* 删不掉不影响主流程:报错本身才是用户要看的 */
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
throw e;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/* ---- 2b. 设备码模式(等待式,或 --no-wait 只申请) ---- */
|
|
494
|
+
if (!result && useDevice) {
|
|
495
|
+
try {
|
|
496
|
+
const device = await requestDeviceCode({ config, deps: loginDeps });
|
|
497
|
+
保存待完成(device);
|
|
498
|
+
if (noWait) {
|
|
499
|
+
/**
|
|
500
|
+
* ★ AI 端友好路径:打印完就退出(不轮询)。
|
|
501
|
+
* `extra` 同时进"机器可读事件"和"人类可读提示",两处口径同源。
|
|
502
|
+
*/
|
|
503
|
+
const extra = {
|
|
504
|
+
no_wait: true,
|
|
505
|
+
pending_file: pendingFile,
|
|
506
|
+
resume_command: `${cmdName} login --device-continue`,
|
|
507
|
+
expires_at: new Date(device.expires_at).toISOString(),
|
|
508
|
+
proxy_warning: proxyPayload,
|
|
509
|
+
logged_in: false,
|
|
510
|
+
next_step:
|
|
511
|
+
'请让用户在**自己的浏览器**里打开上面的网址并点「同意」(AI 端无法代点:device_confirm 需要用户自己的登录会话);' +
|
|
512
|
+
`然后运行 \`${cmdName} login --device-continue\` 领取令牌`,
|
|
513
|
+
};
|
|
514
|
+
announceDeviceCode({ config, log, deps: deviceDeps(extra), device });
|
|
515
|
+
if (!jsonMode) {
|
|
516
|
+
out.line('');
|
|
517
|
+
out.info(`⏸ 已申请短码并退出(--no-wait)。用户点完「同意」后,运行 \`${cmdName} login --device-continue\` 领取令牌。`);
|
|
518
|
+
out.info(` 短码有效期约 ${Math.round(device.expires_in / 60)} 分钟;过期就重新运行 \`${cmdName} login --device --no-wait\`。`);
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
mode: 'device',
|
|
522
|
+
stage: 'awaiting_user',
|
|
523
|
+
...extra,
|
|
524
|
+
user_code: device.user_code,
|
|
525
|
+
verification_uri: device.verification_uri,
|
|
526
|
+
verification_uri_complete: device.verification_uri_complete,
|
|
527
|
+
open_url: device.open_url,
|
|
528
|
+
paste_text: devicePasteText(device),
|
|
529
|
+
expires_in: device.expires_in,
|
|
530
|
+
interval: device.interval,
|
|
531
|
+
credentials_file: config.credentialsFile,
|
|
532
|
+
client_id: config.clientId,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
announceDeviceCode({ config, log, deps: deviceDeps({ no_wait: false, proxy_warning: proxyPayload }), device });
|
|
536
|
+
try {
|
|
537
|
+
result = await pollDeviceToken({ config, log, deps: loginDeps, device });
|
|
538
|
+
deletePendingDevice(pendingFile);
|
|
539
|
+
} catch (e) {
|
|
540
|
+
if (TERMINAL_DEVICE_KINDS.has(e.kind)) {
|
|
541
|
+
try {
|
|
542
|
+
deletePendingDevice(pendingFile);
|
|
543
|
+
} catch {
|
|
544
|
+
/* 同上:删不掉不影响主流程 */
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
throw e;
|
|
548
|
+
}
|
|
549
|
+
} catch (e) {
|
|
550
|
+
/**
|
|
551
|
+
* 自动降级时设备码端点却不可用(服务端缺口/形状不对)→ **老老实实回落回调模式**,
|
|
552
|
+
* 而不是把用户堵死。只有"我们自己主动改道"(autoReason 存在)且用户没显式要设备码时才回落;
|
|
553
|
+
* 显式 `--device` 的失败必须原样报出来(用户要的就是设备码,别替他做别的决定)。
|
|
554
|
+
*/
|
|
555
|
+
const canFallback =
|
|
556
|
+
downgradeReason === 'proxy_detected' && !wantsDevice && (e.kind === 'not_implemented' || e.kind === 'device_authorization_failed');
|
|
557
|
+
if (!canFallback) throw e;
|
|
558
|
+
log(`⚠️ 设备码模式当前不可用(${e.message}),回落到本地回调模式继续…`);
|
|
559
|
+
useDevice = false;
|
|
560
|
+
downgradeReason = null;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/* ---- 2c. 本地回调模式(含"回调没等到 → 并发改设备码",★ 两条路并存) ---- */
|
|
565
|
+
if (!result && !useDevice) {
|
|
566
|
+
/**
|
|
567
|
+
* ★★ 2026-10-03(老板真机反馈 ⚠️):「改用设备码」**不再关掉本地回调监听**。
|
|
568
|
+
*
|
|
569
|
+
* ## 旧行为(真机证据:ERR_CONNECTION_REFUSED)
|
|
570
|
+
* 首轮等待到点 → `runLogin` 抛错,并在自己的 `finally` 里**关掉本地 server** → 命令层再走设备码。
|
|
571
|
+
* 于是用户**之后**才点【同意授权】时,浏览器跳到
|
|
572
|
+
* `http://127.0.0.1:<port>/callback?code=…` → **ERR_CONNECTION_REFUSED**
|
|
573
|
+
* (老板真机截图见过)→ 用户以为授权失败,再来一遍(而其实他马上就会登录成功)。
|
|
574
|
+
*
|
|
575
|
+
* ## 新行为:两条路**并存**,哪个先拿到令牌就用哪个(先到先得)
|
|
576
|
+
* * 回调那条路:本地 server 一直开着(`runLogin` 收到 `handoff` 时到点**不结束等待**);
|
|
577
|
+
* * 设备码那条路:由下面的 `handoff.onSwitch` 启动,轮询到出结果为止。
|
|
578
|
+
* * 回调先成功 → 把设备码那条路**优雅停掉**(唤醒它挂着的 sleep + 删掉待完成状态文件);
|
|
579
|
+
* * 设备码先成功 → `finishWithDevice()` 让回调那条路收尾(监听再多留几秒,
|
|
580
|
+
* 迟到的回调会拿到"本次登录已经完成"的页面,见 login.mjs / page.mjs)。
|
|
581
|
+
*/
|
|
582
|
+
/** 允许"自动改道"吗:用户没写 --no-auto-device,也不是显式设备码模式 */
|
|
583
|
+
const mayAutoSwitch = autoDevice && !wantsDevice && !wantsContinue;
|
|
584
|
+
/** 设备码那条路的可取消状态(回调先赢时要把它收拾干净) */
|
|
585
|
+
const deviceSide = { reason: null, cancelled: false, promise: null, result: null, error: null };
|
|
586
|
+
/** ★ 可被立刻叫醒的 sleep:回调先赢时,不能让设备码那个 5 秒定时器把进程吊住 */
|
|
587
|
+
const cancelGate = createCancelGate(loginDeps.sleep);
|
|
588
|
+
/** 交接面:两边各填一半(`onSwitch` 由这里填,另两个由 runLogin 填;见 login.mjs 的 deps 说明) */
|
|
589
|
+
const handoff = { onSwitch: null, finishWithDevice: null, abort: null };
|
|
590
|
+
|
|
591
|
+
const 跑设备码那条路 = async () => {
|
|
592
|
+
const device = await requestDeviceCode({ config, deps: loginDeps });
|
|
593
|
+
// 回调那条路已经赢了 → 一个用户可见的动作都不要再做(不打印短码、不弹浏览器、不落盘)
|
|
594
|
+
if (deviceSide.cancelled) throw deviceStoppedError();
|
|
595
|
+
保存待完成(device);
|
|
596
|
+
if (deviceSide.cancelled) throw deviceStoppedError();
|
|
597
|
+
announceDeviceCode({ config, log, deps: deviceDeps({ no_wait: false, proxy_warning: proxyPayload }), device });
|
|
598
|
+
return pollDeviceToken({
|
|
599
|
+
config,
|
|
600
|
+
log,
|
|
601
|
+
deps: {
|
|
602
|
+
...loginDeps,
|
|
603
|
+
sleep: cancelGate.sleep,
|
|
604
|
+
/** device.mjs 支持它:另一条路赢了就立刻停止轮询(不写凭据、不报错) */
|
|
605
|
+
shouldStop: () => deviceSide.cancelled,
|
|
606
|
+
},
|
|
607
|
+
device,
|
|
608
|
+
});
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
if (mayAutoSwitch) {
|
|
612
|
+
handoff.onSwitch = (reason) => {
|
|
613
|
+
deviceSide.reason = reason;
|
|
614
|
+
/**
|
|
615
|
+
* ⚠️ 这里**不 await**、也**不抛错**:交接只需把设备码那条路点起来。
|
|
616
|
+
* 成功 → `finishWithDevice` 让回调流程带着这份结果收尾(监听留 grace);
|
|
617
|
+
* 失败 → `abort` 让回调流程用"两条路都不通"的错误结束(用户要看的是真原因)。
|
|
618
|
+
*/
|
|
619
|
+
deviceSide.promise = 跑设备码那条路().then(
|
|
620
|
+
(res) => {
|
|
621
|
+
deviceSide.result = res;
|
|
622
|
+
if (typeof handoff.finishWithDevice === 'function') handoff.finishWithDevice(res);
|
|
623
|
+
},
|
|
624
|
+
(e2) => {
|
|
625
|
+
deviceSide.error = e2;
|
|
626
|
+
/**
|
|
627
|
+
* ★ 两条路都没成:把**两边的报错**合起来抛(只报设备码那条错,用户会以为
|
|
628
|
+
* "回调其实没问题"、又回去白等一轮 —— 见 bothPathsFailedError 的注释)。
|
|
629
|
+
* ⚠️ 同时存一份到 `deviceSide.combinedError`:万一回调那条路**抢先**抛了自己的错
|
|
630
|
+
* (例如用户在这一刻点了「拒绝」),下面 catch 里也要优先把"两条路都不通"报出来。
|
|
631
|
+
*/
|
|
632
|
+
deviceSide.combinedError = bothPathsFailedError(e2, reason, { loginTimeoutMs: config.loginTimeoutMs });
|
|
633
|
+
if (typeof handoff.abort === 'function') handoff.abort(deviceSide.combinedError);
|
|
634
|
+
},
|
|
635
|
+
);
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
result = await runLogin({
|
|
641
|
+
config,
|
|
642
|
+
log,
|
|
643
|
+
deps: {
|
|
644
|
+
...loginDeps,
|
|
645
|
+
/** 超时时要把"代理可能是元凶"写进错误 hint(见 login.mjs 的 timeout 分支) */
|
|
646
|
+
proxyRisk: proxyInfo,
|
|
647
|
+
/** ★★ 两条路并存的交接面(未启用自动改道时 `onSwitch` 为 null ⇒ runLogin 走旧契约) */
|
|
648
|
+
handoff,
|
|
649
|
+
onAuthorizationUrl: (url, extra) => {
|
|
650
|
+
if (jsonMode) {
|
|
651
|
+
out.event({
|
|
652
|
+
ok: true,
|
|
653
|
+
command: 'login',
|
|
654
|
+
mode: 'callback',
|
|
655
|
+
stage: 'awaiting_callback',
|
|
656
|
+
authorization_url: url,
|
|
657
|
+
user_action_required: '请让用户在浏览器中打开上面的 authorization_url,登录并点「同意」',
|
|
658
|
+
proxy_warning: proxyPayload,
|
|
659
|
+
...extra,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
});
|
|
665
|
+
} catch (e) {
|
|
666
|
+
/** ★ 无论怎么失败,都先把设备码那条路收拾干净(不能留一个还在轮询的后台任务) */
|
|
667
|
+
await 停掉设备码那条路(deviceSide, cancelGate);
|
|
668
|
+
/** 设备码那条路也失败了 ⇒ 优先报"两条路都不通"(哪怕回调那边先抛了自己的错) */
|
|
669
|
+
if (deviceSide.combinedError) throw deviceSide.combinedError;
|
|
670
|
+
/**
|
|
671
|
+
* ★ 「回调失败也自动降级」(老板 2026-10-02 要求"不要直接失败退出")。
|
|
672
|
+
* 走得到这里的是**没有启用交接**(`--no-auto-device`)或"连端口都起不来"的两种情况:
|
|
673
|
+
* * `listen` —— 连回环端口都起不来(被安全软件/防火墙拦):
|
|
674
|
+
* 此时**根本没有监听可保留**,只能老老实实改走设备码;
|
|
675
|
+
* * `callback_first_wait` / `timeout` —— 只可能出现在没传 `handoff.onSwitch` 时(旧契约兜底)。
|
|
676
|
+
* 其它错误(换票失败、被拒绝、state 不匹配…)**不降级**:那些是"授权已经走到下一步了",
|
|
677
|
+
* 再来一遍设备码只会让用户做重复劳动(甚至以为被耍了)—— 这条语义**本次没有改动**。
|
|
678
|
+
*/
|
|
679
|
+
const canSwitch =
|
|
680
|
+
autoDevice && !wantsDevice && (e.kind === 'callback_first_wait' || e.kind === 'timeout' || e.kind === 'listen');
|
|
681
|
+
if (!canSwitch) throw e;
|
|
682
|
+
log('');
|
|
683
|
+
const 原因 =
|
|
684
|
+
e.kind === 'timeout' ? '等待超时' : e.kind === 'listen' ? '本地端口监听失败' : '首轮等待没收到回调';
|
|
685
|
+
log(`⚠️ 本地回调没有等到结果(${原因}):${e.message}`);
|
|
686
|
+
if (e.kind === 'callback_first_wait') {
|
|
687
|
+
log('ℹ️ 等了这一小段时间没收到回调,已自动改用**设备码模式**继续(不需要本地回调)。');
|
|
688
|
+
} else {
|
|
689
|
+
log('ℹ️ 已自动改用**设备码模式**继续授权(不需要本地回调)—— 常见原因是代理/防火墙挡了 127.0.0.1。');
|
|
690
|
+
}
|
|
691
|
+
let device;
|
|
692
|
+
try {
|
|
693
|
+
device = await requestDeviceCode({ config, deps: loginDeps });
|
|
694
|
+
} catch (e2) {
|
|
695
|
+
throw bothPathsFailedError(e2, e.kind === 'timeout' ? 'callback_timeout' : 'callback_first_wait', {
|
|
696
|
+
loginTimeoutMs: config.loginTimeoutMs,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
保存待完成(device);
|
|
700
|
+
announceDeviceCode({ config, log, deps: deviceDeps({ no_wait: false, proxy_warning: proxyPayload }), device });
|
|
701
|
+
// 这一次再失败就如实抛出(不无限重试:两条路都失败,用户该看到真原因了)
|
|
702
|
+
result = await pollDeviceToken({ config, log, deps: loginDeps, device });
|
|
703
|
+
deletePendingDevice(pendingFile);
|
|
704
|
+
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';
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (deviceSide.reason) {
|
|
714
|
+
/**
|
|
715
|
+
* ⚠️ 判定"谁赢了"用**凭据对象的同一性**:`runLogin` 返回时会 `{...result}` 复制一层外壳
|
|
716
|
+
* (见 login.mjs 的返回值),所以比较外层对象会永远不相等;
|
|
717
|
+
* 而 `credentials` 是那一层里原样带出来的同一个对象,用它判定既准确又同步。
|
|
718
|
+
*/
|
|
719
|
+
if (deviceSide.result && result.credentials === deviceSide.result.credentials) {
|
|
720
|
+
/**
|
|
721
|
+
* ★ 设备码那条路**先**成功:`runLogin` 已经把回调那条路收尾(监听再留一小会儿给迟到的回调),
|
|
722
|
+
* 这里只要把"最终模式/原因"如实记下来。
|
|
723
|
+
*/
|
|
724
|
+
useDevice = true;
|
|
725
|
+
downgradeReason = deviceSide.reason === 'callback_timeout' ? 'callback_timeout' : 'callback_first_wait';
|
|
726
|
+
} else {
|
|
727
|
+
/**
|
|
728
|
+
* ★ 回调那条路**先**成功(本次修复的核心价值:用户后来点的那次【同意】真的换到了令牌)
|
|
729
|
+
* ⇒ 把设备码那条路立刻停掉,并给用户一句说明(免得他再去浏览器里确认一个作废的短码)。
|
|
730
|
+
*/
|
|
731
|
+
deviceSide.cancelled = true;
|
|
732
|
+
cancelGate.wakeAll();
|
|
733
|
+
await 停掉设备码那条路(deviceSide, cancelGate);
|
|
734
|
+
deletePendingDevice(pendingFile);
|
|
735
|
+
log('ℹ️ 本地回调已经拿到令牌:设备码那条路已停止(刚才那个短码作废,不用再去浏览器里确认)。');
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const account = result.account || null;
|
|
741
|
+
const creds = result.credentials;
|
|
742
|
+
const data = {
|
|
743
|
+
account,
|
|
744
|
+
mode: useDevice ? 'device' : 'callback',
|
|
745
|
+
client_id: creds.client_id,
|
|
746
|
+
scope: creds.scope,
|
|
747
|
+
expires_at: creds.expires_at,
|
|
748
|
+
token_ttl_seconds: result.tokenTtlSeconds || creds.expires_in || result.metadata?.access_token_ttl_seconds || null,
|
|
749
|
+
on_token_expiry: result.onTokenExpiry || result.metadata?.on_token_expiry || null,
|
|
750
|
+
credentials_file: config.credentialsFile,
|
|
751
|
+
file_mode: result.mode && result.mode.supported ? result.mode.modeText : '无法判断(Windows)',
|
|
752
|
+
redirect_uri: result.redirectUri || null,
|
|
753
|
+
user_code: result.user_code || null,
|
|
754
|
+
verification_uri: result.verification_uri || null,
|
|
755
|
+
paste_text: result.paste_text || null,
|
|
756
|
+
poll_attempts: result.poll_attempts ?? null,
|
|
757
|
+
logged_in: true,
|
|
758
|
+
/** ★ 结构化告知"本机代理情况"与"是否因此改道",AI 端不用去猜 */
|
|
759
|
+
proxy_warning: proxyPayload,
|
|
760
|
+
auto_device: downgradeReason ? { applied: true, reason: downgradeReason } : { applied: false, reason: null },
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
if (jsonMode) {
|
|
764
|
+
// 保持 JSONL:stage=done 是最后一行
|
|
765
|
+
out.event({ ok: true, command: 'login', stage: 'done', data });
|
|
766
|
+
} else {
|
|
767
|
+
out.line('');
|
|
768
|
+
out.ok(`✅ 授权成功,已登录:${account && (account.email || account.name) ? account.email || account.name : '(账号信息未取到)'}`);
|
|
769
|
+
if (account && account.sub) out.line(` 用户 ID:${account.sub}`);
|
|
770
|
+
out.line(` 能力范围:${creds.scope}`);
|
|
771
|
+
out.line(` 令牌有效期:${creds.expires_in ? `${Math.round(creds.expires_in / 86400)} 天` : '未知'}(到期时间 ${formatTime(creds.expires_at)})`);
|
|
772
|
+
out.line(` 凭据文件:${config.credentialsFile}(权限 ${data.file_mode})`);
|
|
773
|
+
out.info(' 令牌本身不会显示在任何输出里(安全约定)。');
|
|
774
|
+
if (useDevice && data.user_code) out.info(` 设备码模式:本次短码 ${data.user_code} 已作废,不能再用于下一次授权。`);
|
|
775
|
+
if (downgradeReason === 'proxy_detected')
|
|
776
|
+
out.info(' (本次因"检测到系统代理"自动改用了设备码模式:不再依赖 127.0.0.1 回调,绕过列表写了什么也不作依据。)');
|
|
777
|
+
if (downgradeReason === 'callback_first_wait')
|
|
778
|
+
out.info(' (回调模式首轮等待没收到回调,已自动改用设备码模式完成登录。)');
|
|
779
|
+
if (downgradeReason === 'callback_timeout' || downgradeReason === 'callback_listen_failed')
|
|
780
|
+
out.info(' (本地回调没成功,已自动改用设备码模式完成登录。)');
|
|
781
|
+
}
|
|
782
|
+
return data;
|
|
783
|
+
},
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
/* ------------------------------------------------------------------ */
|
|
787
|
+
/* logout */
|
|
788
|
+
/* ------------------------------------------------------------------ */
|
|
789
|
+
|
|
790
|
+
export const logoutCommand = {
|
|
791
|
+
name: 'logout',
|
|
792
|
+
summary: '删除本地凭据(默认还会调用服务端吊销接口作废该令牌)',
|
|
793
|
+
usage: 'wkgeo logout [--local-only]',
|
|
794
|
+
flags: {
|
|
795
|
+
'local-only': { type: 'boolean', desc: '只删本地文件,不调服务端吊销接口' },
|
|
796
|
+
},
|
|
797
|
+
async run(ctx) {
|
|
798
|
+
const { out, config, flags, api } = ctx;
|
|
799
|
+
const creds = readCredentials(config.credentialsFile);
|
|
800
|
+
if (!creds) {
|
|
801
|
+
out.warn('本地本来就没有凭据(未登录)');
|
|
802
|
+
const data = { removed: false, revoked_remote: false, credentials_file: config.credentialsFile };
|
|
803
|
+
ctx.done(data);
|
|
804
|
+
return data;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
let revoke = { ok: false, reason: '已按要求跳过(--local-only)' };
|
|
808
|
+
if (flags.localOnly !== true) {
|
|
809
|
+
revoke = await api.revoke(creds.access_token);
|
|
810
|
+
}
|
|
811
|
+
const removed = deleteCredentials(config.credentialsFile);
|
|
812
|
+
const data = {
|
|
813
|
+
removed,
|
|
814
|
+
revoked_remote: revoke.ok === true,
|
|
815
|
+
revoke_error: revoke.ok ? null : revoke.reason || null,
|
|
816
|
+
credentials_file: config.credentialsFile,
|
|
817
|
+
client_id: creds.client_id,
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
if (revoke.ok) out.ok('服务端令牌已吊销');
|
|
821
|
+
else out.warn(`未能吊销服务端令牌(${revoke.reason || '未知原因'})—— 本地凭据已删除,登录态在本机已清除`);
|
|
822
|
+
if (removed) out.ok(`本地凭据已删除:${config.credentialsFile}`);
|
|
823
|
+
ctx.done(data);
|
|
824
|
+
return data;
|
|
825
|
+
},
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
/* ------------------------------------------------------------------ */
|
|
829
|
+
/* whoami */
|
|
830
|
+
/* ------------------------------------------------------------------ */
|
|
831
|
+
|
|
832
|
+
export const whoamiCommand = {
|
|
833
|
+
name: 'whoami',
|
|
834
|
+
summary: '显示当前登录的是谁(只显示账号标识,绝不打印令牌)',
|
|
835
|
+
usage: 'wkgeo whoami [--verbose]',
|
|
836
|
+
async run(ctx) {
|
|
837
|
+
const { out, config, flags, api } = ctx;
|
|
838
|
+
const creds = ctx.requireAuth();
|
|
839
|
+
const info = await api.userinfo(creds.access_token);
|
|
840
|
+
|
|
841
|
+
const data = {
|
|
842
|
+
account: {
|
|
843
|
+
user_id: info.sub || null,
|
|
844
|
+
email: info.email || null,
|
|
845
|
+
nickname: info.name || null,
|
|
846
|
+
},
|
|
847
|
+
client_id: creds.client_id,
|
|
848
|
+
scope: creds.scope,
|
|
849
|
+
obtained_at: creds.obtained_at || null,
|
|
850
|
+
expires_at: creds.expires_at || null,
|
|
851
|
+
expires_in_ms: creds.expires_at ? new Date(creds.expires_at).getTime() - ctx.now() : null,
|
|
852
|
+
credentials_file: config.credentialsFile,
|
|
853
|
+
logged_in: true,
|
|
854
|
+
};
|
|
855
|
+
if (flags.verbose) {
|
|
856
|
+
// 掩码是**允许**的(前 6 位 + 长度,抄不走),完整令牌永远不出现
|
|
857
|
+
data.token_masked = maskToken(creds.access_token);
|
|
858
|
+
const mode = checkFileMode(config.credentialsFile);
|
|
859
|
+
data.file_mode = mode.supported ? mode.modeText : '无法判断(Windows)';
|
|
860
|
+
data.file_mode_too_open = mode.supported ? !!mode.tooOpen : null;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (!out.json) {
|
|
864
|
+
out.head('当前登录身份');
|
|
865
|
+
out.line(` 账号:${data.account.email || data.account.nickname || '(服务端未返回账号标识)'}`);
|
|
866
|
+
out.line(` 用户 ID:${data.account.user_id || '-'}`);
|
|
867
|
+
out.line(` 应用(client_id):${data.client_id}`);
|
|
868
|
+
out.line(` 能力范围:${data.scope}`);
|
|
869
|
+
out.line(` 令牌到期:${formatTime(data.expires_at)}(${formatRemaining(data.expires_in_ms)})`);
|
|
870
|
+
out.line(` 凭据文件:${data.credentials_file}`);
|
|
871
|
+
if (flags.verbose) {
|
|
872
|
+
out.line(` 令牌(掩码):${data.token_masked}`);
|
|
873
|
+
out.line(` 文件权限:${data.file_mode}${data.file_mode_too_open ? ' ⚠️ 权限过宽,建议 chmod 600' : ''}`);
|
|
874
|
+
}
|
|
875
|
+
out.info(' (令牌明文不会显示:需要的是账号标识,不是凭据本身)');
|
|
876
|
+
}
|
|
877
|
+
ctx.done(data);
|
|
878
|
+
return data;
|
|
879
|
+
},
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
/* ------------------------------------------------------------------ */
|
|
883
|
+
/* config */
|
|
884
|
+
/* ------------------------------------------------------------------ */
|
|
885
|
+
|
|
886
|
+
export const configCommand = {
|
|
887
|
+
name: 'config',
|
|
888
|
+
summary: '显示当前生效的配置与登录状态(不含任何令牌)',
|
|
889
|
+
usage: 'wkgeo config',
|
|
890
|
+
async run(ctx) {
|
|
891
|
+
const { out, config } = ctx;
|
|
892
|
+
const creds = readCredentials(config.credentialsFile);
|
|
893
|
+
const sources = describeConfigSources(ctx.flags, ctx.env);
|
|
894
|
+
const mode = checkFileMode(config.credentialsFile);
|
|
895
|
+
/**
|
|
896
|
+
* ★ 顺带报告"本机代理情况"。
|
|
897
|
+
*
|
|
898
|
+
* 为什么放在 `config` 里:这条命令就是**排障入口**("把你的 config 输出发我")。
|
|
899
|
+
* 老板说过"很多用户不懂代理",让支持同学只用一条命令就能看出
|
|
900
|
+
* "哦,你机器上有代理且没绕过 127.0.0.1,所以回调收不到" —— 比让用户
|
|
901
|
+
* 自己去翻注册表/系统设置现实得多。
|
|
902
|
+
* 检测是**只读**的:不改用户任何设置(见 lib/proxy.mjs 开头三条硬约束)。
|
|
903
|
+
*/
|
|
904
|
+
const proxy = proxyWarningPayload(ctx.proxyInfo(), { cmdName: config.cmdName || 'wkgeo' });
|
|
905
|
+
|
|
906
|
+
const data = {
|
|
907
|
+
base: config.base,
|
|
908
|
+
/**
|
|
909
|
+
* ⚠️ `web_base` 是**兼容字段**:三站拆分后它指向老站(管理端),
|
|
910
|
+
* 已经没有任何代码用它打开页面 —— 保留只为不破坏既有自动化脚本。
|
|
911
|
+
* 真正生效的是下面三个按站的基址(2026-10-02 三站拆分新增)。
|
|
912
|
+
*/
|
|
913
|
+
web_base: config.webBase,
|
|
914
|
+
oauth_web_base: config.oauthWebBase,
|
|
915
|
+
pay_web_base: config.payWebBase,
|
|
916
|
+
user_web_base: config.userWebBase,
|
|
917
|
+
client_id: config.clientId,
|
|
918
|
+
scope: config.scope,
|
|
919
|
+
credentials_file: config.credentialsFile,
|
|
920
|
+
callback: `http://${config.callbackHost}:${config.callbackPort}${config.callbackPath}`,
|
|
921
|
+
http_timeout_ms: config.httpTimeoutMs,
|
|
922
|
+
login_timeout_ms: config.loginTimeoutMs,
|
|
923
|
+
sources,
|
|
924
|
+
logged_in: !!creds,
|
|
925
|
+
expired: creds ? isExpired(creds, { now: ctx.now() }) : null,
|
|
926
|
+
account: creds && creds.account ? { user_id: creds.account.sub || null, email: creds.account.email || null } : null,
|
|
927
|
+
expires_at: creds ? creds.expires_at || null : null,
|
|
928
|
+
file_mode: mode.supported ? mode.modeText : '无法判断(Windows)',
|
|
929
|
+
/** 结构化:有代理/有风险/建议怎么做(AI 端与支持同学都直接用这份) */
|
|
930
|
+
proxy,
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
if (!out.json) {
|
|
934
|
+
out.head('生效配置');
|
|
935
|
+
for (const [k, v] of Object.entries({
|
|
936
|
+
'API 基址': data.base,
|
|
937
|
+
'授权站基址': data.oauth_web_base,
|
|
938
|
+
'收银台基址': data.pay_web_base,
|
|
939
|
+
'会员站基址': data.user_web_base,
|
|
940
|
+
'老站基址(兼容字段,不再用于跳转)': data.web_base,
|
|
941
|
+
'client_id': data.client_id,
|
|
942
|
+
'能力范围': data.scope,
|
|
943
|
+
'回调地址': data.callback,
|
|
944
|
+
'凭据文件': data.credentials_file,
|
|
945
|
+
})) {
|
|
946
|
+
out.line(` ${k}:${v}`);
|
|
947
|
+
}
|
|
948
|
+
out.line(` 登录状态:${data.logged_in ? (data.expired ? '已过期(需重新 login)' : '已登录') : '未登录'}`);
|
|
949
|
+
if (data.logged_in) {
|
|
950
|
+
out.line(` 账号:${(data.account && (data.account.email || data.account.user_id)) || '-'}`);
|
|
951
|
+
out.line(` 令牌到期:${formatTime(data.expires_at)}`);
|
|
952
|
+
}
|
|
953
|
+
out.line(` 系统代理:${proxy.detected ? `${proxy.proxy}(来源 ${proxy.source})` : '未检测到'}`);
|
|
954
|
+
if (proxy.callback_may_fail) {
|
|
955
|
+
/**
|
|
956
|
+
* ⚠️ 口径与 login 一致:**检测到代理就一定会走设备码**(不看绕过列表),
|
|
957
|
+
* 所以这里不能说"已绕过回环就没问题"(真机上正是这么翻车的)。
|
|
958
|
+
*/
|
|
959
|
+
out.warn(
|
|
960
|
+
' 检测到系统代理 → `wkgeo login` 会**直接改用设备码模式**(不再依赖 127.0.0.1 回调;绕过列表写了 127.*/localhost 也不作依据)。',
|
|
961
|
+
);
|
|
962
|
+
out.info(' 想坚持浏览器回调模式:`wkgeo login --no-auto-device`,并把 127.0.0.1,localhost 加入代理绕过列表。');
|
|
963
|
+
}
|
|
964
|
+
out.info(` (配置来源:${JSON.stringify(sources)})`);
|
|
965
|
+
}
|
|
966
|
+
ctx.done(data);
|
|
967
|
+
return data;
|
|
968
|
+
},
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
/** 未登录时的统一提示(whoami / 业务命令共用) */
|
|
972
|
+
export function notLoggedInError() {
|
|
973
|
+
return new CliError('尚未登录', { kind: 'unauthenticated', hint: loginHint(), exitCode: EXIT.AUTH });
|
|
974
|
+
}
|