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.
- package/README.md +171 -36
- package/SKILL.md +173 -29
- package/lib/args.mjs +5 -0
- package/lib/cli.mjs +5 -0
- package/lib/cms.mjs +317 -0
- package/lib/commands/auth.mjs +129 -93
- package/lib/commands/billing.mjs +641 -421
- package/lib/commands/cms.mjs +1265 -0
- package/lib/commands/import.mjs +164 -0
- package/lib/commands/index.mjs +83 -4
- package/lib/config.mjs +82 -22
- package/lib/device.mjs +22 -5
- package/lib/index.mjs +5 -0
- package/lib/local-import.mjs +185 -0
- package/lib/login.mjs +97 -85
- package/lib/oauth-urls.mjs +163 -0
- package/lib/runtime.mjs +81 -0
- package/lib/site-state.mjs +137 -0
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
* (详见 `
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
154
|
-
*
|
|
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
|
-
*
|
|
159
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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` ——
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
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'
|
|
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` 不是函数)=
|
|
698
|
-
* `
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
`⚠️
|
|
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
|
-
* ★
|
|
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
|
-
/** ★
|
|
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
|
|
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
|
-
*
|
|
1241
|
-
*
|
|
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
|
-
*
|
|
1259
|
-
* →
|
|
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 ??
|
|
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(
|
|
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');
|