wkgeo-cli 0.2.2 → 0.2.4

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/login.mjs CHANGED
@@ -40,6 +40,7 @@
40
40
 
41
41
  import http from 'node:http';
42
42
  import { spawn } from 'node:child_process';
43
+ import { win32 as pathWin32 } from 'node:path';
43
44
  import { URL } from 'node:url';
44
45
 
45
46
  import { createApi } from './api.mjs';
@@ -99,6 +100,63 @@ import { CliError, EXIT } from './errors.mjs';
99
100
  * ② 虽是"用户双击"路径、命中率最高,却带上面那个残余解析风险,所以只在前一条不可用时
100
101
  * 才启用;③ 收尾。这样单条失败**不再等于**整个自动打开失败(这正是这次要修的问题)。
101
102
  */
103
+ /**
104
+ * ★★ 2026-10-21(老板真机反馈「Application not found,浏览器没打开」):Windows 默认处理器
105
+ * 坏了的时候,**不能只依赖 rundll32 / cmd start / powershell**——它们可能报告"应用未找到"
106
+ * 却仍然以退出码 0 结束(静默失败),于是后面的策略根本不会被尝试。
107
+ *
108
+ * ⇒ 在三条通用策略之后追加 **Chrome / Edge 的绝对路径**(不经过任何 shell、不依赖文件关联)。
109
+ * 路径覆盖 32/64 位安装位置与用户级安装(LOCALAPPDATA);某个路径不存在时 spawn 会立刻
110
+ * ENOENT,`openInBrowserWithFallback` 会静默试下一条,不会打扰用户。
111
+ *
112
+ * ⚠️ 顺序保持"默认处理器优先":用户平时登录态在默认浏览器里,直接抢开 Chrome 会让他
113
+ * 重新登录一遍(体验更差)。所以绝对路径只能排在通用策略**之后**当兜底。
114
+ * ⚠️ 这里用 `path.win32.join` 而不是 `path.join`:在非 Windows 上跑单测时也必须拼出
115
+ * Windows 风格路径,否则断言会随运行平台变化。
116
+ *
117
+ * @param {object} [env] 环境变量(默认 process.env;单测可注入确定性的路径)
118
+ * @returns {Array<{label:string, cmd:string, args:(url:string)=>string[]}>}
119
+ */
120
+ export function windowsAbsoluteBrowserStrategies(env = process.env) {
121
+ const source = env && typeof env === 'object' ? env : {};
122
+ const programFiles = source.ProgramFiles || source.PROGRAMFILES || 'C:\\Program Files';
123
+ const programFilesX86 = source['ProgramFiles(x86)'] || source['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)';
124
+ const localAppData = source.LOCALAPPDATA || source.LocalAppData || '';
125
+ /** 安装根 + 诊断标签:先 Program Files / x86,再用户级 LOCALAPPDATA */
126
+ const roots = [
127
+ { label: 'Program Files', root: programFiles },
128
+ { label: 'Program Files (x86)', root: programFilesX86 },
129
+ ];
130
+ if (localAppData) roots.push({ label: 'LOCALAPPDATA', root: localAppData });
131
+ const strategies = [];
132
+ // Chrome 在前(老板/大多数国内用户桌面 Chrome 命中率最高),Edge 在后
133
+ for (const { label, root } of roots) {
134
+ strategies.push({
135
+ label: `chrome · ${label}`,
136
+ cmd: pathWin32.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),
137
+ args: (url) => [url],
138
+ });
139
+ }
140
+ for (const { label, root } of roots) {
141
+ strategies.push({
142
+ label: `edge · ${label}`,
143
+ cmd: pathWin32.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
144
+ args: (url) => [url],
145
+ });
146
+ }
147
+ return strategies;
148
+ }
149
+
150
+ /**
151
+ * 通用 Windows 启动器"其实没打开"的输出特征(老板真机报的就是 `Application not found`)。
152
+ *
153
+ * 为什么要看输出而不是只看退出码:`rundll32` / `cmd start` 在文件关联坏掉时可能**退出码 0**,
154
+ * 但 stderr/stdout 里已经写了找不到应用。只信退出码就会把它当成功,绝对路径兜底永远不会触发
155
+ * ——这正是这次要修的那条真机故障。见 `launchStrategy` 的 captureOutput 分支。
156
+ */
157
+ const WINDOWS_HANDLER_FAILURE_PATTERN =
158
+ /application not found|windows cannot find|cannot find the file|系统找不到|找不到应用程序|不是内部或外部命令|no application is associated/i;
159
+
102
160
  export const BROWSER_STRATEGY_TABLE = {
103
161
  /**
104
162
  * ⚠️⚠️ Windows 第 ② 条(`cmd /c start`)的**引号**是必须的(老板真机踩过,见上面那段记录):
@@ -116,11 +174,31 @@ export const BROWSER_STRATEGY_TABLE = {
116
174
  */
117
175
  win32: [
118
176
  // ① 最稳的一条:不经任何 shell,URL 作为独立 argv 逐字符原样交给系统(线上实测能弹浏览器的那条)
119
- { label: 'rundll32 url.dll,FileProtocolHandler', cmd: 'rundll32', args: (url) => ['url.dll,FileProtocolHandler', url] },
177
+ {
178
+ label: 'rundll32 url.dll,FileProtocolHandler',
179
+ cmd: 'rundll32',
180
+ args: (url) => ['url.dll,FileProtocolHandler', url],
181
+ captureOutput: true,
182
+ failureOutputPattern: WINDOWS_HANDLER_FAILURE_PATTERN,
183
+ },
120
184
  // ② 兜底:命令与引号写法**保持原样**(引号必须在字符串里);它要经 cmd 解析,存在"退出码 0 却截断 URL"的残余风险,所以不排第一
121
- { label: 'cmd /c start', cmd: 'cmd', args: (url) => ['/c', `start "" "${url}"`] },
185
+ {
186
+ label: 'cmd /c start',
187
+ cmd: 'cmd',
188
+ args: (url) => ['/c', `start "" "${url}"`],
189
+ captureOutput: true,
190
+ failureOutputPattern: WINDOWS_HANDLER_FAILURE_PATTERN,
191
+ },
122
192
  // ③ 前两条都哑了时的最后一条
123
- { label: 'powershell Start-Process', cmd: 'powershell', args: (url) => ['-NoProfile', '-Command', `Start-Process '${url}'`] },
193
+ {
194
+ label: 'powershell Start-Process',
195
+ cmd: 'powershell',
196
+ args: (url) => ['-NoProfile', '-Command', `Start-Process '${url}'`],
197
+ captureOutput: true,
198
+ failureOutputPattern: WINDOWS_HANDLER_FAILURE_PATTERN,
199
+ },
200
+ // ④~⑨ 默认处理器/文件关联坏掉时的绝对路径兜底(Chrome / Edge,见 windowsAbsoluteBrowserStrategies)
201
+ ...windowsAbsoluteBrowserStrategies(),
124
202
  ],
125
203
  darwin: [
126
204
  { label: 'open', cmd: 'open', args: (url) => [url] },
@@ -191,9 +269,21 @@ export function strategiesFor(platform, strategies) {
191
269
  return BROWSER_STRATEGY_TABLE[platform] || FALLBACK_STRATEGIES;
192
270
  }
193
271
 
194
- /** 子进程的固定选项:不继承 stdio、独立进程组(CLI 退出后浏览器继续活着)、不闪黑框 */
195
- function browserSpawnOptions() {
196
- return { stdio: 'ignore', detached: true, windowsHide: true };
272
+ /**
273
+ * 子进程的固定选项:独立进程组(CLI 退出后浏览器继续活着)、不闪黑框。
274
+ *
275
+ * ★ 0.2.4:只有**异步多策略**链路里、且策略声明了 `captureOutput` 时才接管输出
276
+ * (用来识别 Windows 通用启动器"退出码 0 但输出 Application not found"的静默失败)。
277
+ * 同步版 `openInBrowser`(device.mjs 用)必须保持 `stdio:'ignore'` —— 它不消费管道,
278
+ * 接管输出反而可能把子进程的管道缓冲区写满。
279
+ */
280
+ function browserSpawnOptions(strategy = null, { captureOutput = false } = {}) {
281
+ const capture = captureOutput === true && strategy && strategy.captureOutput === true;
282
+ return {
283
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'ignore',
284
+ detached: true,
285
+ windowsHide: true,
286
+ };
197
287
  }
198
288
 
199
289
  /**
@@ -333,16 +423,30 @@ function launchStrategy(strategy, target, spawnImpl, timeoutMs) {
333
423
  return new Promise((resolve) => {
334
424
  let settled = false;
335
425
  let timer = null;
426
+ /** ★ 0.2.4:Windows 通用启动器的输出(最多留 4KB,只为识别"Application not found") */
427
+ let output = '';
428
+ let child = null;
336
429
  const settle = (result) => {
337
430
  if (settled) return;
338
431
  settled = true;
339
432
  if (timer) clearTimeout(timer);
433
+ // 超时/退出后把管道关掉:否则 pipe 句柄可能把 CLI 的事件循环多吊一会儿
434
+ for (const stream of [child && child.stdout, child && child.stderr]) {
435
+ try {
436
+ if (stream && typeof stream.destroy === 'function') stream.destroy();
437
+ } catch {
438
+ /* 清理失败不影响结论 */
439
+ }
440
+ }
340
441
  resolve(result);
341
442
  };
342
443
 
343
- let child;
344
444
  try {
345
- child = spawnImpl(strategy.cmd, strategy.args(target), browserSpawnOptions());
445
+ child = spawnImpl(
446
+ strategy.cmd,
447
+ strategy.args(target),
448
+ browserSpawnOptions(strategy, { captureOutput: strategy.captureOutput === true }),
449
+ );
346
450
  } catch (e) {
347
451
  resolve({ ok: false, error: e });
348
452
  return;
@@ -352,6 +456,20 @@ function launchStrategy(strategy, target, spawnImpl, timeoutMs) {
352
456
  return;
353
457
  }
354
458
 
459
+ if (strategy.captureOutput === true) {
460
+ const collect = (chunk) => {
461
+ if (output.length >= 4096) return;
462
+ output += String(chunk).slice(0, 4096 - output.length);
463
+ };
464
+ for (const stream of [child.stdout, child.stderr]) {
465
+ if (stream && typeof stream.on === 'function') {
466
+ // 流错误(管道提前关闭等)不能变成未捕获异常把 CLI 崩掉
467
+ stream.on('error', () => {});
468
+ stream.on('data', collect);
469
+ }
470
+ }
471
+ }
472
+
355
473
  timer = setTimeout(() => settle({ ok: true, timedOut: true }), timeoutMs);
356
474
 
357
475
  if (typeof child.on === 'function') {
@@ -359,7 +477,21 @@ function launchStrategy(strategy, target, spawnImpl, timeoutMs) {
359
477
  child.on('error', (e) => settle({ ok: false, error: e }));
360
478
  // 进程正常退出才算成功:非零退出码意味着"它自己认为没干成"(例如 xdg-open 找不到浏览器)
361
479
  child.on('exit', (code, signal) => {
362
- if (code === 0) return settle({ ok: true });
480
+ if (code === 0) {
481
+ /**
482
+ * ★ 0.2.4:退出码 0 **不等于**浏览器真的打开了 —— Windows 上文件关联坏掉时,
483
+ * rundll32 / cmd start 会打出 `Application not found` 却仍然返回 0。
484
+ * 这里把这种"静默失败"识别出来,交给下一条(Chrome/Edge 绝对路径)兜底。
485
+ */
486
+ const pattern = strategy.failureOutputPattern;
487
+ if (pattern instanceof RegExp && output && pattern.test(output)) {
488
+ return settle({
489
+ ok: false,
490
+ error: new Error(`退出码 0,但输出表明没有打开:${output.trim().replace(/\s+/g, ' ').slice(0, 160)}`),
491
+ });
492
+ }
493
+ return settle({ ok: true });
494
+ }
363
495
  settle({ ok: false, error: new Error(`退出码 ${code}${signal ? `(信号 ${signal})` : ''}`) });
364
496
  });
365
497
  } else {
@@ -388,34 +520,117 @@ function normalizeOpenStatus(raw, url, skippedReason = null) {
388
520
  }
389
521
 
390
522
  /**
391
- * ★★ 「要不要替用户弹**系统**浏览器」—— **全 CLI 唯一的裁决点**。
523
+ * ★★ CI / 非交互环境的判定因子(2026-10-21 老板反馈的直接产物)。
392
524
  *
393
- * ## ★ 老板最终拍板:走**传统路线**(弹系统浏览器),本函数因此回到"默认总是尝试打开"
394
- * 原话:「还是返回以前那种**打开跳到一个浏览器**,然后打开授权那个吧,**大家也都是那个的**。
395
- * 我们在右侧这个……能力还不会太完善,我们这样做也不太好,**还是走传统的路吧**。」
396
- * (A 路线 = CLI 自动弹系统浏览器 ✔ 采用;B 路线 = AI 客户端右侧内置浏览器 ✘ 暂时不做。)
525
+ * ## 真实现场
526
+ * 「别人在 **CI** 里执行 `wkgeo-cli` 的 login,**默认弹出了系统默认浏览器**」
527
+ * —— CI 机器上没有显示器/桌面会话,`rundll32` / `open` / `xdg-open` 要么静默失败,
528
+ * 要么在装了 Xvfb / 桌面残留的 runner 上真的拉起一个窗口(占资源、拖慢、还可能卡住)。
529
+ * CI 里唯一正确的做法是:**只输出授权链接/JSON**,把链接交给有浏览器的人打开。
397
530
  *
398
- * 为什么传统路线更好(写进代码里,免得下次又被"弹窗会不会吓到用户"带偏):
399
- * * **稳定**:各平台都有成熟的启动器(`rundll32` / `open` / `xdg-open`…),
400
- * 而"在 AI 客户端右侧打开"要靠宿主插件 + 客户端配合,能力还**不完善**;
401
- * * **`window.close()` 在独立浏览器里生效**:授权页/收银台完成后的那一屏能**自己关掉**,
402
- * 用户不会留下一个"我是不是没成功"的疑问窗口(内置侧边栏里关不掉,反而更别扭)。
531
+ * ## 判定(满足任一即"非交互")
532
+ * 1. `process.stdout.isTTY` 为假(管道 / 重定向 / AI 子进程 / CI 日志采集);
533
+ * 2. 存在任一 CI 环境变量(**存在即判定**,不看值;列表见 `CI_ENV_VARS`);
534
+ * 3. `WKONGAI_CLI_NO_BROWSER` 为 `1/true/yes/on`(调用方显式声明"我这没有/不要浏览器")。
403
535
  *
404
- * ## 判定(三条,顺序即优先级)
405
- * 1. `--no-browser`(`noBrowser`)→ **不打开**(`skipped_reason: "no_browser"`)。
406
- * ⚠️ 这是 AI 端"我不想弹窗"的**唯一开关**,也压过 `--open-browser`;
407
- * 2. `--open-browser`(`forceOpen`)→ 打开(`mode: "forced"`)。
408
- * ⚠️ 它现在与**默认行为等价**,但**保留**:显式语义 + 兼容既有脚本;
409
- * 3. 默认(其它一切情况)→ **总是尝试打开系统浏览器**:
410
- * * 人在终端(`isTTY === true && json !== true`)→ `mode: "terminal"`;
411
- * * 其余(非 TTY / `--json` = **AI 场景**)→ `mode: "agent"`,★ **同样打开**。
412
- * ⇒ 这是本次的行为变更:`json` / `isTTY` **不再**是"不打开"的理由。
536
+ * ⚠️ 这个函数是**纯函数**(只吃注入的 `isTTY` / `env`),单测可以直接构造任意组合,
537
+ * 不必去改真实进程的 TTY,也不会污染 `process.env`。
413
538
  *
414
- * ## ⚠️ 已弃用的取值:`skipped_reason = "non_tty_agent_mode"`(不再可能出现)
415
- * 2026-10-01 曾按"AI 场景默认不弹、改提示用户去右侧内置浏览器打开"做过一版,
416
- * 那时才会有这个取值。老板最终拍板走传统路线后**它已经消失** ——
417
- * 现在 `skipped_reason` 只有 `"no_browser"` 或 `null` 两种(老文档/老代码看到它就知道是历史值)。
418
- * ⚠️ `open_browser` 的**结构与取值口径没有变**:`{attempted, opened, tried, url, skipped_reason}`。
539
+ * @param {object} [opts]
540
+ * @param {boolean} [opts.isTTY] stdout 是不是终端(生产传 `!!process.stdout.isTTY`)
541
+ * @param {object} [opts.env] 环境变量(生产传 `process.env`;默认空对象)
542
+ * @returns {{nonInteractive:boolean, reason:(string|null)}} `reason` 形如
543
+ * `"stdout_not_tty"` / `"ci_env:GITHUB_ACTIONS"` / `"env:WKONGAI_CLI_NO_BROWSER"`
544
+ */
545
+ export function detectNonInteractive({ isTTY = false, env = {} } = {}) {
546
+ // ① 不是终端:管道、重定向、AI 子进程都走这条(CI 日志采集也通常不是 TTY)
547
+ if (isTTY !== true) return { nonInteractive: true, reason: 'stdout_not_tty' };
548
+ // ② CI 环境变量:存在即判定(值的含义各家不一,`CI=0` 也出现过,不猜)
549
+ const source = env && typeof env === 'object' ? env : {};
550
+ const ciVar = CI_ENV_VARS.find((name) => Object.prototype.hasOwnProperty.call(source, name) && source[name] !== undefined && source[name] !== null);
551
+ if (ciVar) return { nonInteractive: true, reason: `ci_env:${ciVar}` };
552
+ // ③ 调用方显式声明(给"没有 CI 变量但确实在无浏览器环境"的容器/远程场景用)
553
+ const raw = source[NO_BROWSER_ENV];
554
+ if (raw !== undefined && raw !== null && /^(1|true|yes|on)$/i.test(String(raw).trim())) {
555
+ return { nonInteractive: true, reason: `env:${NO_BROWSER_ENV}` };
556
+ }
557
+ return { nonInteractive: false, reason: null };
558
+ }
559
+
560
+ /** CI 环境变量清单(**存在即判定**;顺序只影响诊断文案里报哪一个) */
561
+ export const CI_ENV_VARS = Object.freeze([
562
+ 'CI',
563
+ 'GITHUB_ACTIONS',
564
+ 'GITLAB_CI',
565
+ 'JENKINS_URL',
566
+ 'TF_BUILD',
567
+ 'BUILDKITE',
568
+ 'TEAMCITY_VERSION',
569
+ ]);
570
+
571
+ /** 「显式关掉浏览器」的环境变量(值为 `1/true/yes/on` 时生效;与 `--no-browser` 等价的效果) */
572
+ export const NO_BROWSER_ENV = 'WKONGAI_CLI_NO_BROWSER';
573
+
574
+ /** 把 `detectNonInteractive().reason` 翻译成人话(只用于提示文案,不影响判定) */
575
+ export function nonInteractiveReasonText(reason = '') {
576
+ const text = String(reason || '');
577
+ if (text === 'stdout_not_tty') return 'stdout 不是终端(管道 / 重定向 / AI 子进程)';
578
+ if (text.startsWith('ci_env:')) return `检测到 CI 环境变量 ${text.slice('ci_env:'.length)}`;
579
+ if (text.startsWith('env:')) return `环境变量 ${text.slice('env:'.length)} 已开启`;
580
+ return '非交互环境';
581
+ }
582
+
583
+ /**
584
+ * 非交互时可照做的提示:**不会自动打开浏览器** + 两条出路(转给有浏览器的人 / 改用设备码)。
585
+ *
586
+ * 为什么必须打印:非交互场景(CI / 容器 / AI 子进程)里用户看不到任何窗口,
587
+ * 只拿到一行 URL 很容易以为"CLI 卡住了/什么都没发生"。这段提示把"下一步"说死,
588
+ * 而且明确建议 `--device`(CI 里最可靠:不需要 127.0.0.1 回调)。
589
+ *
590
+ * @param {object} [opts]
591
+ * @param {'auth'|'pay'} [opts.kind]
592
+ * @param {string} [opts.reason] `detectNonInteractive().reason`
593
+ * @param {string} [opts.cmdName]
594
+ * @param {string} [opts.url] 收银台/授权链接(`kind:"pay"` 时打印出来,方便直接复制)
595
+ * @returns {string[]}
596
+ */
597
+ export function nonInteractiveBrowserHintLines({ kind = 'auth', reason = '', cmdName = 'wkgeo', url = '' } = {}) {
598
+ const lead = `ℹ️ 检测到非交互环境(${nonInteractiveReasonText(reason)}):不会自动打开浏览器。`;
599
+ const link = url ? [` ${url}`] : [];
600
+ if (kind === 'pay') {
601
+ return [
602
+ lead,
603
+ ...link,
604
+ ' 本机(CI / 容器 / 远程)通常没有可用浏览器:请把上面的收银台链接交给用户,让他在自己的浏览器里打开并用微信扫码支付。',
605
+ ];
606
+ }
607
+ return [
608
+ lead,
609
+ ' 本机(CI / 容器 / 远程)通常没有可用浏览器:请把上面的授权链接转给有浏览器的用户打开。',
610
+ ` 若本机无法接收 127.0.0.1 回调(CI / 远程环境),改用 \`${cmdName} login --device\`(设备码模式,不需要回调)。`,
611
+ ];
612
+ }
613
+
614
+ /**
615
+ * ★★ 「要不要替用户弹**系统**浏览器」—— **全 CLI 唯一的裁决点**。
616
+ *
617
+ * ## 判定(顺序即优先级;2026-10-21 起加入非交互/CI 检测)
618
+ * 1. `--no-browser`(`noBrowser`,或 `deps.openBrowser === false`)
619
+ * → **不打开**,`skipped_reason: "no_browser"`(压过 `--open-browser`);
620
+ * 2. **非交互 / CI**(`detectNonInteractive`:stdout 非 TTY / CI 环境变量 / `WKONGAI_CLI_NO_BROWSER=1`)
621
+ * → **不打开**,`skipped_reason: "non_interactive"`。
622
+ * ⚠️ 这一条**压过 `--open-browser`**:CI 里绝不允许弹浏览器(老板 2026-10-21 反馈的原话场景)。
623
+ * 要弹就必须在**人类终端**里跑 —— `--open-browser` 只在终端里保留显式语义;
624
+ * 3. `--open-browser`(`forceOpen`)→ 打开(`mode: "forced"`;与终端默认行为等价,保留兼容);
625
+ * 4. 人在终端(`isTTY === true`)→ 打开(`mode: "terminal"`;`--json` 只影响 `mode` 诊断字段);
626
+ * 5. 其余(理论上到不了;兜底仍按 agent 诊断)→ 打开。
627
+ *
628
+ * ## 历史(别再翻回去)
629
+ * * 2026-10-01:曾"AI 场景默认不弹"(`skipped_reason:"non_tty_agent_mode"`)→ 已弃用;
630
+ * * 2026-10-02:老板拍板走传统路线(弹系统浏览器)→ 默认**总是**打开(含 AI 场景);
631
+ * * 2026-10-21:老板真机反馈"CI 里弹了浏览器" ⇒ 恢复"非交互不弹",但**人类终端**保持自动打开。
632
+ * ⇒ 现在 `skipped_reason` 的合法取值是 `"no_browser"` / `"non_interactive"` / `null`。
633
+ * ⚠️ `open_browser` 的**结构没变**:`{attempted, opened, tried, url, skipped_reason}`。
419
634
  *
420
635
  * ## ⚠️ 绝不允许因为"没打开浏览器"让命令失败
421
636
  * 返回 `allow:false` 只是"不弹窗";即使 `allow:true` 而打开失败(`attempted:true, opened:false`),
@@ -423,20 +638,25 @@ function normalizeOpenStatus(raw, url, skippedReason = null) {
423
638
  * 授权/支付流程**照旧继续等回调、继续轮询**,语义完全不变。
424
639
  *
425
640
  * @param {object} [opts]
426
- * @param {boolean} [opts.isTTY] stdout 是不是终端(命令层传 `!!stdout.isTTY`;现在只影响 `mode` 这个诊断字段)
427
- * @param {boolean} [opts.json] 是不是 JSON 输出(同上,只影响 `mode`)
641
+ * @param {boolean} [opts.isTTY] stdout 是不是终端(命令层传 `!!stdout.isTTY`)
642
+ * @param {boolean} [opts.json] 是不是 JSON 输出(只影响 `mode` 诊断字段)
428
643
  * @param {boolean} [opts.forceOpen] 用户写了 `--open-browser`
429
644
  * @param {boolean} [opts.noBrowser] 用户写了 `--no-browser`(或 deps.openBrowser === false)
430
- * @returns {{allow:boolean, mode:'off'|'forced'|'terminal'|'agent', skipped_reason:('no_browser'|null)}}
645
+ * @param {object} [opts.env] 环境变量(CI 判定用;生产传 `process.env`)
646
+ * @returns {{allow:boolean, mode:'off'|'forced'|'terminal'|'agent', skipped_reason:('no_browser'|'non_interactive'|null)}}
431
647
  */
432
- export function resolveBrowserIntent({ isTTY = false, json = false, forceOpen = false, noBrowser = false } = {}) {
648
+ export function resolveBrowserIntent({ isTTY = false, json = false, forceOpen = false, noBrowser = false, env = {} } = {}) {
433
649
  // ① 用户明确关掉:连 `--open-browser` 也不许把它顶回来(--no-browser 优先)
434
650
  if (noBrowser === true) return { allow: false, mode: 'off', skipped_reason: 'no_browser' };
435
- // ② 用户明确要求打开:保留(与默认行为等价,但显式语义与兼容性都要留住)
651
+ // ② 非交互 / CI:**只输出链接**,不 spawn 任何浏览器(压过 --open-browser)
652
+ if (detectNonInteractive({ isTTY, env }).nonInteractive) {
653
+ return { allow: false, mode: 'off', skipped_reason: 'non_interactive' };
654
+ }
655
+ // ③ 用户明确要求打开(已在终端里):保留显式语义与兼容
436
656
  if (forceOpen === true) return { allow: true, mode: 'forced', skipped_reason: null };
437
- // ③ 人在终端:保持 CLI 一直以来的体验(这条**不能**破坏)
657
+ // ④ 人在终端:保持 CLI 一直以来的体验(这条**不能**破坏)
438
658
  if (isTTY === true && json !== true) return { allow: true, mode: 'terminal', skipped_reason: null };
439
- // ④ AI 场景(非 TTY / --json):★ 本次改回来的那一条 —— **同样尝试打开系统浏览器**
659
+ // ⑤ TTY + --json:人还在终端,照旧打开(`json` 只影响 mode 诊断字段)
440
660
  return { allow: true, mode: 'agent', skipped_reason: null };
441
661
  }
442
662
 
@@ -491,8 +711,11 @@ export function browserFailedHintLines(url, { kind = 'auth', agent = false, caus
491
711
  /**
492
712
  * 「照着念」的一句话 —— 给 AI 端直接转述用(AI 不该自己组织语言,否则很容易漏掉网址)。
493
713
  *
494
- * ★ 老板最终拍板走传统路线(弹系统浏览器)之后,**两种场景共用同一句话术**:
495
- * 以"已为你打开浏览器"为主,并把地址**原样带上**(万一本机没弹出来,AI 还能把它交给用户手动打开)。
714
+ * ## 两种口径(**必须与"到底弹没弹"对得上**,否则 AI 会照着念错话)
715
+ * * 交互终端 / 已尝试打开 → "已为你打开浏览器,请在那里完成授权:<url>…";
716
+ * * 非交互(CI / 管道 / AI 子进程,即 `skipped_reason === "non_interactive"`)→
717
+ * "请打开以下链接完成授权:<url>…"
718
+ * —— 这时屏幕上**什么都没弹**,再说"已为你打开"会让用户以为链接坏了。
496
719
  *
497
720
  * @param {string} url
498
721
  * @param {object} [opts]
@@ -500,11 +723,17 @@ export function browserFailedHintLines(url, { kind = 'auth', agent = false, caus
500
723
  * @param {boolean} [opts.agent] ⚠️ **已弃用**(仅为兼容既有调用方而保留):
501
724
  * 以前用它切"请在右侧(内置浏览器)…"的口径;B 路线暂停后两种场景话术已统一,
502
725
  * 这个入参**不再影响输出**。
726
+ * @param {boolean} [opts.nonInteractive] 非交互环境(`skipped_reason === "non_interactive"`)
503
727
  */
504
- export function browserPasteText(url, { kind = 'auth', agent = false } = {}) {
728
+ export function browserPasteText(url, { kind = 'auth', agent = false, nonInteractive = false } = {}) {
505
729
  // 已弃用:见上面的参数说明(保留形参只是不想为了一个布尔值去改所有调用方)
506
730
  void agent;
507
731
  const closingHint = '(完成后页面会自动关闭;没关就手动关掉即可)';
732
+ if (nonInteractive === true) {
733
+ return kind === 'pay'
734
+ ? `请打开以下链接完成支付(微信扫码):${url}${closingHint}`
735
+ : `请打开以下链接完成授权:${url}${closingHint}`;
736
+ }
508
737
  return kind === 'pay'
509
738
  ? `已为你打开浏览器,请在那里完成支付(微信扫码):${url}${closingHint}`
510
739
  : `已为你打开浏览器,请在那里完成授权:${url}${closingHint}`;
@@ -588,15 +817,15 @@ export function defaultOpenBrowserAsync(deps = {}, log = () => {}) {
588
817
  * @param {string} url 要打开的完整网址
589
818
  * @param {object} [opts]
590
819
  * @param {object} [opts.deps] 运行期依赖 `{ openBrowser, platform, spawnImpl, browserStrategies, browserTimeoutMs, verbose,
591
- * isTTY, json, forceOpen, noBrowser }`
820
+ * isTTY, json, forceOpen, noBrowser, env }`
592
821
  * @param {(msg:string)=>void} [opts.log] 诊断日志(`--verbose` 用)
593
- * @returns {Promise<{attempted:boolean, opened:boolean, tried:string[], url:string, skipped_reason:(string|null)}>}
822
+ * @returns {Promise<{attempted:boolean, opened:boolean, tried:string[], url:string, skipped_reason:('no_browser'|'non_interactive'|null)}>}
594
823
  */
595
824
  export async function openUrlForUser(url, { deps = {}, log = () => {} } = {}) {
596
825
  const target = typeof url === 'string' ? url.trim() : '';
597
826
  /**
598
- * ★ 先过**唯一闸门**(见 resolveBrowserIntent):默认**总是尝试打开**系统浏览器
599
- * (人在终端 ✔ + AI 场景 ✔),只有 `--no-browser` 才不打开。
827
+ * ★ 先过**唯一闸门**(见 resolveBrowserIntent):**人在终端默认打开**;
828
+ * `--no-browser` 与 **非交互/CI**(非 TTY / CI 环境变量 / `WKONGAI_CLI_NO_BROWSER=1`)都不打开。
600
829
  * ⚠️ 这里是钱包充值那条链路(login 走 runLogin 里的同一个函数),所以两处行为天然一致 ——
601
830
  * **不允许**在任何命令里自己判断一遍 TTY 再决定要不要 spawn(那就是复制逻辑,
602
831
  * 迟早会出现"授权不弹、支付还弹"这种不一致)。
@@ -606,6 +835,8 @@ export async function openUrlForUser(url, { deps = {}, log = () => {} } = {}) {
606
835
  json: deps.json === true,
607
836
  forceOpen: deps.forceOpen === true,
608
837
  noBrowser: deps.noBrowser === true || deps.openBrowser === false,
838
+ /** ★ CI / 非交互判定(2026-10-21):管道、CI 环境变量、WKONGAI_CLI_NO_BROWSER 都不弹 */
839
+ env: deps.env && typeof deps.env === 'object' ? deps.env : {},
609
840
  });
610
841
  if (!intent.allow) {
611
842
  // 不弹窗 ≠ 失败:地址照常由调用方打印/放进 JSON,流程一丝不变
@@ -660,13 +891,13 @@ function sendHtml(res, status, page) {
660
891
  * @param {object} args.config 已生效的配置(base/clientId/scope/credentialsFile/callback*)
661
892
  * @param {(msg:string)=>void} args.log
662
893
  * @param {object} [args.deps] { fetchImpl, openBrowser, now, listenPort, callbackFirstWaitMs,
663
- * spawnImpl, platform, onAuthorizationUrl, proxyRisk,
894
+ * spawnImpl, platform, onAuthorizationUrl, proxyRisk, env,
664
895
  * ★ isTTY, json, forceBrowser, noBrowser —— 见 resolveBrowserIntent(),
665
896
  * ★ handoff, postDeviceGraceMs —— 见下 }
666
- * ★ `isTTY` / `json` / `forceBrowser`(`--open-browser`)/ `noBrowser`(`--no-browser`)四个字段
667
- * 决定"要不要替用户弹系统浏览器":★ **默认总是尝试打开**(人在终端与 AI 场景都一样),
668
- * 只有 `noBrowser`(`--no-browser`)才不打开;`isTTY` / `json` 现在只影响 `mode` 这个诊断字段
669
- * ——理由见 resolveBrowserIntent()。
897
+ * ★ `isTTY` / `json` / `forceBrowser`(`--open-browser`)/ `noBrowser`(`--no-browser`)/ `env`(CI 判定)
898
+ * 决定"要不要替用户弹系统浏览器":**人在终端默认打开**;`--no-browser` 与 **非交互/CI**
899
+ * (stdout 非 TTY / CI 环境变量 / `WKONGAI_CLI_NO_BROWSER=1`)都不打开;
900
+ * `--open-browser` 只在终端里保留显式语义 —— 理由见 detectNonInteractive() / resolveBrowserIntent()。
670
901
  * `callbackFirstWaitMs` —— 可选的「首轮等待」窗口,**默认 0 = 关闭**(0.2.2 起)。
671
902
  * 只有用户显式写了 `--callback-first-wait` / `WKONGAI_CALLBACK_FIRST_WAIT_MS` 时,
672
903
  * 命令层才会给非 0 值(见 `commands/auth.mjs` 的 resolveCallbackFirstWaitMs);
@@ -695,25 +926,24 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
695
926
  *
696
927
  * ★ 2026-10-01 起:`deps.openBrowser === false` 只是"用户主动关掉"这一种情况;
697
928
  * **要不要弹系统浏览器**这件事统一由 `resolveBrowserIntent()` 裁决 ——
698
- * ★ 老板最终拍板走**传统路线**:**默认总是尝试打开**(AI 场景也打开),
699
- * 只有 `--no-browser` 才不打开(见 resolveBrowserIntent 的说明)。
929
+ * ★ 2026-10-21 起:**人在终端默认打开**;`--no-browser` 与 **非交互/CI**(非 TTY / CI 环境变量 /
930
+ * `WKONGAI_CLI_NO_BROWSER=1`)都不打开(见 resolveBrowserIntent / detectNonInteractive 的说明)。
700
931
  */
701
932
  const browserIntent = resolveBrowserIntent({
702
933
  isTTY: deps.isTTY === true,
703
934
  json: deps.json === true,
704
935
  forceOpen: deps.forceBrowser === true,
705
936
  noBrowser: deps.openBrowser === false || deps.noBrowser === true,
937
+ /** ★ CI / 非交互判定(2026-10-21):命令层把 `ctx.env` 透传进来,纯函数在 login.mjs 里 */
938
+ env: deps.env && typeof deps.env === 'object' ? deps.env : {},
706
939
  });
707
940
  /**
708
941
  * ★★ 最终是否自动打开系统浏览器 = **唯一闸门**的结论。
709
942
  *
710
- * ★ 本次变更(老板最终拍板):「**总是尝试打开**」重新成为默认 ——
711
- * 人在终端 ✔、AI 场景(非 TTY / `--json`)✔ 都打开;`json` / `isTTY` **不再**是"不打开"的理由。
712
- * 唯一的"不打开"开关是 `--no-browser`(AI 端不想弹窗就用它)。
713
- * 为什么回到传统路线(原话):
714
- * 「还是返回以前那种打开跳到一个浏览器,然后打开授权那个吧,大家也都是那个的。
715
- * 我们在右侧这个……能力还不会太完善……还是走传统的路吧。」
716
- * ⇒ 稳定(成熟的系统启动器)+ `window.close()` 在独立浏览器里生效(页面能自己关)。
943
+ * ★ 2026-10-21(老板真机反馈「CI 里 login 弹了浏览器」):
944
+ * **人类终端**默认打开;**非交互 / CI**(stdout 非 TTY / CI 环境变量 /
945
+ * `WKONGAI_CLI_NO_BROWSER=1`)与 `--no-browser` 都不打开。
946
+ * `json` / `isTTY` 不再决定"要不要弹"(`json` 只影响 `mode` 诊断字段)。
717
947
  * ⚠️ 无论哪种情况,**绝不**因为"没打开浏览器"让登录失败 —— 地址一直完整打印,回调照旧等。
718
948
  */
719
949
  const shouldOpenBrowser = browserIntent.allow;
@@ -1113,23 +1343,68 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1113
1343
  server.listen(port, config.callbackHost);
1114
1344
  });
1115
1345
 
1346
+ /**
1347
+ * ★★ 0.2.4(老板真机反馈):端口优先 53682;被占用就自动换系统分配的空闲端口;
1348
+ * **bind 彻底失败时只报明确错误 + `--device` 建议,绝不静默改走设备码**
1349
+ * (设备码是另一条授权协议,用户没要求时不能替他换;命令层也不再兜底,见 commands/auth.mjs)。
1350
+ */
1351
+ const preferredPort = deps.listenPort ?? config.callbackPort;
1352
+ const buildListenError = (cause, { afterFallback = false } = {}) => {
1353
+ const what = afterFallback
1354
+ ? `首选端口 ${preferredPort} 被占用后,系统分配端口也无法监听`
1355
+ : `无法在 ${config.callbackHost}:${preferredPort} 上监听本地回调服务`;
1356
+ const codeText = cause && cause.code ? `(${cause.code})` : '';
1357
+ return new CliError(`${what}${codeText}:${(cause && cause.message) || cause}`, {
1358
+ kind: 'listen',
1359
+ hint:
1360
+ '本地回调服务没有启动,所以不会打开浏览器。请检查端口占用/防火墙,或用 --callback-port 指定其它端口;' +
1361
+ `若本机确实无法监听回环地址,请显式改用 \`${config.cmdName || 'wkgeo'} login --device\`(设备码模式,不需要本地回调)` +
1362
+ (proxyHint ? `。另外:${proxyHint}` : ''),
1363
+ cause,
1364
+ });
1365
+ };
1366
+
1116
1367
  let bound;
1117
1368
  try {
1118
- bound = await listenOn(deps.listenPort ?? config.callbackPort);
1369
+ bound = await listenOn(preferredPort);
1119
1370
  } catch (e) {
1120
- if (e && e.code === 'EADDRINUSE') {
1121
- log(`端口 ${config.callbackPort} 被占用,改用随机回环端口(服务端按 RFC 8252 忽略端口,仍能回调成功)`);
1122
- bound = await listenOn(0);
1371
+ if (e && e.code === 'EADDRINUSE' && Number(preferredPort) !== 0) {
1372
+ log(`⚠️ 回调端口 ${preferredPort} 已被占用,自动改用系统分配的空闲端口(redirect_uri 会使用实际监听端口)`);
1373
+ try {
1374
+ bound = await listenOn(0);
1375
+ } catch (e2) {
1376
+ throw buildListenError(e2, { afterFallback: true });
1377
+ }
1123
1378
  } else {
1124
- throw new CliError(`无法在 ${config.callbackHost} 上监听回调端口:${e.message}`, {
1125
- kind: 'listen',
1126
- hint: `检查防火墙/端口占用,或用 --callback-port 指定其它端口${proxyHint ? `。另外:${proxyHint}` : ''}`,
1127
- cause: e,
1128
- });
1379
+ throw buildListenError(e);
1129
1380
  }
1130
1381
  }
1131
1382
  const actualPort = bound.port;
1383
+ /**
1384
+ * ⚠️ 顺序硬保证(0.2.4):**监听真的成功**之后才允许构造/打印授权地址、才允许打开浏览器。
1385
+ * 理论上 `listenOn` resolve 时 server 一定处于 listening;这里再核一次,是为了让
1386
+ * "服务没起来却把浏览器弹出去"这类故障在代码层**不可能**发生(而不是靠调用顺序的自觉)。
1387
+ */
1388
+ if (!server.listening) {
1389
+ // 极端防御:真走到这里说明监听状态丢了 —— 先尽量关掉,再明确报错(绝不弹浏览器)
1390
+ try {
1391
+ server.close();
1392
+ } catch {
1393
+ /* 本来就没监听,忽略 */
1394
+ }
1395
+ throw new CliError('本地回调服务没有进入监听状态,已取消打开浏览器', {
1396
+ kind: 'listen',
1397
+ hint:
1398
+ `请重新运行 \`${config.cmdName || 'wkgeo'} login\`;若仍失败,改用 \`${config.cmdName || 'wkgeo'} login --device\`(设备码模式,不需要本地回调)`,
1399
+ });
1400
+ }
1132
1401
  redirectUri = `http://${config.callbackHost}:${actualPort}${config.callbackPath}`;
1402
+ /**
1403
+ * ★★ 老板要求的可观测证据(0.2.4):先明确告诉用户"本地服务已经在监听",再谈浏览器。
1404
+ * 顺序是:listen 成功 → 这行日志 → 打印授权网址 → 尝试打开浏览器。
1405
+ * 用户/排障者因此能一眼分辨"服务没起来"和"浏览器没打开"这两类完全不同的故障。
1406
+ */
1407
+ log(`✅ 回调服务已启动 ${redirectUri}`);
1133
1408
  const authUrl = new URL(authorizeBase);
1134
1409
  authUrl.searchParams.set('client_id', config.clientId);
1135
1410
  authUrl.searchParams.set('redirect_uri', redirectUri);
@@ -1178,7 +1453,7 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1178
1453
  opened: false,
1179
1454
  tried: [],
1180
1455
  url: authUrl.toString(),
1181
- // ★ 现在只可能因为 `--no-browser` 不尝试(`non_tty_agent_mode` 已随老板拍板走传统路线而弃用)
1456
+ // ★ 只有两种"不尝试":`--no-browser`(no_browser)与非交互/CI(non_interactive)
1182
1457
  skipped_reason: browserIntent.skipped_reason,
1183
1458
  };
1184
1459
  if (shouldOpenBrowser) {
@@ -1195,8 +1470,24 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1195
1470
  }
1196
1471
  }
1197
1472
  if (!shouldOpenBrowser) {
1198
- // ⚠️ 只有"--no-browser"才打这一行(用户主动关掉的,不该报"失败")
1199
- if (browserIntent.skipped_reason === 'no_browser') log('(已按 --no-browser 跳过自动打开)');
1473
+ if (browserIntent.skipped_reason === 'no_browser') {
1474
+ // 用户主动关掉的,不该报"失败"(原有行为,保持兼容)
1475
+ log('(已按 --no-browser 跳过自动打开)');
1476
+ } else if (browserIntent.skipped_reason === 'non_interactive') {
1477
+ /**
1478
+ * ★ 2026-10-21(老板反馈:CI 里弹了浏览器):
1479
+ * 非交互环境**不弹**,但必须把"为什么没弹 + 下一步怎么走"说清楚,
1480
+ * 否则用户/CI 日志里只看到一行 URL,会以为 CLI 卡住了。
1481
+ * ⚠️ 这段走 `log`:`--json` 时命令层会把它转到 stderr,不污染 stdout 的 JSONL。
1482
+ */
1483
+ const reason = detectNonInteractive({
1484
+ isTTY: deps.isTTY === true,
1485
+ env: deps.env && typeof deps.env === 'object' ? deps.env : {},
1486
+ }).reason;
1487
+ for (const line of nonInteractiveBrowserHintLines({ kind: 'auth', reason, cmdName: config.cmdName || 'wkgeo' })) {
1488
+ log(line);
1489
+ }
1490
+ }
1200
1491
  } else if (openStatus.opened) {
1201
1492
  // ★ 成功:明说"已经打开了",并告诉用户去那里做什么(老板最终拍板走传统路线)
1202
1493
  log(browserOpenedText({ kind: 'auth' }));
@@ -1222,15 +1513,18 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1222
1513
  /**
1223
1514
  * ★ 机器可读的"自动打开"状态:AI 端据此判断"到底要不要催用户手动打开"。
1224
1515
  * `authorization_url` 仍然照旧单独给(绝不能因为多了这个字段就把它挤掉)。
1225
- * ★ 老板最终拍板走**传统路线**:CLI 默认**总是尝试**打开系统浏览器(AI 场景也一样),
1226
- * 所以 `skipped_reason` 现在只有两种取值:
1227
- * * `null` = 真的尝试过(成没成看 `opened`);
1228
- * * `"no_browser"` = 调用方/用户明确要求别打开(唯一开关)。
1516
+ * ★ 2026-10-21 起 `skipped_reason` 有三种取值:
1517
+ * * `null` = 真的尝试过(成没成看 `opened`);
1518
+ * * `"no_browser"` = 调用方/用户明确要求别打开;
1519
+ * * `"non_interactive"` = CI / 管道 / AI 子进程(**默认不弹**,见 detectNonInteractive)。
1229
1520
  * (历史取值 `"non_tty_agent_mode"` 已弃用,见 resolveBrowserIntent 的注释。)
1230
1521
  */
1231
1522
  open_browser: openStatus,
1232
1523
  /** ★ 给 AI 端"照着念"的一句话(AI 不该自己组织语言,否则容易漏掉网址) */
1233
- paste_text: browserPasteText(authUrl.toString(), { kind: 'auth' }),
1524
+ paste_text: browserPasteText(authUrl.toString(), {
1525
+ kind: 'auth',
1526
+ nonInteractive: browserIntent.skipped_reason === 'non_interactive',
1527
+ }),
1234
1528
  });
1235
1529
  }
1236
1530