wkgeo-cli 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@
5
5
  * 需要用户做的事(同意授权)表现为:打印网址 + 阻塞等待 + 超时给明确错误。
6
6
  */
7
7
 
8
- import { runLogin, DEFAULT_CALLBACK_FIRST_WAIT_MS } from '../login.mjs';
8
+ import { runLogin } from '../login.mjs';
9
9
  import { requestDeviceCode, announceDeviceCode, pollDeviceToken, devicePasteText } from '../device.mjs';
10
10
  import {
11
11
  pendingDeviceFile,
@@ -62,34 +62,36 @@ export function resolveAutoDevice(flags = {}, env = {}) {
62
62
  }
63
63
 
64
64
  /**
65
- * 「回调模式首轮等待」窗口(毫秒)的解析 —— ★ 第二条保险(见 login.mjs 的同名注释)。
65
+ * 「回调模式首轮等待」窗口(毫秒)的解析 —— **可选自愈,默认关闭**。
66
66
  *
67
- * ## ⚠️⚠️ 2026-10-03 老板真机反馈后改了触发条件(这是本次修复的第一条)
68
- * 旧逻辑:**无条件**返回默认 60 秒 ⇒ 用户机器上**没有任何代理/VPN**时也会自愈。
69
- * 而"把授权页送到用户面前、他读完再点【同意】"经常超过 60 秒 ⇒ 明明不需要设备码也切了过去。
70
- * 老板原话:「现在变成设备码了,我们因为没有开任何VPN,所以这个是不对的。」
71
- * ⇒ 现在:**只有 `proxyDetected === true`(真检测到生效中的代理)才用默认值启用**;
72
- * **没有代理时不启用**(返回 0,老老实实等满 `--timeout`)。
67
+ * ## ★★ 0.2.2(老板 2026-10-21 反馈):默认不再自动改设备码
68
+ * 老板原话:「默认的也不应该是设备授权,默认的就是那个传统的授权方式(浏览器打开授权页 +
69
+ * 本地 localhost 回调)。设备码是本地读不了/代理时才用的。」
70
+ * ⇒ 本函数**不再**按"有没有代理"给默认值:用户没显式指定时**一律返回 0**。
71
+ * 也就是说 `wkgeo login` 默认会一路等回调到 `--timeout`,**不会**因为等太久自动改设备码。
73
72
  *
74
- * ## 优先级(显式 > 自动判断)
75
- * `--callback-first-wait <时长>`(默认单位**秒**;`0` / `off` / `none` = 关掉自愈)
73
+ * 设备码只在这三种情况出现(见 loginCommand.run):
74
+ * 1. 用户显式 `--device` / `--no-wait` / `--device-continue`;
75
+ * 2. 检测到系统代理且判定回调不可用(`--no-auto-device` 可关);
76
+ * 3. 本地回调端口**真的起不来**(`kind = 'listen'`,真实失败兜底;`--no-auto-device` 可关)。
77
+ *
78
+ * ## 保留的显式开关(向后兼容,不参与默认判断)
79
+ * `--callback-first-wait <时长>`(默认单位**秒**;`0` / `off` / `none` = 关闭)
76
80
  * > `WKONGAI_CALLBACK_FIRST_WAIT_MS`(毫秒)
77
- * > **有代理**时的 `DEFAULT_CALLBACK_FIRST_WAIT_MS`(180 秒)/ **无代理**时的 0
78
- * ⇒ 用户**显式**指定时一律照旧尊重(哪怕没有代理)—— 显式指定优先于自动判断。
81
+ * > 默认 **0(关闭)**
82
+ * ⇒ 只有用户**显式**写了它,才会在首轮等待到点后并发启动设备码(两条路先到先得)。
83
+ * 显式指定是用户的明确选择,我们照旧尊重(但他不写就绝不会自动发生)。
79
84
  *
80
85
  * ⚠️ `--no-auto-device`(autoDevice=false)时**强制返回 0**:用户已经明确要求"只走回调",
81
- * 我们没有"改道"这个选项,就不能背着他偷偷改(那样比不给自愈更糟:他会以为
82
- * CLI 没听他的)。此时整段 `--timeout` 依然是兜底。
86
+ * 我们就连显式开关也不启用(不背着他改道)。此时整段 `--timeout` 依然是兜底。
83
87
  *
84
88
  * @param {object} flags 已解析的命令行标志(camelCase)
85
89
  * @param {object} env 环境变量
86
90
  * @param {{autoDevice?: boolean, proxyDetected?: boolean}} [opts]
87
- * `proxyDetected` **必须**由调用方用 `proxyInfo.detected` 显式传入;
88
- * 缺省按 `false`(= 没检测到代理)处理 —— 这是刻意的保守默认:
89
- * "信息不足时宁可不自愈",也避免有人忘了传就又回到"无代理也切设备码"的老毛病。
90
- * @returns {number} 毫秒;0 = 关闭自愈
91
+ * `proxyDetected` 仅为**兼容旧调用方**保留,**已不影响返回值**(0.2.2 起默认永不开启)。
92
+ * @returns {number} 毫秒;0 = 不启用(默认)
91
93
  */
92
- export function resolveCallbackFirstWaitMs(flags = {}, env = {}, { autoDevice = true, proxyDetected = false } = {}) {
94
+ export function resolveCallbackFirstWaitMs(flags = {}, env = {}, { autoDevice = true } = {}) {
93
95
  if (!autoDevice) return 0;
94
96
  const off = new Set(['0', 'off', 'none', 'false', 'no']);
95
97
  if (flags.callbackFirstWait !== undefined && flags.callbackFirstWait !== '') {
@@ -103,11 +105,11 @@ export function resolveCallbackFirstWaitMs(flags = {}, env = {}, { autoDevice =
103
105
  return parseDurationMs(envVal, { defaultUnit: 'ms', flag: 'WKONGAI_CALLBACK_FIRST_WAIT_MS' });
104
106
  }
105
107
  /**
106
- * ★ 走到这里 = 用户**没有**显式指定 ⇒ 只有"真检测到代理"才启用自愈。
107
- * 没有代理时返回 0:`auto_device.applied=true, reason='callback_first_wait'`
108
- * 因此**只可能**出现在有代理的机器上(老板真机那条反馈的口径)。
108
+ * ★ 走到这里 = 用户**没有**显式指定 ⇒ **默认不启用**(0.2.2 起)。
109
+ * 不再看 `proxyDetected`:有代理时命令层已经在第一跳直接走设备码了,
110
+ * 回调模式下的"等待超时"只该如实报错,不该偷偷改道。
109
111
  */
110
- return proxyDetected ? DEFAULT_CALLBACK_FIRST_WAIT_MS : 0;
112
+ return 0;
111
113
  }
112
114
 
113
115
  /**
@@ -165,7 +167,7 @@ function deviceStoppedError() {
165
167
  * 宁可让那次后台轮询在下一轮自己发现 `shouldStop()` 并退出,也不能把用户的登录卡在这里。
166
168
  * ⚠️ `deviceSide.promise` 内部两个分支都已 catch(不会 reject),这里 await 它不会抛。
167
169
  */
168
- async function 停掉设备码那条路(deviceSide, cancelGate) {
170
+ async function stopDeviceCodeFlow(deviceSide, cancelGate) {
169
171
  if (!deviceSide.promise) return;
170
172
  deviceSide.cancelled = true;
171
173
  cancelGate.wakeAll();
@@ -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,15 +216,15 @@ const TERMINAL_DEVICE_KINDS = new Set(['denied', 'expired', 'not_implemented', '
212
216
 
213
217
  export const loginCommand = {
214
218
  name: 'login',
215
- summary: '浏览器授权一次,把 30 天令牌存到本地(默认本地回调模式;--device 走设备码)',
219
+ summary: '浏览器授权一次,把 30 天令牌存到本地(默认本地回调模式;设备码仅在本地无法回调/检测到代理时兜底)',
216
220
  usage:
217
221
  'wkgeo login [--scope "site:read site:write billing:self"] [--no-browser] [--open-browser] [--client-id <id>] [--callback-port <n>] [--timeout <秒>]\n' +
218
- 'wkgeo login --device # 设备码模式:不需要回调地址/监听端口,给用户一个短码\n' +
222
+ 'wkgeo login --device # 设备码模式:不需要回调地址/监听端口,给用户一个短码(兜底路径)\n' +
219
223
  'wkgeo login --device --no-wait # ★ 只申请短码就退出(AI 端友好),稍后用下面这条领令牌\n' +
220
224
  'wkgeo login --device-continue # 用上次保存的设备码换票、写完凭据就完成登录\n' +
221
- 'wkgeo login --no-auto-device # **检测到系统代理也不**自动改设备码(强制走回调;CLI 会警告)\n' +
225
+ 'wkgeo login --no-auto-device # 检测到系统代理也不自动改设备码(强制回调;CLI 会警告;端口真起不来也不兜底)\n' +
222
226
  'wkgeo login --open-browser # 强制尝试打开系统浏览器(⚠️ 现在与默认行为等价,保留只为显式语义与兼容)\n' +
223
- 'wkgeo login --callback-first-wait 60 # 首轮等待 60 秒(★ 只在检测到代理时才有默认值 180;0/off = 关掉这个自愈)\n' +
227
+ 'wkgeo login --callback-first-wait 60 # ★ 可选:显式启用"首轮等待 60 秒没回调就并行改设备码"(默认关闭;0/off = 关闭)\n' +
224
228
  'wkgeo login --timeout 600 # 等待授权 600 秒(默认单位是秒,不是毫秒;也支持 600s / 500ms)',
225
229
  flags: {
226
230
  browser: { type: 'boolean', desc: '是否自动打开浏览器(★ 默认总是尝试打开:终端与 AI 场景都一样;--no-browser 关闭)' },
@@ -233,7 +237,7 @@ export const loginCommand = {
233
237
  * ⚠️ 与 `--no-browser` 同时出现时 **--no-browser 优先**(关掉就是关掉,见 resolveBrowserIntent)。
234
238
  */
235
239
  'open-browser': { type: 'boolean', desc: '强制尝试打开系统浏览器(⚠️ 现在与默认行为等价,保留只为显式语义与兼容)' },
236
- device: { type: 'boolean', desc: '用设备码模式(RFC 8628)代替本地回调:不需要任何回调地址,对代理/防火墙免疫' },
240
+ device: { type: 'boolean', desc: '用设备码模式(RFC 8628)代替本地回调:不需要任何回调地址,对代理/防火墙免疫(本地读不了回调时的兜底)' },
237
241
  /**
238
242
  * ★ 老板 2026-10-02 要的"AI 端友好路径"。
239
243
  * 为什么做成"申请完就退出"而不是"申请完继续挂着轮询"(后者靠 JSONL 事件流也能做):
@@ -246,20 +250,20 @@ export const loginCommand = {
246
250
  'device-continue': { type: 'boolean', desc: '继续上一次 --no-wait/被中断的设备码登录(读取本地保存的设备码并轮询到出结果)' },
247
251
  'auto-device': {
248
252
  type: 'boolean',
249
- desc: '检测到系统代理时自动改用设备码(**默认开**;--no-auto-device 或 WKONGAI_AUTO_DEVICE=0 关闭,关闭时会警告"代理可能吃掉回调")',
253
+ desc: '检测到系统代理时自动改用设备码(**默认开**;--no-auto-device 或 WKONGAI_AUTO_DEVICE=0 关闭,关闭时会警告"代理可能吃掉回调",且端口真起不来时也不兜底)',
250
254
  },
251
255
  /**
252
- * ★ 第二条保险(见 login.mjs 的 DEFAULT_CALLBACK_FIRST_WAIT_MS)。
256
+ * ★ 可选自愈(默认**关闭**,0.2.2 起)。
253
257
  * 默认单位**秒**,与 `--timeout` 一致(人类写等待窗口都按秒想);
254
- * `0` / `off` / `none` = 关掉自愈,回到"只等整段 --timeout"的旧行为。
258
+ * `0` / `off` / `none` = 关闭(默认值就是关闭)。
255
259
  * 环境变量 `WKONGAI_CALLBACK_FIRST_WAIT_MS` 是毫秒(与 `WKONGAI_LOGIN_TIMEOUT_MS` 同口径)。
256
260
  *
257
- * ⚠️ 2026-10-03 起:**不传它时默认值是"看有没有代理"** —— 有代理才是 180 秒,没有代理是 0
258
- * (老板真机反馈:没有开任何 VPN 也被切成了设备码)。显式传值时照旧尊重用户。
261
+ * ⚠️ 2026-10-21(老板反馈):**默认不再自动改设备码** —— 不写它时无论等多久都只走回调,
262
+ * 超时只报错。只有显式写它,才会在首轮等待到点后并发启动设备码(显式优先)。
259
263
  */
260
264
  'callback-first-wait': {
261
265
  type: 'string',
262
- desc: '回调模式的**首轮等待**(默认单位秒;★ 只在检测到系统代理时默认 180 秒,无代理默认不启用):这段时间没等到回调就自动改用设备码继续(本地回调监听会同时保留);0/off 关闭该自愈',
266
+ desc: '★ 可选自愈(默认**关闭**,默认单位秒):显式写它才会在"首轮等待"到点后并行改用设备码继续(本地回调监听同时保留);不写它时**不会**因为等待超时自动改设备码;0/off 关闭',
263
267
  },
264
268
  /**
265
269
  * 显式声明一次(`timeout` 本来就在 GLOBAL_FLAGS 里),只为让 `wkgeo login --help`
@@ -320,6 +324,12 @@ export const loginCommand = {
320
324
  const proxyInfo = !wantsDevice && !wantsContinue ? ctx.proxyInfo() : null;
321
325
  const proxyPayload = proxyInfo ? proxyWarningPayload(proxyInfo, { cmdName }) : null;
322
326
  const autoDevice = resolveAutoDevice(flags, ctx.env);
327
+ /**
328
+ * ★ 可选首轮等待(默认 0 = 关闭,0.2.2 起)。
329
+ * 只有用户**显式**写 `--callback-first-wait` / `WKONGAI_CALLBACK_FIRST_WAIT_MS`
330
+ * 才会启用;`--no-auto-device` 强制 0。它**不参与**"默认走什么模式"的判断。
331
+ */
332
+ const callbackFirstWaitMs = resolveCallbackFirstWaitMs(flags, ctx.env, { autoDevice });
323
333
 
324
334
  /**
325
335
  * ★★ 核心规则(2026-10-02 第二版加固):**`detected === true` 就直接走设备码**。
@@ -335,7 +345,7 @@ export const loginCommand = {
335
345
  */
336
346
  const proxyDetected = !!(proxyInfo && proxyInfo.detected);
337
347
  let useDevice = wantsDevice;
338
- /** 为什么会走设备码:proxy_detected / callback_first_wait / callback_timeout / callback_listen_failed */
348
+ /** 为什么会走设备码:proxy_detected / callback_listen_failed / callback_first_wait(仅显式开启时) */
339
349
  let downgradeReason = null;
340
350
  if (!useDevice && autoDevice && proxyDetected) {
341
351
  useDevice = true;
@@ -380,15 +390,14 @@ export const loginCommand = {
380
390
  noBrowser: flags.browser === false,
381
391
  platform: ctx.platform,
382
392
  /**
383
- * ★ 第二条保险:回调模式的**首轮等待**。
393
+ * ★ 可选首轮等待(见上面 `callbackFirstWaitMs`)。
384
394
  *
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` → 照旧尊重;
395
+ * ⚠️⚠️ 0.2.2 起**默认 0 = 关闭**(老板 2026-10-21 反馈):
396
+ * * 不写 `--callback-first-wait` / 环境变量 → 0,一直等满 `--timeout`,**不会**自动改设备码;
397
+ * * 显式写 `--callback-first-wait <秒>` / `WKONGAI_CALLBACK_FIRST_WAIT_MS` → 照旧启用;
389
398
  * * `--no-auto-device` → 强制 0(用户明确要求只走回调,就不能偷偷改道)。
390
399
  */
391
- callbackFirstWaitMs: resolveCallbackFirstWaitMs(flags, ctx.env, { autoDevice, proxyDetected }),
400
+ callbackFirstWaitMs,
392
401
  /**
393
402
  * ★★ 设备码先成功后,本地回调监听再多留多久(毫秒;0 = 立刻收尾)。
394
403
  * 生产不传 = `login.mjs` 的 `DEFAULT_POST_DEVICE_GRACE_MS`;
@@ -398,19 +407,23 @@ export const loginCommand = {
398
407
  };
399
408
 
400
409
  /**
401
- * ★ 无代理时的一句说明(老板真机反馈的直接回应)。
410
+ * ★ 默认模式的正面说明(老板 2026-10-21 反馈的直接回应)。
402
411
  *
403
- * 为什么值得打这一行:他这次的困惑正是"**我没有开代理,为什么变成设备码了**"。
404
- * 明说"这次不会因为首轮等待就自动改设备码"能直接消掉这个困惑;
405
- * 同时也要说清"整段 --timeout 用完仍可能改"(那是既有的兜底语义,本次没有改动)。
406
- * ⚠️ 只在"回调模式 + 没检测到代理 + 没开自愈"时打:其它情况一律不打(正常环境不要噪音)。
412
+ * 老板原话:「默认的也不应该是设备授权,默认的就是那个传统的授权方式(浏览器打开授权页 +
413
+ * 本地 localhost 回调)。设备码是本地读不了/代理时才用的。」
414
+ * ⇒ 这里明说"本次就是回调、等多久都不会自动改设备码",同时列出**唯一会走设备码**的三种情况,
415
+ * 并给出"主动提前切换"的显式开关,免得用户以为 CLI 背着他改道。
416
+ * ⚠️ 只在"回调模式 + 没检测到代理 + 没显式开首轮等待"时打:其它情况一律不打(正常环境不要噪音)。
407
417
  */
408
- if (!useDevice && !wantsContinue && !proxyDetected && loginDeps.callbackFirstWaitMs === 0) {
418
+ if (!useDevice && !wantsContinue && !proxyDetected && callbackFirstWaitMs === 0) {
409
419
  log(
410
- `ℹ️ 未检测到系统代理:本次会一直等你完成授权(最多 ${Math.round(config.loginTimeoutMs / 1000)} 秒),` +
411
- '不会因为"首轮等待"到点就自动改用设备码。',
420
+ `ℹ️ 未检测到系统代理:本次使用浏览器回调授权(默认),会一直等你完成授权(最多 ${Math.round(config.loginTimeoutMs / 1000)} 秒)。`,
412
421
  );
413
- log(' (想主动限制首轮等待:`--callback-first-wait <秒>`;想强制设备码:`--device`。)');
422
+ log(
423
+ ' 不会因为"首轮等待/等待超时"到点就自动改用设备码;只有显式 `--device`、检测到系统代理、' +
424
+ '或本地回调端口真的起不来时才会用设备码(`--no-auto-device` 可关掉后两种自动兜底)。',
425
+ );
426
+ log(' (想主动提前切换:`--callback-first-wait <秒>`;想直接走设备码:`--device`。)');
414
427
  }
415
428
 
416
429
  /**
@@ -434,7 +447,7 @@ export const loginCommand = {
434
447
 
435
448
  const pendingFile = pendingDeviceFile(config.credentialsFile);
436
449
  /** 申请到码之后立刻落盘:进程被 Ctrl+C / 终端被关掉,用户还能用 --device-continue 续上 */
437
- const 保存待完成 = (device) => {
450
+ const savePending = (device) => {
438
451
  try {
439
452
  savePendingDevice(pendingFile, { ...device, base: config.base, client_id: config.clientId });
440
453
  return true;
@@ -494,7 +507,7 @@ export const loginCommand = {
494
507
  if (!result && useDevice) {
495
508
  try {
496
509
  const device = await requestDeviceCode({ config, deps: loginDeps });
497
- 保存待完成(device);
510
+ savePending(device);
498
511
  if (noWait) {
499
512
  /**
500
513
  * ★ AI 端友好路径:打印完就退出(不轮询)。
@@ -561,10 +574,10 @@ export const loginCommand = {
561
574
  }
562
575
  }
563
576
 
564
- /* ---- 2c. 本地回调模式(含"回调没等到 → 并发改设备码",★ 两条路并存) ---- */
577
+ /* ---- 2c. 本地回调模式(默认路径;显式首轮等待时才与设备码两条路并存) ---- */
565
578
  if (!result && !useDevice) {
566
579
  /**
567
- * ★★ 2026-10-03(老板真机反馈 ⚠️):「改用设备码」**不再关掉本地回调监听**。
580
+ * ★★ 2026-10-03(老板真机反馈 ⚠️):显式开启的"首轮等待"到点改道时**不再关掉本地回调监听**。
568
581
  *
569
582
  * ## 旧行为(真机证据:ERR_CONNECTION_REFUSED)
570
583
  * 首轮等待到点 → `runLogin` 抛错,并在自己的 `finally` 里**关掉本地 server** → 命令层再走设备码。
@@ -572,15 +585,17 @@ export const loginCommand = {
572
585
  * `http://127.0.0.1:<port>/callback?code=…` → **ERR_CONNECTION_REFUSED**
573
586
  * (老板真机截图见过)→ 用户以为授权失败,再来一遍(而其实他马上就会登录成功)。
574
587
  *
575
- * ## 新行为:两条路**并存**,哪个先拿到令牌就用哪个(先到先得)
588
+ * ## 新行为(仅显式 `--callback-first-wait` 时):两条路**并存**,哪个先拿到令牌就用哪个
576
589
  * * 回调那条路:本地 server 一直开着(`runLogin` 收到 `handoff` 时到点**不结束等待**);
577
590
  * * 设备码那条路:由下面的 `handoff.onSwitch` 启动,轮询到出结果为止。
578
591
  * * 回调先成功 → 把设备码那条路**优雅停掉**(唤醒它挂着的 sleep + 删掉待完成状态文件);
579
592
  * * 设备码先成功 → `finishWithDevice()` 让回调那条路收尾(监听再多留几秒,
580
593
  * 迟到的回调会拿到"本次登录已经完成"的页面,见 login.mjs / page.mjs)。
594
+ * ★ 0.2.2 起默认**不启用**这套交接(`--callback-first-wait` 未显式给出时
595
+ * `callbackFirstWaitMs === 0`):默认就是一路等回调,超时只报错。
581
596
  */
582
- /** 允许"自动改道"吗:用户没写 --no-auto-device,也不是显式设备码模式 */
583
- const mayAutoSwitch = autoDevice && !wantsDevice && !wantsContinue;
597
+ /** 允许"等待到点改道"吗:只有用户**显式**开了首轮等待,且没写 --no-auto-device、不是显式设备码模式 */
598
+ const mayAutoSwitch = autoDevice && !wantsDevice && !wantsContinue && callbackFirstWaitMs > 0;
584
599
  /** 设备码那条路的可取消状态(回调先赢时要把它收拾干净) */
585
600
  const deviceSide = { reason: null, cancelled: false, promise: null, result: null, error: null };
586
601
  /** ★ 可被立刻叫醒的 sleep:回调先赢时,不能让设备码那个 5 秒定时器把进程吊住 */
@@ -588,11 +603,11 @@ export const loginCommand = {
588
603
  /** 交接面:两边各填一半(`onSwitch` 由这里填,另两个由 runLogin 填;见 login.mjs 的 deps 说明) */
589
604
  const handoff = { onSwitch: null, finishWithDevice: null, abort: null };
590
605
 
591
- const 跑设备码那条路 = async () => {
606
+ const runDeviceCodeFlow = async () => {
592
607
  const device = await requestDeviceCode({ config, deps: loginDeps });
593
608
  // 回调那条路已经赢了 → 一个用户可见的动作都不要再做(不打印短码、不弹浏览器、不落盘)
594
609
  if (deviceSide.cancelled) throw deviceStoppedError();
595
- 保存待完成(device);
610
+ savePending(device);
596
611
  if (deviceSide.cancelled) throw deviceStoppedError();
597
612
  announceDeviceCode({ config, log, deps: deviceDeps({ no_wait: false, proxy_warning: proxyPayload }), device });
598
613
  return pollDeviceToken({
@@ -616,7 +631,7 @@ export const loginCommand = {
616
631
  * 成功 → `finishWithDevice` 让回调流程带着这份结果收尾(监听留 grace);
617
632
  * 失败 → `abort` 让回调流程用"两条路都不通"的错误结束(用户要看的是真原因)。
618
633
  */
619
- deviceSide.promise = 跑设备码那条路().then(
634
+ deviceSide.promise = runDeviceCodeFlow().then(
620
635
  (res) => {
621
636
  deviceSide.result = res;
622
637
  if (typeof handoff.finishWithDevice === 'function') handoff.finishWithDevice(res);
@@ -664,50 +679,39 @@ export const loginCommand = {
664
679
  });
665
680
  } catch (e) {
666
681
  /** ★ 无论怎么失败,都先把设备码那条路收拾干净(不能留一个还在轮询的后台任务) */
667
- await 停掉设备码那条路(deviceSide, cancelGate);
682
+ await stopDeviceCodeFlow(deviceSide, cancelGate);
668
683
  /** 设备码那条路也失败了 ⇒ 优先报"两条路都不通"(哪怕回调那边先抛了自己的错) */
669
684
  if (deviceSide.combinedError) throw deviceSide.combinedError;
670
685
  /**
671
- * ★ 「回调失败也自动降级」(老板 2026-10-02 要求"不要直接失败退出")。
672
- * 走得到这里的是**没有启用交接**(`--no-auto-device`)或"连端口都起不来"的两种情况:
673
- * * `listen` —— 连回环端口都起不来(被安全软件/防火墙拦):
674
- * 此时**根本没有监听可保留**,只能老老实实改走设备码;
675
- * * `callback_first_wait` / `timeout` —— 只可能出现在没传 `handoff.onSwitch` 时(旧契约兜底)。
676
- * 其它错误(换票失败、被拒绝、state 不匹配…)**不降级**:那些是"授权已经走到下一步了",
677
- * 再来一遍设备码只会让用户做重复劳动(甚至以为被耍了)—— 这条语义**本次没有改动**。
686
+ * ★★ 0.2.2 起:**只有"本地回调端口真的起不来"(`kind = 'listen'`)才自动兜底设备码**。
687
+ * (老板:「设备码是本地读不了/代理时才用的」—— 连监听都起不来就是"本地读不了"。)
688
+ * * `listen` —— 回环端口起不来(被安全软件/防火墙拦 / 端口非法):
689
+ * 此时**根本没有监听可保留**,只能老老实实改走设备码;
690
+ * * `timeout` / `callback_first_wait` —— **不再自动改道**:
691
+ * 等待超时只如实报错(默认就是回调,不该因为等太久变设备码)。
692
+ * * `--no-auto-device` 时**连 listen 兜底也关闭**(`canSwitch=false`)。
693
+ * 其它错误(换票失败、被拒绝、state 不匹配…)本来就不降级。
678
694
  */
679
- const canSwitch =
680
- autoDevice && !wantsDevice && (e.kind === 'callback_first_wait' || e.kind === 'timeout' || e.kind === 'listen');
695
+ const canSwitch = autoDevice && !wantsDevice && e.kind === 'listen';
681
696
  if (!canSwitch) throw e;
682
697
  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
- }
698
+ log(`⚠️ 本地回调端口起不来(${e.message}):这是"本地读不了回调"的真实失败,改用【设备码模式】继续授权。`);
699
+ log('ℹ️ 设备码不需要本地监听(常见原因是端口被安全软件/防火墙拦);授权页照旧点「同意」即可。');
691
700
  let device;
692
701
  try {
693
702
  device = await requestDeviceCode({ config, deps: loginDeps });
694
703
  } catch (e2) {
695
- throw bothPathsFailedError(e2, e.kind === 'timeout' ? 'callback_timeout' : 'callback_first_wait', {
704
+ throw bothPathsFailedError(e2, 'callback_listen_failed', {
696
705
  loginTimeoutMs: config.loginTimeoutMs,
697
706
  });
698
707
  }
699
- 保存待完成(device);
708
+ savePending(device);
700
709
  announceDeviceCode({ config, log, deps: deviceDeps({ no_wait: false, proxy_warning: proxyPayload }), device });
701
710
  // 这一次再失败就如实抛出(不无限重试:两条路都失败,用户该看到真原因了)
702
711
  result = await pollDeviceToken({ config, log, deps: loginDeps, device });
703
712
  deletePendingDevice(pendingFile);
704
713
  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';
714
+ downgradeReason = 'callback_listen_failed';
711
715
  }
712
716
 
713
717
  if (deviceSide.reason) {
@@ -722,7 +726,8 @@ export const loginCommand = {
722
726
  * 这里只要把"最终模式/原因"如实记下来。
723
727
  */
724
728
  useDevice = true;
725
- downgradeReason = deviceSide.reason === 'callback_timeout' ? 'callback_timeout' : 'callback_first_wait';
729
+ // 0.2.2 起整段超时不再交接,`deviceSide.reason` 只可能是显式首轮等待
730
+ downgradeReason = 'callback_first_wait';
726
731
  } else {
727
732
  /**
728
733
  * ★ 回调那条路**先**成功(本次修复的核心价值:用户后来点的那次【同意】真的换到了令牌)
@@ -730,7 +735,7 @@ export const loginCommand = {
730
735
  */
731
736
  deviceSide.cancelled = true;
732
737
  cancelGate.wakeAll();
733
- await 停掉设备码那条路(deviceSide, cancelGate);
738
+ await stopDeviceCodeFlow(deviceSide, cancelGate);
734
739
  deletePendingDevice(pendingFile);
735
740
  log('ℹ️ 本地回调已经拿到令牌:设备码那条路已停止(刚才那个短码作废,不用再去浏览器里确认)。');
736
741
  }
@@ -775,9 +780,9 @@ export const loginCommand = {
775
780
  if (downgradeReason === 'proxy_detected')
776
781
  out.info(' (本次因"检测到系统代理"自动改用了设备码模式:不再依赖 127.0.0.1 回调,绕过列表写了什么也不作依据。)');
777
782
  if (downgradeReason === 'callback_first_wait')
778
- out.info(' (回调模式首轮等待没收到回调,已自动改用设备码模式完成登录。)');
779
- if (downgradeReason === 'callback_timeout' || downgradeReason === 'callback_listen_failed')
780
- out.info(' (本地回调没成功,已自动改用设备码模式完成登录。)');
783
+ out.info(' (已按显式 --callback-first-wait:首轮等待没收到回调,已自动改用设备码模式完成登录。)');
784
+ if (downgradeReason === 'callback_listen_failed')
785
+ out.info(' (本地回调端口起不来,已自动改用设备码模式完成登录。)');
781
786
  }
782
787
  return data;
783
788
  },
@@ -903,8 +908,25 @@ export const configCommand = {
903
908
  */
904
909
  const proxy = proxyWarningPayload(ctx.proxyInfo(), { cmdName: config.cmdName || 'wkgeo' });
905
910
 
911
+ /**
912
+ * 本地"当前站点"(`wkgeo site use` 选的)。
913
+ * ⚠️ 诊断命令**不能**因为一个坏文件就整体失败:读失败时如实报出来,其它信息照常给。
914
+ */
915
+ let cmsSite = null;
916
+ let cmsSiteError = null;
917
+ try {
918
+ cmsSite = ctx.readSiteState();
919
+ } catch (e) {
920
+ cmsSiteError = (e && e.message) || String(e);
921
+ }
922
+
906
923
  const data = {
907
924
  base: config.base,
925
+ /**
926
+ * CMS 面(栏目/内容/素材/模板)是**另一个云函数、另一个域名**,
927
+ * 所以单独列一行 —— 排查"CMS 命令为什么 404"时第一眼看的就是它。
928
+ */
929
+ cms_base: config.cmsBase,
908
930
  /**
909
931
  * ⚠️ `web_base` 是**兼容字段**:三站拆分后它指向老站(管理端),
910
932
  * 已经没有任何代码用它打开页面 —— 保留只为不破坏既有自动化脚本。
@@ -920,6 +942,10 @@ export const configCommand = {
920
942
  callback: `http://${config.callbackHost}:${config.callbackPort}${config.callbackPath}`,
921
943
  http_timeout_ms: config.httpTimeoutMs,
922
944
  login_timeout_ms: config.loginTimeoutMs,
945
+ /** 本地"当前站点"(CMS 命令用;`site use` 写的) */
946
+ cms_site: cmsSite,
947
+ cms_site_file: config.siteStateFile,
948
+ cms_site_error: cmsSiteError,
923
949
  sources,
924
950
  logged_in: !!creds,
925
951
  expired: creds ? isExpired(creds, { now: ctx.now() }) : null,
@@ -934,6 +960,7 @@ export const configCommand = {
934
960
  out.head('生效配置');
935
961
  for (const [k, v] of Object.entries({
936
962
  'API 基址': data.base,
963
+ 'CMS 接口基址': data.cms_base,
937
964
  '授权站基址': data.oauth_web_base,
938
965
  '收银台基址': data.pay_web_base,
939
966
  '会员站基址': data.user_web_base,
@@ -945,6 +972,15 @@ export const configCommand = {
945
972
  })) {
946
973
  out.line(` ${k}:${v}`);
947
974
  }
975
+ out.line(
976
+ ` 当前站点:${
977
+ data.cms_site && data.cms_site.id
978
+ ? `${data.cms_site.label ? `${data.cms_site.label}(` : ''}${data.cms_site.id}${data.cms_site.label ? ')' : ''}`
979
+ : data.cms_site_error
980
+ ? `⚠️ 读取失败:${data.cms_site_error}`
981
+ : '未选(CMS 命令前先 `wkgeo site use <站点ID或域名>`)'
982
+ }`,
983
+ );
948
984
  out.line(` 登录状态:${data.logged_in ? (data.expired ? '已过期(需重新 login)' : '已登录') : '未登录'}`);
949
985
  if (data.logged_in) {
950
986
  out.line(` 账号:${(data.account && (data.account.email || data.account.user_id)) || '-'}`);