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/lib/proxy.mjs ADDED
@@ -0,0 +1,454 @@
1
+ /**
2
+ * 系统代理检测 —— **只读**,只回答一个问题:
3
+ * 「浏览器能不能把授权码送回 127.0.0.1?」
4
+ *
5
+ * ===========================================================================
6
+ * ## 为什么需要它(老板报的真实故障)
7
+ * 本地回调模式要求浏览器访问 `http://127.0.0.1:<端口>/callback?code=…`。
8
+ * 多数代理默认绕过回环地址,但**全局/系统代理 + 绕过列表为空**时,
9
+ * 浏览器会把这条回环请求也交给代理 → CLI 的本地服务永远收不到 code
10
+ * → 用户在页面上点了「同意」,终端却一直卡在"等待授权",最后超时。
11
+ * (极少数代理还会回一个 502/连接失败页,用户以为站点坏了。)
12
+ *
13
+ * 2026-10-02 老板原话:「有代理的时候不能成功。」
14
+ * → 我们的做法:**检测到代理就直接改走设备码(不需要任何回调)**,
15
+ * 同时在文档/错误提示里保留"改代理绕过列表"这条补充说明。
16
+ *
17
+ * ===========================================================================
18
+ * ## ⚠️⚠️ 2026-10-02 真机第二次翻车 → 第二版加固(**这条最容易被写错,别改回去**)
19
+ * 第一版的判定是"有代理 **且** 绕过列表里没有回环 → 算风险 → 才降级"。
20
+ * 真机上它**失效了**,过程如下(直接采信,不需要复现):
21
+ * * 老板的代理是虚拟网卡代理,Windows 系统代理 = `127.0.0.1:7890`,
22
+ * 而 `ProxyOverride` 里**确实写着** `localhost;127.*;…`;
23
+ * * 我们的检测据此得出 `loopback_bypassed: true` → `risk: false`
24
+ * → **不降级、走回调模式**;
25
+ * * 结果:老板在同意页点了【同意授权】,CLI **一直拿不到令牌**
26
+ * (回调请求根本没到本机);
27
+ * * 独立探测证据:`curl -x http://127.0.0.1:7890 http://127.0.0.1:1/x`
28
+ * → **返回 502** —— 说明该代理**会尝试接管回环请求**(而不是直接拒绝),
29
+ * 也就是说"绕过列表里有 127.*"在这台机器上**并没有真正让请求直连**。
30
+ * ⇒ 结论:**"绕过列表里有 127.*"不等于绕过一定生效**。不同代理软件 / 浏览器 /
31
+ * 系统代理 + PAC 叠加时,对 bypass 的解释并不一致(有的只认主机名不认 `127.*`
32
+ * 这种通配、有的先把回环发到上游再回连、有的 PAC 求值根本不看 ProxyOverride)。
33
+ * ⇒ 因此**本次起:只要 `detected === true` 就一定走设备码**。
34
+ * 宁可让用户多走一步(按一次短码),也绝不能让用户卡在"点了同意却没反应"。
35
+ * ⇒ `loopback_bypassed` / `risk` **只作诊断字段继续上报**(`--json` 里还在,支持同学
36
+ * 仍然能看到"绕过列表写了什么"),但**不再作为放行回调的依据**。
37
+ * ⇒ 想坚持回调的高级用户仍可用 `--no-auto-device` / `WKONGAI_AUTO_DEVICE=0`
38
+ * 强制走回调(那是他的显式选择),但 CLI 必须把"代理可能吃掉回调"明确警告出来
39
+ * —— 见 `proxyForcedCallbackLines()`。
40
+ *
41
+ * ===========================================================================
42
+ * ## 三条硬约束(改这个文件时别越线)
43
+ * 1. **绝不修改用户的代理设置**:只读注册表 / 只读 `scutil` / 只读环境变量。
44
+ * 代理由用户的 VPN/代理软件管理,我们改动它等于替用户改网络配置(危险且不可预期)。
45
+ * 2. **零第三方依赖**:Windows 用系统自带 `reg.exe`,macOS 用系统自带 `scutil`,
46
+ * Linux 只读环境变量 —— 刻意**不**引入任何 npm 包(本项目 CLI 依赖数为 0),
47
+ * 也刻意不去读 gsettings / /etc/environment(没有跨发行版通用做法,收益极低)。
48
+ * 3. **检测失败一律当成"没检测到"**:拿不到信息最多是"少给一条提示",
49
+ * **绝不能**因此让登录失败(宁可少提示,也不要把登录拦下来)。
50
+ *
51
+ * ===========================================================================
52
+ * ## 各平台到底看什么
53
+ * * Windows:注册表 `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings`
54
+ * —— `ProxyEnable`(0/1)、`ProxyServer`(可能是 `http=…;https=…` 这种按协议的写法)、
55
+ * `ProxyOverride`(绕过列表,分号分隔,可以含 `<local>`)、`AutoConfigURL`(PAC)。
56
+ * ⚠️ 真机数据(本机实测):`ProxyEnable=0x0` + `ProxyServer=127.0.0.1:7890`
57
+ * + `ProxyOverride=…;localhost;*.local;127.*;10.*;…`
58
+ * —— 典型的"装过 Clash 类代理,但当前**没有**开启系统代理"。
59
+ * 所以**只看 `ProxyServer` 存不存在会误报**,必须同时看 `ProxyEnable`;
60
+ * 反过来 `ProxyOverride` 里有 `127.*` / `localhost` 也算合格绕过(不能误报)。
61
+ * * macOS:`scutil --proxy`(系统自带)。它把 HTTP/HTTPS/SOCKS 的开关、地址、端口
62
+ * 和 `ExceptionsList`(绕过列表)都打出来。`scutil` 不存在时静默跳过。
63
+ * * Linux:只看 `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`(大小写都认)。
64
+ * 桌面环境的 gsettings/环境变量差异太大,不做猜测。
65
+ *
66
+ * ===========================================================================
67
+ * ## ⚠️ 一个容易被误解的点(写文档时要带上)
68
+ * 这里的"代理"对我们有**两种截然不同的影响**:
69
+ * * 对**浏览器**:系统代理直接生效 —— 这正是"回调被接管"的根因(我们主要防这个)。
70
+ * * 对 **CLI 自己**:Node 的 fetch(undici)**默认不读** `HTTP_PROXY` 环境变量,
71
+ * 也不读系统代理(要 Node ≥ 24 的 `--use-env-proxy` / `NODE_USE_ENV_PROXY=1`
72
+ * 才会走环境变量代理)。所以"CLI 出网失败"通常不是这里的代理造成的,
73
+ * 我们也不据此改 CLI 的网络行为 —— 只提示、只降级。
74
+ * (设备码流程全程只需要 CLI **出网**,不需要任何入站回调,因此对代理天然免疫。)
75
+ */
76
+
77
+ import { spawnSync } from 'node:child_process';
78
+
79
+ /** Windows 系统代理设置的注册表位置(WinINET,即 Edge/Chrome/IE 共用的那份) */
80
+ export const WININET_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings';
81
+
82
+ /** 外部命令的超时:宁可"没检测到",也不能把登录卡住(`reg.exe` 正常 <50ms) */
83
+ export const PROXY_PROBE_TIMEOUT_MS = 2000;
84
+
85
+ /** 环境变量里"代理"和"绕过列表"的常见键名(大小写两种写法都认,Linux 上两种都有人写) */
86
+ const PROXY_ENV_KEYS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'];
87
+ const NO_PROXY_ENV_KEYS = ['NO_PROXY', 'no_proxy'];
88
+
89
+ /**
90
+ * 一个绕过表项是否"**看起来**覆盖了回环地址"。
91
+ *
92
+ * ⚠️ 这里刻意认了 **`<local>`** 和 **`*`**:
93
+ * * `<local>`:Windows 的"绕过本地地址"约定。实测 Clash / V2Ray 这类客户端
94
+ * 写的就是 `localhost;127.*;<local>`。
95
+ * * `*`:WinINET 与 NO_PROXY 里都表示"全部绕过"(等于没有代理)。
96
+ *
97
+ * 🔴 **但它的结论只是"诊断"**:本函数回答的是"绕过列表**写了**回环",
98
+ * 而**不是**"绕过**真的生效了" —— 真机上就撞见过"写了 127.* 却依然被代理接管"
99
+ * (见文件顶部那段真机证据)。所以 `loopback_bypassed` **绝不允许**再被当成
100
+ * "可以放心走回调"的依据;要不要降级只由 `detected` 决定。
101
+ * (这条注释是刻意留在这里的:后来的人很容易看到"绕过了"就顺手拿它去放行。)
102
+ */
103
+ const LOOPBACK_ENTRY_PATTERNS = [
104
+ /^\*$/,
105
+ /^<local>$/i,
106
+ /^localhost(?::\d+)?$/i,
107
+ /^127(?:\.(?:\d{1,3}|\*)){1,3}(?::\d+)?$/, // 127.0.0.1 / 127.* / 127.0.0.*
108
+ /^127\.0\.0\.0\/8$/,
109
+ /^::1$/,
110
+ /^\[::1\](?::\d+)?$/,
111
+ ];
112
+
113
+ /** 按 `,` / `;` / 空白切分绕过列表(Windows 用分号,Unix 用逗号,还有人手写空格) */
114
+ export function splitBypass(text) {
115
+ return String(text == null ? '' : text)
116
+ .split(/[,;\s]+/)
117
+ .map((s) => s.trim())
118
+ .filter(Boolean);
119
+ }
120
+
121
+ /** 绕过列表里是否有"覆盖回环地址"的项 */
122
+ export function isLoopbackBypassed(list) {
123
+ const entries = Array.isArray(list) ? list : splitBypass(list);
124
+ return entries.some((e) => LOOPBACK_ENTRY_PATTERNS.some((re) => re.test(String(e).trim())));
125
+ }
126
+
127
+ /** `0x1` / `1` / `'0x0'` → boolean(注册表里 ProxyEnable 是 REG_DWORD,值是 `0x1`/`0x0`) */
128
+ export function parseDword(value) {
129
+ const text = String(value == null ? '' : value).trim();
130
+ if (!text) return false;
131
+ if (/^0x[0-9a-f]+$/i.test(text)) return Number.parseInt(text, 16) !== 0;
132
+ return Number(text) !== 0;
133
+ }
134
+
135
+ /**
136
+ * 解析 `reg query "<key>"` 的输出。
137
+ *
138
+ * 输出形如(只取键值行,子键行没有 `REG_` 标记,天然被忽略):
139
+ * ```
140
+ * HKEY_CURRENT_USER\...\Internet Settings
141
+ * ProxyEnable REG_DWORD 0x0
142
+ * ProxyServer REG_SZ 127.0.0.1:7890
143
+ * ProxyOverride REG_SZ localhost;127.*;<local>
144
+ * ```
145
+ * ⚠️ 值可能是空串(有人把 ProxyServer 清空了),所以值部分要允许为空。
146
+ * ⚠️ 每个值名在同一次输出里可能出现多次(按协议分列时也会),取**第一条**即可。
147
+ */
148
+ export function parseRegQuery(stdout) {
149
+ const values = {};
150
+ for (const line of String(stdout == null ? '' : stdout).split(/\r?\n/)) {
151
+ const m = /^\s+([^\s]+)\s+(REG_[A-Z_]+)\s*(.*)$/.exec(line);
152
+ if (!m) continue;
153
+ const [, name, , value] = m;
154
+ if (values[name] === undefined) values[name] = value.trim();
155
+ }
156
+ return values;
157
+ }
158
+
159
+ /**
160
+ * 解析 `scutil --proxy` 的输出。
161
+ *
162
+ * 输出形如:
163
+ * ```
164
+ * <dictionary> {
165
+ * ExceptionsList : <array> {
166
+ * 0 : *.local
167
+ * 1 : 169.254/16
168
+ * }
169
+ * HTTPEnable : 0
170
+ * HTTPSEnable : 1
171
+ * HTTPSPort : 7890
172
+ * HTTPSProxy : 127.0.0.1
173
+ * }
174
+ * ```
175
+ * ⚠️ 数组块要单独处理:里面的行也是 `数字 : 值` 的形状,若不区分就会把 `0 : *.local`
176
+ * 当成一个叫 "0" 的键(无害但绕过列表会丢)。
177
+ */
178
+ export function parseScutilProxy(stdout) {
179
+ const text = String(stdout == null ? '' : stdout);
180
+ const values = {};
181
+ const exceptions = [];
182
+ let inArray = false;
183
+
184
+ for (const line of text.split(/\r?\n/)) {
185
+ const trimmed = line.trim();
186
+ if (!trimmed) continue;
187
+ if (/^(<array>|ExceptionsList\s*:\s*<array>)\s*\{/.test(trimmed)) {
188
+ inArray = true;
189
+ continue;
190
+ }
191
+ if (inArray) {
192
+ if (trimmed === '}') {
193
+ inArray = false;
194
+ continue;
195
+ }
196
+ const em = /^\d+\s*:\s*(.+)$/.exec(trimmed);
197
+ if (em) exceptions.push(em[1].trim());
198
+ continue;
199
+ }
200
+ const m = /^([A-Za-z0-9_]+)\s*:\s*(.*)$/.exec(trimmed);
201
+ if (m) values[m[1]] = m[2].trim();
202
+ }
203
+
204
+ const servers = [];
205
+ if (parseDword(values.HTTPEnable)) servers.push(`http://${values.HTTPProxy || ''}:${values.HTTPPort || ''}`);
206
+ if (parseDword(values.HTTPSEnable)) servers.push(`https://${values.HTTPSProxy || ''}:${values.HTTPSPort || ''}`);
207
+ if (parseDword(values.SOCKSEnable)) servers.push(`socks://${values.SOCKSProxy || ''}:${values.SOCKSPort || ''}`);
208
+
209
+ return {
210
+ enabled: servers.length > 0,
211
+ servers,
212
+ exceptions,
213
+ autoConfig: parseDword(values.ProxyAutoConfigEnable) && !!values.ProxyAutoConfigURLString,
214
+ autoConfigUrl: values.ProxyAutoConfigURLString || null,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * 默认的"跑一条外部命令拿 stdout"实现。
220
+ * 失败(命令不存在 / 超时 / 非 0 退出)一律返回 `null` —— 检测不到就当没有代理。
221
+ */
222
+ export function runCommand(cmd, args) {
223
+ try {
224
+ const r = spawnSync(cmd, args, {
225
+ encoding: 'utf8',
226
+ timeout: PROXY_PROBE_TIMEOUT_MS,
227
+ windowsHide: true,
228
+ });
229
+ if (r.error || r.status !== 0) return null;
230
+ return typeof r.stdout === 'string' ? r.stdout : null;
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
235
+
236
+ /** 从 env 里取第一个非空的键值(`''` 视为没设) */
237
+ function firstEnv(env, keys) {
238
+ for (const k of keys) {
239
+ const v = env && env[k];
240
+ if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();
241
+ }
242
+ return null;
243
+ }
244
+
245
+ /**
246
+ * 检测系统代理。
247
+ *
248
+ * @param {object} [opts]
249
+ * @param {string} [opts.platform] 平台(默认 process.platform;测试注入)
250
+ * @param {object} [opts.env] 环境变量(默认 process.env;测试注入)
251
+ * @param {Function} [opts.exec] `(cmd, args) => stdout|null`(默认 runCommand;测试注入,**单测绝不起子进程**)
252
+ * @returns {{
253
+ * detected: boolean, // 是否发现"生效中"的代理 —— ★ **这是唯一的降级依据**
254
+ * risk: boolean, // 诊断字段:有代理,且至少一个来源**没有**绕过 127.0.0.1/localhost
255
+ * loopback_bypassed: boolean, // 诊断字段:发现的代理是否**全部**绕过了回环(⚠️ 不代表绕过真的生效)
256
+ * sources: Array<{source:string,label:string,proxy:string,bypass:string[],loopback_bypassed:boolean}>,
257
+ * platform: string,
258
+ * probe_error: string|null // 探测本身失败的原因(只作诊断,不影响判定)
259
+ * }}
260
+ */
261
+ export function detectProxy({ platform = process.platform, env = process.env, exec = runCommand } = {}) {
262
+ const sources = [];
263
+ let probeError = null;
264
+
265
+ /* ---- 1. 环境变量(三平台都看;Linux 只有这一条) ---- */
266
+ const envProxy = firstEnv(env, PROXY_ENV_KEYS);
267
+ if (envProxy) {
268
+ const bypass = splitBypass(firstEnv(env, NO_PROXY_ENV_KEYS));
269
+ sources.push({
270
+ source: 'env',
271
+ label: '环境变量代理(HTTP_PROXY/HTTPS_PROXY/ALL_PROXY)',
272
+ proxy: envProxy,
273
+ bypass,
274
+ loopback_bypassed: isLoopbackBypassed(bypass),
275
+ });
276
+ }
277
+
278
+ /* ---- 2. Windows:注册表(WinINET,浏览器实际用的那份) ---- */
279
+ if (platform === 'win32') {
280
+ const stdout = exec('reg', ['query', WININET_KEY]);
281
+ if (stdout === null) {
282
+ probeError = 'reg query 没拿到结果(可能不在 PATH 或被安全软件拦了)';
283
+ } else {
284
+ const v = parseRegQuery(stdout);
285
+ const enabled = parseDword(v.ProxyEnable);
286
+ if (enabled && v.ProxyServer) {
287
+ const bypass = splitBypass(v.ProxyOverride);
288
+ sources.push({
289
+ source: 'windows-registry',
290
+ label: 'Windows 系统代理(Internet 设置)',
291
+ proxy: v.ProxyServer,
292
+ bypass,
293
+ loopback_bypassed: isLoopbackBypassed(bypass),
294
+ });
295
+ }
296
+ /**
297
+ * PAC(自动配置脚本):绕过规则写在**脚本里**,我们无法在零依赖的前提下求值。
298
+ * 保守起见按"存在代理且绕过规则未知"处理 → 判为风险。
299
+ * 理由:宁可让用户走一次设备码(体验仍然顺畅),也不要让他点在同意页上干等 5 分钟。
300
+ */
301
+ if (enabled && v.AutoConfigURL) {
302
+ sources.push({
303
+ source: 'windows-pac',
304
+ label: `Windows 自动配置脚本(PAC:${v.AutoConfigURL})`,
305
+ proxy: v.AutoConfigURL,
306
+ bypass: [],
307
+ loopback_bypassed: false,
308
+ });
309
+ }
310
+ }
311
+ }
312
+
313
+ /* ---- 3. macOS:`scutil --proxy`(系统自带;不存在/失败就跳过) ---- */
314
+ if (platform === 'darwin') {
315
+ const stdout = exec('scutil', ['--proxy']);
316
+ if (stdout === null) {
317
+ probeError = 'scutil --proxy 没拿到结果';
318
+ } else {
319
+ const p = parseScutilProxy(stdout);
320
+ if (p.enabled) {
321
+ sources.push({
322
+ source: 'macos-scutil',
323
+ label: 'macOS 系统代理',
324
+ proxy: p.servers.join(' , '),
325
+ bypass: p.exceptions,
326
+ loopback_bypassed: isLoopbackBypassed(p.exceptions),
327
+ });
328
+ }
329
+ if (p.autoConfig) {
330
+ sources.push({
331
+ source: 'macos-pac',
332
+ label: `macOS 自动代理配置(PAC:${p.autoConfigUrl})`,
333
+ proxy: p.autoConfigUrl,
334
+ bypass: [],
335
+ loopback_bypassed: false,
336
+ });
337
+ }
338
+ }
339
+ }
340
+
341
+ const detected = sources.length > 0;
342
+ return {
343
+ detected,
344
+ risk: sources.some((s) => !s.loopback_bypassed),
345
+ loopback_bypassed: detected && sources.every((s) => s.loopback_bypassed),
346
+ sources,
347
+ platform,
348
+ probe_error: probeError,
349
+ };
350
+ }
351
+
352
+ /**
353
+ * 把检测结果变成 **`--json` 用的结构化字段**(`meta.proxy_warning` / 事件里的 `proxy_warning`)。
354
+ *
355
+ * 为什么连"建议动作"也结构化:AI 端要能**照着念给用户**,而不是自己组织语言。
356
+ * 之前 AI 端拿到的只有一句错误文本,它只能猜该让用户做什么。
357
+ *
358
+ * ⚠️ 2026-10-02 第二版加固后的口径(**改这里之前先读文件顶部那段真机证据**):
359
+ * * `callback_may_fail` = **`detected`** —— 只要检测到生效中的代理就认为回调不可信,
360
+ * **不再**看绕过列表(真机上"写了 127.* 也照样被接管");
361
+ * * `risk` / `loopback_bypassed` / `risky_sources` **只作诊断**保留,
362
+ * 方便支持同学看清楚"这台机器上代理与绕过列表长什么样";
363
+ * * 新增 `loopback_bypass_trusted: false`,把"绕过列表已不再作为放行依据"这件事
364
+ * 明确写进机器可读输出(AI 端就不会再自作聪明地说"你绕过了所以没事")。
365
+ */
366
+ export function proxyWarningPayload(info, { cmdName = 'wkgeo' } = {}) {
367
+ if (!info) return null;
368
+ const risky = info.sources.filter((s) => !s.loopback_bypassed);
369
+ /** ★ 唯一的降级依据:有没有检测到生效中的代理(与绕过列表无关) */
370
+ const detected = !!info.detected;
371
+ return {
372
+ detected,
373
+ /** ★ 只有它为 true 才需要用户/CLI 做点什么(现在等价于 detected) */
374
+ callback_may_fail: detected,
375
+ /** ⚠️ 恒为 false:绕过列表**不再**是"可以走回调"的凭据(真机证据见文件顶部) */
376
+ loopback_bypass_trusted: false,
377
+ /** 旧口径,纯诊断:有代理且至少一个来源没绕过回环 */
378
+ risk: !!info.risk,
379
+ loopback_bypassed: !!info.loopback_bypassed,
380
+ platform: info.platform || null,
381
+ proxy: info.sources.length ? info.sources.map((s) => s.proxy).join(' , ') : null,
382
+ source: info.sources.length ? info.sources.map((s) => s.source).join(',') : null,
383
+ bypass: info.sources.length ? info.sources[0].bypass : [],
384
+ risky_sources: risky.map((s) => ({ source: s.source, proxy: s.proxy, bypass: s.bypass })),
385
+ probe_error: info.probe_error || null,
386
+ /** 主路径:改用设备码(不需要任何回调);后两条是"如果你就想用回调模式"的补充办法 */
387
+ actions: detected
388
+ ? [
389
+ {
390
+ type: 'use_device_code',
391
+ label: '改用设备码模式(不需要本地回调,推荐)',
392
+ command: `${cmdName} login --device`,
393
+ },
394
+ {
395
+ type: 'bypass_loopback',
396
+ label: '把 127.0.0.1,localhost 加进代理绕过列表(想继续用浏览器回调模式时;注意它不保证生效)',
397
+ },
398
+ { type: 'disable_proxy', label: '临时关闭代理后重试' },
399
+ ]
400
+ : [],
401
+ };
402
+ }
403
+
404
+ /**
405
+ * 人类可读提示块:**"已自动改用设备码模式"**(人类模式下走这条路,AI 端看上面的 payload)。
406
+ *
407
+ * ⚠️ 触发条件与 payload 的 `callback_may_fail` 保持一致:`detected`,**不是** `risk`。
408
+ * ⚠️ 没有检测到代理返回 `[]`(**不要在正常环境里制造噪音**)。
409
+ */
410
+ export function proxyDeviceSwitchLines(info, { cmdName = 'wkgeo' } = {}) {
411
+ if (!info || !info.detected) return [];
412
+ const first = info.sources[0] || { proxy: '', bypass: [], source: '' };
413
+ const bypass = (first.bypass || []).join(',') || '空';
414
+ return [
415
+ `⚠️ 检测到系统代理(${first.proxy || '未知地址'})。为避免“点了同意却一直收不到结果”,已自动改用【设备码模式】。`,
416
+ ` 想强制用浏览器回调模式:加 --no-auto-device,并把 127.0.0.1,localhost 加入代理绕过列表。`,
417
+ ` (检测详情:源=${info.sources.map((s) => s.source).join(',')} 代理=${first.proxy || '-'} 绕过列表=${bypass};` +
418
+ `⚠️ 绕过列表里就算写了 127.*/localhost 也**不再**作为放行回调的依据 —— 真机上出现过"写了却仍被代理接管"。)`,
419
+ ];
420
+ }
421
+
422
+ /**
423
+ * 人类可读警告块:用户**显式强制**走回调模式(`--no-auto-device` / `WKONGAI_AUTO_DEVICE=0`),
424
+ * 但本机确实有代理 —— 必须把"你可能一直等不到结果"讲清楚,否则他只会觉得 CLI 坏了。
425
+ *
426
+ * ⚠️ 这一段是**唯一的**阻止"用户点了同意却干等超时"的东西(因为我们按他的要求没有改道),
427
+ * 所以文案里必须同时给出**两条**可照做的出路。
428
+ */
429
+ export function proxyForcedCallbackLines(info) {
430
+ if (!info || !info.detected) return [];
431
+ const first = info.sources[0] || { proxy: '' };
432
+ return [
433
+ `⚠️ 你已强制使用回调模式(--no-auto-device / WKONGAI_AUTO_DEVICE=0),但本机检测到系统代理(${first.proxy || '未知地址'})。`,
434
+ ' 若这个代理会接管 127.0.0.1,你在页面上点了【同意授权】之后本机也收不到结果(会一直等到超时)。',
435
+ ' 两条出路:① 把 127.0.0.1,localhost 加入代理绕过列表;② 去掉 --no-auto-device,让 CLI 自动改用设备码模式。',
436
+ ];
437
+ }
438
+
439
+ /**
440
+ * 给**错误 `hint`** 用的一句话版本(错误对象里只能放短文本)。
441
+ * 没有检测到代理返回 `null` —— 调用方据此决定要不要加这句。
442
+ *
443
+ * ⚠️ 触发条件同样是 `detected`(不是 `risk`):超时那一刻我们已经无法判断
444
+ * "绕过列表到底有没有生效",所以有代理就要把这条线索给出去。
445
+ */
446
+ export function proxyRiskHint(info, { cmdName = 'wkgeo' } = {}) {
447
+ if (!info || !info.detected) return null;
448
+ const proxy = (info.sources[0] && info.sources[0].proxy) || '未知地址';
449
+ return (
450
+ `本机检测到系统代理(${proxy});若它接管 127.0.0.1,浏览器回调就收不到(绕过列表里写了 127.*/localhost 也不保证生效)。` +
451
+ `三选一:① 改用不需要回调的 \`${cmdName} login --device\`;` +
452
+ `② 把 127.0.0.1,localhost 加入代理绕过列表;③ 临时关闭代理后重试`
453
+ );
454
+ }
package/lib/qr.mjs ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * 可选的终端二维码。
3
+ *
4
+ * 为什么做成"有就用、没有就打印链接":
5
+ * 微信 Native 支付返回的 `code_url` 是 `weixin://wxpay/...`,
6
+ * **手机直接扫这段文本是扫不了的**,所以给人看的场景最好在终端画一个 ASCII 二维码。
7
+ * 但 `qrcode-terminal` 是个第三方包,而老板要求"尽量零第三方依赖"。
8
+ * 折中:动态 import —— 装了就用,没装就只打印链接(并说明"用手机打开/复制到微信")。
9
+ * ⚠️ 绝不能因为二维码库缺失就让下单/充值命令失败。
10
+ */
11
+
12
+ export async function renderQrOptional(text, out) {
13
+ if (!text) return false;
14
+ try {
15
+ const mod = await import('qrcode-terminal');
16
+ const gen = mod && (mod.default || mod);
17
+ if (gen && typeof gen.generate === 'function') {
18
+ let printed = '';
19
+ gen.generate(text, { small: true }, (q) => {
20
+ printed = q;
21
+ });
22
+ if (printed) {
23
+ out.line(printed);
24
+ return true;
25
+ }
26
+ }
27
+ } catch {
28
+ /* 没装 → 静默降级 */
29
+ }
30
+ return false;
31
+ }