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/README.md +32 -23
- package/SKILL.md +43 -32
- package/lib/commands/auth.mjs +52 -59
- package/lib/commands/billing.mjs +36 -11
- package/lib/device.mjs +24 -4
- package/lib/login.mjs +372 -78
- package/lib/page.mjs +51 -54
- package/package.json +1 -1
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
/**
|
|
195
|
-
|
|
196
|
-
|
|
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(
|
|
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)
|
|
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
|
-
* ★★
|
|
523
|
+
* ★★ CI / 非交互环境的判定因子(2026-10-21 老板反馈的直接产物)。
|
|
392
524
|
*
|
|
393
|
-
* ##
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
525
|
+
* ## 真实现场
|
|
526
|
+
* 「别人在 **CI** 里执行 `wkgeo-cli` 的 login,**默认弹出了系统默认浏览器**」
|
|
527
|
+
* —— CI 机器上没有显示器/桌面会话,`rundll32` / `open` / `xdg-open` 要么静默失败,
|
|
528
|
+
* 要么在装了 Xvfb / 桌面残留的 runner 上真的拉起一个窗口(占资源、拖慢、还可能卡住)。
|
|
529
|
+
* CI 里唯一正确的做法是:**只输出授权链接/JSON**,把链接交给有浏览器的人打开。
|
|
397
530
|
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
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
|
|
427
|
-
* @param {boolean} [opts.json] 是不是 JSON
|
|
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
|
-
* @
|
|
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
|
-
//
|
|
657
|
+
// ④ 人在终端:保持 CLI 一直以来的体验(这条**不能**破坏)
|
|
438
658
|
if (isTTY === true && json !== true) return { allow: true, mode: 'terminal', skipped_reason: null };
|
|
439
|
-
//
|
|
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
|
-
*
|
|
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:(
|
|
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
|
-
*
|
|
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
|
-
* 决定"要不要替用户弹系统浏览器"
|
|
668
|
-
*
|
|
669
|
-
*
|
|
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
|
-
* ★
|
|
699
|
-
*
|
|
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
|
-
*
|
|
712
|
-
*
|
|
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(
|
|
1369
|
+
bound = await listenOn(preferredPort);
|
|
1119
1370
|
} catch (e) {
|
|
1120
|
-
if (e && e.code === 'EADDRINUSE') {
|
|
1121
|
-
log(
|
|
1122
|
-
|
|
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
|
|
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
|
-
// ★
|
|
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
|
-
|
|
1199
|
-
|
|
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
|
-
* ★
|
|
1226
|
-
*
|
|
1227
|
-
* * `
|
|
1228
|
-
* * `"
|
|
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(), {
|
|
1524
|
+
paste_text: browserPasteText(authUrl.toString(), {
|
|
1525
|
+
kind: 'auth',
|
|
1526
|
+
nonInteractive: browserIntent.skipped_reason === 'non_interactive',
|
|
1527
|
+
}),
|
|
1234
1528
|
});
|
|
1235
1529
|
}
|
|
1236
1530
|
|