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.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * 「老站导入」的本机脚本发现与参数拼装。
3
+ *
4
+ * ## 为什么 CLI 不自己抓站、不自己写库
5
+ * 抓取 / URL 层级 → 栏目树 / 正文白名单 / 幂等键这套逻辑已经有**能跑的实现**:
6
+ * `tencent/cloudbase/import-site.mjs`(21 项单测、默认 dry-run)。CLI 再写一套 = 两份行为会漂。
7
+ *
8
+ * ## 但也**不能**让 CLI 直接持有数据库凭证
9
+ * 那个脚本走 `service_role` 直连 PG(`tencent/cloudbase/.env` 里的 `CLOUDBASE_APIKEY`),
10
+ * 与"桌面/CLI 永远拿不到 AK"是同一条边界。所以这里的定位是明确的:
11
+ * **它是运维/管理员在本机跑的一个包装**,不是给客户用的导入通道 ——
12
+ * 带鉴权的 HTTP 导入入口还在后端待办(见 docs/规划-CLI与AI建站.md §5.2)。
13
+ * ⇒ 命令本身不碰数据库,只负责"找脚本 + 拼参数 + 起子进程 + 如实转述退出码"。
14
+ *
15
+ * ## 为什么要单独一个文件
16
+ * "脚本在哪"和"参数怎么拼"都是**纯函数**,可以在单测里把这两个容易写歪的地方钉住
17
+ * (拼错一个 `--site` 就等于把内容导进别的站)。
18
+ */
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { spawnSync } from 'node:child_process';
23
+
24
+ /** 脚本相对仓库根的位置(本文件在 `cli/lib/`,往上两级就是仓库根) */
25
+ const SCRIPT_RELATIVE = ['tencent', 'cloudbase', 'import-site.mjs'];
26
+
27
+ /** 只看普通文件(避免把目录 / 断链当成脚本) */
28
+ function isFile(p) {
29
+ try {
30
+ return fs.statSync(p).isFile();
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * 找本机的导入脚本(**只看约定位置**;显式配置由 `resolveImportScript` 处理)。
38
+ *
39
+ * 顺序(命中即返回):
40
+ * 1. **包内相对路径** `../../tencent/cloudbase/import-site.mjs`(在本仓库里跑 `node cli/wkgeo.mjs` 时命中);
41
+ * 2. `cwd/tencent/cloudbase/import-site.mjs`;
42
+ * 3. `cwd/import-site.mjs`(把脚本单独拷到当前目录的场景)。
43
+ *
44
+ * ⚠️ 刻意**不**在 npm 安装目录里到处找:npx 装的包里没有这个脚本(也不该有,
45
+ * `package.json` 的 `files` 只带 `wkgeo.mjs` / `lib` / 文档),找不到时给的是
46
+ * "请在有本仓库的机器上运行",而不是让用户对着 ENOENT 发呆。
47
+ *
48
+ * @returns {string} 绝对路径;找不到返回 `''`
49
+ */
50
+ export function findImportScript({ cwd = process.cwd(), moduleUrl = import.meta.url } = {}) {
51
+ const candidates = [];
52
+ try {
53
+ /** `cli/lib/local-import.mjs` → `../../tencent/cloudbase/import-site.mjs` = 仓库根下的同目录 */
54
+ candidates.push(filePathFromUrl(new URL(`../../${SCRIPT_RELATIVE.join('/')}`, moduleUrl)));
55
+ } catch {
56
+ /* moduleUrl 不是合法 URL(测试里传了怪值):跳过这一项 */
57
+ }
58
+ candidates.push(path.join(cwd, ...SCRIPT_RELATIVE));
59
+ candidates.push(path.join(cwd, SCRIPT_RELATIVE[SCRIPT_RELATIVE.length - 1]));
60
+
61
+ for (const one of candidates) {
62
+ if (one && isFile(one)) return one;
63
+ }
64
+ return '';
65
+ }
66
+
67
+ /** file: URL → 本地路径(Windows 上要还原成 `F:\…` 这种形态) */
68
+ function filePathFromUrl(url) {
69
+ const href = String(url && url.href ? url.href : url);
70
+ return decodeURIComponent(href.replace(/^file:\/\//i, '').replace(/^\/([A-Za-z]:)/, '$1'));
71
+ }
72
+
73
+ /**
74
+ * 命令层用的"解析脚本"入口 —— 与 `findImportScript` 的区别是**严格对待显式配置**。
75
+ *
76
+ * ⚠️ 为什么必须严格:`WKONGAI_IMPORT_SCRIPT` 是用户**明确指定**的脚本。
77
+ * 如果它写错了却悄悄回落到仓库里的默认脚本,用户会以为跑的是他指定的那份
78
+ * (参数/分支可能完全不一样)—— 这种"看起来成功"的错最难查。
79
+ * ⇒ 显式给了但不存在:直接报错,不回落。
80
+ *
81
+ * @returns {{script:string, error:null|{kind:string, message:string, hint:string}}}
82
+ */
83
+ export function resolveImportScript({ env = {}, cwd = process.cwd(), moduleUrl = import.meta.url } = {}) {
84
+ const explicit = String((env && env.WKONGAI_IMPORT_SCRIPT) || '').trim();
85
+ if (explicit) {
86
+ const abs = path.resolve(cwd, explicit);
87
+ if (!isFile(abs)) {
88
+ return {
89
+ script: '',
90
+ error: {
91
+ kind: 'import_unavailable',
92
+ message: `指定的导入脚本不存在:${explicit}`,
93
+ hint: 'WKONGAI_IMPORT_SCRIPT 要指向一个真实存在的 `import-site.mjs`(用绝对路径最稳)。',
94
+ },
95
+ };
96
+ }
97
+ return { script: abs, error: null };
98
+ }
99
+ const script = findImportScript({ cwd, moduleUrl });
100
+ if (!script) {
101
+ return {
102
+ script: '',
103
+ error: {
104
+ kind: 'import_unavailable',
105
+ message: '没有找到本地导入器(tencent/cloudbase/import-site.mjs)',
106
+ hint:
107
+ '`import` 目前只有"本机运维脚本"这一条路(service_role 直连数据库,需要运维权限);' +
108
+ '带鉴权的 HTTP 导入入口后端还没做。请在有本仓库的机器上运行,' +
109
+ '或用环境变量 WKONGAI_IMPORT_SCRIPT 指定脚本路径。',
110
+ },
111
+ };
112
+ }
113
+ return { script, error: null };
114
+ }
115
+
116
+ /**
117
+ * 拼传给 `import-site.mjs` 的参数(**只拼它认识的**:它遇到未知参数会直接报错)。
118
+ *
119
+ * @param {object} opts
120
+ * @param {string} opts.url 老站首页(必填)
121
+ * @param {string} opts.site 目标站(站点 ID / 域名 / 临时域名 / 站点名)
122
+ * @param {boolean} [opts.apply] true = 真写库;false/undefined = dry-run(脚本默认就是 dry-run)
123
+ * @param {string} [opts.status] `draft`(默认)/ `published`
124
+ * @param {string} [opts.out] 中间 JSON 落盘路径
125
+ * @param {number|string} [opts.maxPages]
126
+ * @param {number|string} [opts.delay] 每页间隔毫秒
127
+ * @param {string} [opts.include] 只抓匹配这个正则的路径
128
+ * @param {string} [opts.exclude] 跳过匹配这个正则的路径
129
+ * @param {boolean} [opts.overwrite] 已存在的同名内容也覆盖正文
130
+ * @param {boolean} [opts.noProbe] 不去探测(脚本的 `--no-probe`)
131
+ * @returns {string[]}
132
+ */
133
+ export function buildImportArgs(opts = {}) {
134
+ const args = [];
135
+ const url = String(opts.url || '').trim();
136
+ if (!url) throw new Error('缺少 --url');
137
+ args.push(`--url=${url}`);
138
+
139
+ const site = String(opts.site || '').trim();
140
+ if (site) args.push(`--site=${site}`);
141
+
142
+ /** 默认 dry-run:只有显式 apply 才写库(脚本自己也是这个口径,这里不重复给 --dry-run 也行) */
143
+ if (opts.apply === true) args.push('--apply');
144
+
145
+ if (opts.status !== undefined && opts.status !== '') args.push(`--status=${String(opts.status)}`);
146
+ if (opts.out !== undefined && opts.out !== '') args.push(`--out=${String(opts.out)}`);
147
+ if (opts.maxPages !== undefined && opts.maxPages !== '') args.push(`--max-pages=${String(opts.maxPages)}`);
148
+ if (opts.delay !== undefined && opts.delay !== '') args.push(`--delay=${String(opts.delay)}`);
149
+ if (opts.include !== undefined && opts.include !== '') args.push(`--include=${String(opts.include)}`);
150
+ if (opts.exclude !== undefined && opts.exclude !== '') args.push(`--exclude=${String(opts.exclude)}`);
151
+ if (opts.overwrite === true) args.push('--overwrite');
152
+ if (opts.noProbe === true) args.push('--no-probe');
153
+ return args;
154
+ }
155
+
156
+ /**
157
+ * 起子进程跑脚本(**默认实现**;单测通过 `runtime.runScript` 注入替身)。
158
+ *
159
+ * 为什么默认 `spawnSync` 而不是异步 `spawn`:
160
+ * 导入是一个"长时间、人盯着看进度"的动作,同步阻塞反而最简单(stdio 直通终端,
161
+ * Ctrl+C 就是 Ctrl+C)。JSON 模式下 stdout/stderr 改走管道,由调用方收进信封。
162
+ */
163
+ export function runNodeCommand(command, args, options = {}) {
164
+ const res = spawnSync(command, args, {
165
+ encoding: 'utf8',
166
+ maxBuffer: 64 * 1024 * 1024,
167
+ ...options,
168
+ });
169
+ if (res && res.error) {
170
+ return { status: 1, stdout: String(res.stdout || ''), stderr: String(res.stderr || res.error.message || ''), error: res.error };
171
+ }
172
+ return {
173
+ status: typeof res.status === 'number' ? res.status : 1,
174
+ stdout: String(res.stdout || ''),
175
+ stderr: String(res.stderr || ''),
176
+ error: null,
177
+ };
178
+ }
179
+
180
+ /** 取日志尾巴(JSON 信封里不带整份日志,只带最后一段足够定位问题的) */
181
+ export function tailText(text, maxChars = 4000) {
182
+ const s = String(text || '');
183
+ if (s.length <= maxChars) return s;
184
+ return `…(前 ${s.length - maxChars} 字符省略)…\n${s.slice(-maxChars)}`;
185
+ }
package/lib/login.mjs CHANGED
@@ -19,16 +19,23 @@
19
19
  * 依赖注入(deps)是为了**可测**:offline 单测里把 fetch / 开浏览器 / 端口都替换掉,
20
20
  * 就成了一台"假浏览器",可以逐条验证 state 校验、超时、错误页这些分支。
21
21
  *
22
- * ⚠️ 两个等待窗口(别搞混,`commands/auth.mjs` 靠 `kind` 区分"要不要自动改设备码"):
23
- * * `DEFAULT_CALLBACK_FIRST_WAIT_MS`(**180 秒**,`--callback-first-wait` 可调/可关,
24
- * ★ **只在检测到系统代理时**才由命令层启用)
25
- * → 到点抛 `kind = 'callback_first_wait'` → **自动改用设备码继续,不退出**(第二条保险);
22
+ * ★★ 2026-10-21(老板反馈):**默认就是这条本地回调授权**,`wkgeo login` 不会因为
23
+ * "等了一会儿"自动变成设备码。设备码只在**这几种情况**出现(裁决点在 `commands/auth.mjs`):
24
+ * * 用户显式 `--device` / `--no-wait` / `--device-continue`;
25
+ * * 检测到系统代理且判定回调不可用(默认开,`--no-auto-device` 可关);
26
+ * * 本地回调端口**真的起不来**(`kind = 'listen'`,真实失败兜底;`--no-auto-device` 可关)。
27
+ * 整段等待超时(`kind = 'timeout'`)**只报错**,不再自动改设备码(0.2.2 起取消)。
28
+ *
29
+ * 两个等待窗口(别搞混,`commands/auth.mjs` 靠 `kind` 区分"要不要自动改设备码"):
30
+ * * 可选首轮等待(`--callback-first-wait` / `WKONGAI_CALLBACK_FIRST_WAIT_MS`,
31
+ * ★ **默认关闭**,只有用户显式指定才启用)
32
+ * → 到点抛 `kind = 'callback_first_wait'`;命令层在场时会**并发**启动设备码(仅显式开启时);
26
33
  * * `config.loginTimeoutMs`(默认 300 秒,`--timeout` 可调)
27
34
  * → 到点抛 `kind = 'timeout'`(整段等待的兜底,也是老板报的那个故障的现场)。
28
35
  *
29
- * ★★ 2026-10-03(老板真机反馈)起,这两条"到点"都**不再关掉本地回调监听**:
36
+ * ★★ 2026-10-03(老板真机反馈)起,"首轮等待"到点改道时**不再关掉本地回调监听**:
30
37
  * 命令层会通过 `deps.handoff` 拿到通知,**并发**去跑设备码那条路,**哪条先成功就用哪条**
31
- * (详见 `DEFAULT_CALLBACK_FIRST_WAIT_MS` 与 `deps.handoff` 的注释)。
38
+ * (详见 `deps.handoff` 的注释;该自愈现在只有显式 `--callback-first-wait` 才会触发)。
32
39
  */
33
40
 
34
41
  import http from 'node:http';
@@ -40,6 +47,7 @@ import { successPage, errorPage, alreadyDonePage } from './page.mjs';
40
47
  import { createVerifier, challengeOf, createState, safeEqual } from './pkce.mjs';
41
48
  import { writeCredentials, credentialsFromToken } from './credentials.mjs';
42
49
  import { withTrailingSlash } from './config.mjs';
50
+ import { canonicalOauthUrl } from './oauth-urls.mjs';
43
51
  import { proxyRiskHint } from './proxy.mjs';
44
52
  import { CliError, EXIT } from './errors.mjs';
45
53
 
@@ -55,7 +63,8 @@ import { CliError, EXIT } from './errors.mjs';
55
63
  *
56
64
  * ⚠️⚠️ Windows 这条分支踩过一个真机实测的大坑(2026-10-01):
57
65
  * 原来的写法是 `spawn('cmd', ['/c', 'start', '', url])` —— 浏览器**确实弹出来了**,
58
- * 但打开的是光秃秃的 `/admin/oauth/authorize`,**所有 query 参数都没了**,
66
+ * 但打开的是光秃秃的 `/admin/oauth/authorize`(★ 2026-10-02 起授权站是 `/authorize`),
67
+ * **所有 query 参数都没了**,
59
68
  * 授权页显示「授权请求不完整」。
60
69
  *
61
70
  * 机理(必须记住):**在 Windows 的 cmd.exe 里,`&` 是命令分隔符**。
@@ -133,47 +142,23 @@ const FALLBACK_STRATEGIES = BROWSER_STRATEGY_TABLE.linux;
133
142
  export const BROWSER_LAUNCH_TIMEOUT_MS = 4000;
134
143
 
135
144
  /**
136
- * ★ 「回调模式首轮等待」窗口(默认 **180 秒**)—— 第二条保险,**只在检测到系统代理时启用**。
137
- *
138
- * ## ⚠️⚠️ 2026-10-03 老板真机反馈:这条自愈**被用错了场景**(本次修的就是它)
139
- * 老板**没有开任何 VPN / 代理**,但连着重跑几次 `wkgeo login` 都**自动变成了设备码**,
140
- * 原话:「现在变成设备码了,我们因为没有开任何VPN,所以这个是不对的。」
141
- * 现场:旧逻辑把这个窗口**无条件**挂在 60 秒上,而"把授权页送到用户面前、用户读完再点
142
- * 【同意】"经常超过 60 秒 —— 于是明明不需要设备码,也切了过去。
143
- * ⇒ 现在:**只有 `proxy_warning.detected === true`(真检测到生效中的代理)才启用**;
144
- * **没有代理时一律不启用**(老老实实等满 `--timeout`)。
145
- * 用户**显式**写 `--callback-first-wait <秒>` 或 `WKONGAI_CALLBACK_FIRST_WAIT_MS` 时照旧尊重
146
- * —— **显式指定优先于自动判断**(他怎么要求就怎么做)。
147
- * ⇒ 连带结论:`auto_device.applied=true, reason='callback_first_wait'` **只可能出现在有代理的机器上**;
148
- * 没有代理、用户也没显式指定时,**绝不会**发生。
149
- * (判定点在 `commands/auth.mjs` 的 `resolveCallbackFirstWaitMs()`,那边有一份对应的说明与单测钉子。)
145
+ * 历史常量:回调模式「首轮等待」的**旧默认窗口**(180 秒)。
150
146
  *
151
- * ## 为什么还需要它(什么时候它是对的)
152
- * "代理把回调吃掉了"这种故障里,用户其实早就点完【同意授权】了,只是结果回不来;
153
- * 而设备码**不需要任何入站回调**,几分钟内就能拿到令牌 —— 比让他干等 5~15 分钟强得多。
154
- * 放宽到 180 秒(原来是 60 秒)就是给"用户真在认真读授权页"留足余量:
155
- * 它只在"有代理、回调本来就可能回不来"的机器上生效,宁可多等一会儿再改道。
147
+ * ★ 0.2.2 起**不再作为默认值生效**(老板 2026-10-21 反馈):
148
+ * 「默认的也不应该是设备授权,默认的就是那个传统的授权方式(浏览器打开授权页 +
149
+ * 本地 localhost 回调)。设备码是本地读不了/代理时才用的。」
150
+ * ⇒ `wkgeo login` 默认一路等回调(最多 `--timeout`),**不会**因为等太久自动改设备码;
151
+ * 只有用户**显式**写 `--callback-first-wait <秒>` / `WKONGAI_CALLBACK_FIRST_WAIT_MS`
152
+ * 才启用这条可选自愈(显式优先)。
156
153
  *
157
- * ## ★★ 到点之后**不关本地回调监听**(同一次修复的第二条,真机证据见下)
158
- * 旧行为:到点 → 抛 `callback_first_wait` → 命令层改走设备码,而 `runLogin` 的 `finally`
159
- * **已经把本地 server 关掉了**。于是用户随后才点【同意授权】时,浏览器跳到
160
- * `http://127.0.0.1:<port>/callback?code=…` 得到 **ERR_CONNECTION_REFUSED**
161
- * (老板真机截图见过)—— 用户以为授权失败,又重来一次(其实他马上就会登录成功)。
162
- * 现在的契约:调用方传了 `deps.handoff` 时,到点**只通知、不结束、不关监听**(见 `runLogin`),
163
- * 由命令层**并发**跑设备码,两条路**先到先得**。
154
+ * ⚠️ 保留这个导出只为**兼容**仍 import 它的外部脚本/旧文档;产品行为以
155
+ * `commands/auth.mjs` 的 `resolveCallbackFirstWaitMs()` 为准(无显式指定时返回 0)。
156
+ * 这个常量本身**不参与任何默认判断**。
164
157
  *
165
- * ## 覆盖方式(优先级:命令行 > 环境变量 > 这个默认值)
166
- * * `--callback-first-wait <时长>`:默认单位**秒**(`--callback-first-wait 90` = 90 秒),
167
- * 也支持 `90s` / `90000ms`;写 `0` / `off` / `none` 表示**关掉这个自愈窗口**
168
- * (那就只剩整段 `--timeout` 窗口)。
169
- * * `WKONGAI_CALLBACK_FIRST_WAIT_MS`:毫秒(沿用 `WKONGAI_LOGIN_TIMEOUT_MS` 的口径)。
170
- * * `--no-auto-device` / `WKONGAI_AUTO_DEVICE=0` 时**强制关闭**:用户明确要求只走回调,
171
- * 我们就没有"改道"这个选项,不能偷偷改(见 `commands/auth.mjs` 的 resolveCallbackFirstWaitMs)。
158
+ * 历史背景(仍适用于"显式开启"的场景):到点后**不关本地回调监听**,由命令层并发跑设备码,
159
+ * 两条路先到先得(旧版关监听会让用户之后点【同意】看到 ERR_CONNECTION_REFUSED)。
172
160
  *
173
- * ## 约束
174
- * * 必须**小于** `config.loginTimeoutMs` 才有意义(否则只会先撞整段超时);
175
- * * 到点后抛 `kind = 'callback_first_wait'`(**不是** `timeout`),命令层据此自动改设备码、
176
- * **不退出**;换票失败/被拒绝等其他错误**照旧不降级**。
161
+ * @deprecated 0.2.2 起默认关闭;需要"等待到点改设备码"请显式传 `--callback-first-wait`。
177
162
  */
178
163
  export const DEFAULT_CALLBACK_FIRST_WAIT_MS = 180_000;
179
164
 
@@ -490,10 +475,10 @@ export function browserOpenedText({ kind = 'auth' } = {}) {
490
475
  * @param {string} [opts.cause] 失败原因(spawn 报的原文),有就附在第一行括号里,方便排障
491
476
  */
492
477
  export function browserFailedHintLines(url, { kind = 'auth', agent = false, cause = '' } = {}) {
493
- const 动作 = kind === 'pay' ? '支付' : '授权';
478
+ const action = kind === 'pay' ? '支付' : '授权';
494
479
  return [
495
480
  '',
496
- `⚠️ 浏览器没能自动打开${cause ? `(${cause})` : ''},请手动复制这个地址到浏览器完成${动作}:`,
481
+ `⚠️ 浏览器没能自动打开${cause ? `(${cause})` : ''},请手动复制这个地址到浏览器完成${action}:`,
497
482
  ` ${url}`,
498
483
  kind === 'pay'
499
484
  ? '(这是我们网站的收银台页面:打开后用微信扫码支付;支付成功后页面会自动尝试关闭,没关就手动关掉即可。)'
@@ -519,10 +504,10 @@ export function browserFailedHintLines(url, { kind = 'auth', agent = false, caus
519
504
  export function browserPasteText(url, { kind = 'auth', agent = false } = {}) {
520
505
  // 已弃用:见上面的参数说明(保留形参只是不想为了一个布尔值去改所有调用方)
521
506
  void agent;
522
- const 收尾 = '(完成后页面会自动关闭;没关就手动关掉即可)';
507
+ const closingHint = '(完成后页面会自动关闭;没关就手动关掉即可)';
523
508
  return kind === 'pay'
524
- ? `已为你打开浏览器,请在那里完成支付(微信扫码):${url}${收尾}`
525
- : `已为你打开浏览器,请在那里完成授权:${url}${收尾}`;
509
+ ? `已为你打开浏览器,请在那里完成支付(微信扫码):${url}${closingHint}`
510
+ : `已为你打开浏览器,请在那里完成授权:${url}${closingHint}`;
526
511
  }
527
512
 
528
513
  /**
@@ -682,22 +667,24 @@ function sendHtml(res, status, page) {
682
667
  * 决定"要不要替用户弹系统浏览器":★ **默认总是尝试打开**(人在终端与 AI 场景都一样),
683
668
  * 只有 `noBrowser`(`--no-browser`)才不打开;`isTTY` / `json` 现在只影响 `mode` 这个诊断字段
684
669
  * ——理由见 resolveBrowserIntent()。
685
- * `callbackFirstWaitMs` —— ★ 首轮等待窗口(默认 `DEFAULT_CALLBACK_FIRST_WAIT_MS` = 180 秒;
686
- * 传 0 或负数 = 关掉自愈,只等整段 `loginTimeoutMs`)。命令层会把它透传进来
687
- * (**只有检测到系统代理时才给非 0**,见 `commands/auth.mjs` 的 resolveCallbackFirstWaitMs),
688
- * 单测则用短窗口(例如 400ms)来验证"自动改设备码"这条分支,不必真的等 180 秒。
670
+ * `callbackFirstWaitMs` —— 可选的「首轮等待」窗口,**默认 0 = 关闭**(0.2.2 起)。
671
+ * 只有用户显式写了 `--callback-first-wait` / `WKONGAI_CALLBACK_FIRST_WAIT_MS` 时,
672
+ * 命令层才会给非 0 值(见 `commands/auth.mjs` 的 resolveCallbackFirstWaitMs);
673
+ * 传 0 / 不传 / 负数 = 不自愈,一直等满整段 `loginTimeoutMs`。
674
+ * 单测用短窗口(例如 400ms)验证"显式开启后自动改设备码"这条分支。
689
675
  * ★★ `handoff` —— **「回调 / 设备码两条路并存」的交接面**(2026-10-03 修 ERR_CONNECTION_REFUSED 时加的)。
690
676
  * 调用方(命令层)传一个**可变对象**,两边各填一半:
691
- * * 调用方**先**填 `handoff.onSwitch(reason)` —— 首轮等待/整段超时到点时会被调用
692
- * (`reason` 是 `'callback_first_wait'` 或 `'callback_timeout'`),它应当去启动设备码那条路,
677
+ * * 调用方**先**填 `handoff.onSwitch(reason)` —— **显式首轮等待**到点时会被调用
678
+ * (`reason` 是 `'callback_first_wait'`),它应当去启动设备码那条路,
693
679
  * **不要**在这里结束回调这条路:本地监听由本函数继续持有;
680
+ * ⚠️ 整段 `--timeout` 到点**不会**再调用它(0.2.2 起取消"等太久自动改道")。
694
681
  * * 本函数填 `handoff.finishWithDevice(result)` / `handoff.abort(error)` ——
695
682
  * 设备码那条路先拿到令牌时调前者(本函数据此收尾,并给"迟到的回调"回一张已完成页),
696
683
  * 设备码那条路也失败时调后者(本函数用这个错误结束等待)。
697
- * ⚠️ **没传 `handoff`(或 `onSwitch` 不是函数)= 旧契约**:到点直接抛 `callback_first_wait` /
698
- * `timeout` 并在 `finally` 里关掉监听(既有的 runLogin 级单测走的就是这条,语义不许变)。
684
+ * ⚠️ **没传 `handoff`(或 `onSwitch` 不是函数)= 旧契约**:显式首轮等待到点直接抛
685
+ * `callback_first_wait` 并在 `finally` 里关掉监听(既有 runLogin 级单测走的就是这条)。
699
686
  * `postDeviceGraceMs` —— 设备码先成功后,本地监听再多留多久(默认 `DEFAULT_POST_DEVICE_GRACE_MS`;
700
- * 只对"首轮等待"这条自愈生效;单测注入小值即可确定性地验证"迟到的回调拿到已完成页")。
687
+ * 只对"显式首轮等待"这条自愈生效;单测注入小值即可确定性地验证"迟到的回调拿到已完成页")。
701
688
  */
702
689
  export async function runLogin({ config, log = () => {}, deps = {} }) {
703
690
  const fetchImpl = deps.fetchImpl || globalThis.fetch;
@@ -765,25 +752,43 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
765
752
  log(`⚠️ 读取授权服务器元数据失败(${e.message}),改用内置默认授权地址继续`);
766
753
  }
767
754
 
768
- let authorizeBase = (metadata && metadata.authorization_endpoint) || `${config.oauthWebBase}/oauth/authorize`;
769
- // 线上曾经返回 http://localhost:8000/...(云函数默认值没配环境变量)。
770
- // 那种地址打开的是**用户自己机器**的端口 → 第一步就断,必须纠正。
771
- // ★ 2026-10-02(三站拆分):纠偏目标改成**授权站子域** `config.oauthWebBase`。
772
- // 顺带加一条守卫:host 不属于本平台域名(*.wkgeo.com / *.wkongai.cn)的一律纠偏 ——
773
- // 服务端配置写错域名时,比"打开一个陌生站点"更安全的做法是用内置默认值。
774
- const 本平台授权域 = /^https?:\/\/[^/]*\.(wkgeo\.com|wkgeo\.cn)(:\d+)?\//i;
775
- if (
776
- /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i.test(String(authorizeBase)) ||
777
- !本平台授权域.test(String(authorizeBase))
778
- ) {
755
+ /**
756
+ * 服务端声明的授权地址;拿不到就用配置基址 + `/authorize` 兜底。
757
+ * (下面紧接着要把它归一化到 canonical 授权站 —— 见 `canonicalOauthUrl()` 的说明)
758
+ */
759
+ let authorizeBase = (metadata && metadata.authorization_endpoint) || `${config.oauthWebBase}/authorize`;
760
+
761
+ /**
762
+ * ★★ 2026-10-18(域名统一到路径):授权页地址**以配置基址为 canonical**。
763
+ *
764
+ * 服务端 metadata 的 `authorization_endpoint` 由云函数 `oauth` 的 `ADMIN_WEB_BASE`
765
+ * 生成,**线上环境变量里仍是老地址** `https://oauth.wkgeo.com` —— 只改 CLI 的兜底
766
+ * 常量没用,那个字段永远优先。所以这里统一过一道 `canonicalOauthUrl()`:
767
+ * * 平台内的地址 ⇒ 把 pathname 挪到 `config.oauthWebBase`(默认 `https://wkgeo.com/oauth`)下;
768
+ * * 平台外的地址(含 `localhost`,历史上真被坑过)⇒ 回落到配置基址的 `/authorize`。
769
+ * 回滚老站只要设 `WKONGAI_OAUTH_WEB_BASE=https://oauth.wkgeo.com`,不用改代码。
770
+ *
771
+ * ⚠️ 下面两步的顺序不能颠倒:
772
+ * 先归一化(决定"打开哪个站"),再补尾斜杠(决定"302 会不会丢 query")。
773
+ * 尾斜杠规则见 `withTrailingSlash()` 的说明(托管侧对目录路径 302 且丢 query)。
774
+ */
775
+ const canonicalAuthorize = canonicalOauthUrl(
776
+ authorizeBase,
777
+ config.oauthWebBase,
778
+ { fallbackPath: '/authorize' },
779
+ );
780
+ if (canonicalAuthorize.changed) {
779
781
  log(
780
- `⚠️ 服务端返回的授权地址不是本平台域名(${authorizeBase}),已改用 ${config.oauthWebBase}/oauth/authorize`,
782
+ `⚠️ 服务端返回的授权地址不在当前授权站(${authorizeBase}),` +
783
+ `已改用 ${config.oauthWebBase} 下的同一路径:${canonicalAuthorize.url}`,
781
784
  );
782
- authorizeBase = `${config.oauthWebBase}/oauth/authorize`;
783
785
  }
786
+ authorizeBase = canonicalAuthorize.url;
784
787
 
785
788
  /**
786
- * ★ 授权页地址**从构造时就带尾斜杠**(`/admin/oauth/authorize/?client_id=…`)。
789
+ * ★ 授权页地址**从构造时就带尾斜杠**(`…/authorize/?client_id=…`;
790
+ * 老站是 `/admin/oauth/authorize/?client_id=…`,子域时代是 `/authorize/?…`,
791
+ * 2026-10-18 路径化后是 `/oauth/authorize/?…`)。
787
792
  *
788
793
  * 为什么:前端是静态导出(每个路由一个目录),托管侧(OSS 静态托管 / 阿里云 ESA)
789
794
  * 对**不带尾斜杠**的目录路径会 302,而那个 302 **不携带 query** ——
@@ -823,7 +828,7 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
823
828
  let settled = false;
824
829
  let server;
825
830
  let timer = null;
826
- /** ★ 首轮等待定时器(见 DEFAULT_CALLBACK_FIRST_WAIT_MS):到点 → 让命令层改走设备码 */
831
+ /** ★ 可选首轮等待定时器(仅显式 `--callback-first-wait` 才有):到点 → 让命令层并发跑设备码 */
827
832
  let firstWaitTimer = null;
828
833
  /**
829
834
  * ★★ 交接面(见 deps.handoff 的说明):命令层传进来的可变对象,两边各填一半。
@@ -900,9 +905,9 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
900
905
  const handOff = (reason) => {
901
906
  if (settled || switchReason) return;
902
907
  switchReason = reason;
903
- const 原因 = reason === 'callback_first_wait' ? '首轮等待没收到回调' : '等待超时';
908
+ const reasonText = reason === 'callback_first_wait' ? '首轮等待没收到回调' : '等待超时';
904
909
  log('');
905
- log(`⚠️ 本地回调没有等到结果(${原因}):已自动改用【设备码模式】继续(不需干等,两条路并行)。`);
910
+ log(`⚠️ 本地回调没有等到结果(${reasonText}):已自动改用【设备码模式】继续(不需干等,两条路并行)。`);
906
911
  log(
907
912
  'ℹ️ 本机回调监听**继续保留**:如果你之后才在浏览器里点【同意授权】,那一次照样能直接完成登录' +
908
913
  '(不会再出现"连接被拒绝 / ERR_CONNECTION_REFUSED")。',
@@ -1237,17 +1242,21 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1237
1242
  * (⚠️ 注意:有代理时命令层通常**已经**走设备码了,根本走不到这里;
1238
1243
  * 能走到这里的情况是"用户在代理下强制回调"或"探测没发现代理但回调仍被别的
1239
1244
  * 东西挡了 —— 防火墙/安全软件/浏览器插件"。)
1240
- * ★ 传了 `handoff` 时**不再抛错**:改成通知命令层"并发去跑设备码",本地监听**保持开着**
1241
- * (用户随后才点的那次【同意】照样能成功;见 DEFAULT_CALLBACK_FIRST_WAIT_MS 的真机证据)。
1245
+ * ★★ 0.2.2 起:整段超时**不再自动改设备码**(老板:默认就是回调,设备码只在
1246
+ * 本地读不了/代理时才用)。这里只报 `kind = 'timeout'`,并在 hint 里明确给出
1247
+ * "显式改用 `--device`"的下一步。
1248
+ * ⚠️ 唯一例外:用户**显式**开了 `--callback-first-wait`,到点已经把设备码那条路
1249
+ * 并发点起来了(`switchReason` 非空)。此时整段超时到点不能再把设备码那条路判死,
1250
+ * 交给它自己跑到设备码过期/成功为止(与 2026-10-03 的"两条路并存"契约一致)。
1242
1251
  */
1243
- if (canHandoff && !deviceDone) {
1244
- handOff('callback_timeout');
1252
+ if (canHandoff && switchReason && !deviceDone) {
1245
1253
  return;
1246
1254
  }
1247
1255
  finish(new CliError(`等待授权超时(${config.loginTimeoutMs}ms)`, {
1248
1256
  kind: 'timeout',
1249
1257
  hint:
1250
- '授权码本身只有 5 分钟有效期。请重新运行 `wkgeo login`(浏览器里通常还有登录态,点一下同意即可)' +
1258
+ '授权码本身只有 5 分钟有效期。请重新运行 `wkgeo login`(浏览器里通常还有登录态,点一下同意即可);' +
1259
+ '若本机确实无法接收 127.0.0.1 回调(代理/防火墙/远程环境),请显式使用 `wkgeo login --device`(设备码模式)' +
1251
1260
  (proxyHint ? `。另外:${proxyHint}` : ''),
1252
1261
  exitCode: EXIT.AUTH,
1253
1262
  }));
@@ -1255,8 +1264,11 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1255
1264
  if (timer && typeof timer.unref === 'function') timer.unref();
1256
1265
 
1257
1266
  /**
1258
- * ★ 第二条保险:**首轮等待**(默认 180 秒,且只由命令层在"检测到代理"时给非 0 值)没等到回调
1259
- * → 主动结束这一轮等待,让命令层自动改用设备码继续(`kind = 'callback_first_wait'`,**不是** `timeout`)。
1267
+ * 可选自愈:**首轮等待**(★ 默认关闭;只有用户显式 `--callback-first-wait` 才会 > 0)
1268
+ * 没等到回调 → 主动结束这一轮等待 / 或交接给设备码(`kind = 'callback_first_wait'`,**不是** `timeout`)。
1269
+ *
1270
+ * ⚠️ 0.2.2 起**默认不挂这个定时器**(旧版有代理时默认 180 秒,会"等太久自动变设备码",
1271
+ * 正是老板这次反馈要改掉的行为)。
1260
1272
  *
1261
1273
  * 为什么不是在这里直接改走设备码:设备码流程在 `device.mjs` / `commands/auth.mjs`,
1262
1274
  * 而 `runLogin` 的契约是"要么拿到凭据、要么抛一个说明原因的错误"。
@@ -1268,11 +1280,11 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
1268
1280
  * ⚠️ 只在 `0 < firstWait < loginTimeoutMs` 时挂:否则先撞到的必然是整段超时,
1269
1281
  * 挂上去只会让两个定时器互相打乱,也让错误信息变得难以理解。
1270
1282
  */
1271
- const firstWaitMs = Number(deps.callbackFirstWaitMs ?? DEFAULT_CALLBACK_FIRST_WAIT_MS);
1283
+ const firstWaitMs = Number(deps.callbackFirstWaitMs ?? 0);
1272
1284
  if (Number.isFinite(firstWaitMs) && firstWaitMs > 0 && firstWaitMs < config.loginTimeoutMs) {
1273
1285
  const label = firstWaitMs >= 1000 ? `${Math.round(firstWaitMs / 1000)} 秒` : `${firstWaitMs} 毫秒`;
1274
- // 提前告知"不用干等",并说清"监听还会开着"(后者正是本次修复:用户之后点同意也来得及)
1275
- log(`(如果 ${label}内没收到回调,会自动改用设备码模式继续,不用一直干等;本地回调监听也会继续保留)`);
1286
+ // 提前告知"不用干等",并说清"监听还会开着"(后者正是 2026-10-03 修复:用户之后点同意也来得及)
1287
+ log(`(已按 --callback-first-wait 显式启用首轮等待:如果 ${label}内没收到回调,会自动改用设备码模式继续;本地回调监听也会继续保留)`);
1276
1288
  firstWaitTimer = setTimeout(() => {
1277
1289
  if (canHandoff && !deviceDone) {
1278
1290
  handOff('callback_first_wait');