wkgeo-cli 0.2.0 → 0.2.1

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
@@ -40,6 +40,7 @@ import { successPage, errorPage, alreadyDonePage } from './page.mjs';
40
40
  import { createVerifier, challengeOf, createState, safeEqual } from './pkce.mjs';
41
41
  import { writeCredentials, credentialsFromToken } from './credentials.mjs';
42
42
  import { withTrailingSlash } from './config.mjs';
43
+ import { canonicalOauthUrl } from './oauth-urls.mjs';
43
44
  import { proxyRiskHint } from './proxy.mjs';
44
45
  import { CliError, EXIT } from './errors.mjs';
45
46
 
@@ -55,7 +56,8 @@ import { CliError, EXIT } from './errors.mjs';
55
56
  *
56
57
  * ⚠️⚠️ Windows 这条分支踩过一个真机实测的大坑(2026-10-01):
57
58
  * 原来的写法是 `spawn('cmd', ['/c', 'start', '', url])` —— 浏览器**确实弹出来了**,
58
- * 但打开的是光秃秃的 `/admin/oauth/authorize`,**所有 query 参数都没了**,
59
+ * 但打开的是光秃秃的 `/admin/oauth/authorize`(★ 2026-10-02 起授权站是 `/authorize`),
60
+ * **所有 query 参数都没了**,
59
61
  * 授权页显示「授权请求不完整」。
60
62
  *
61
63
  * 机理(必须记住):**在 Windows 的 cmd.exe 里,`&` 是命令分隔符**。
@@ -490,10 +492,10 @@ export function browserOpenedText({ kind = 'auth' } = {}) {
490
492
  * @param {string} [opts.cause] 失败原因(spawn 报的原文),有就附在第一行括号里,方便排障
491
493
  */
492
494
  export function browserFailedHintLines(url, { kind = 'auth', agent = false, cause = '' } = {}) {
493
- const 动作 = kind === 'pay' ? '支付' : '授权';
495
+ const action = kind === 'pay' ? '支付' : '授权';
494
496
  return [
495
497
  '',
496
- `⚠️ 浏览器没能自动打开${cause ? `(${cause})` : ''},请手动复制这个地址到浏览器完成${动作}:`,
498
+ `⚠️ 浏览器没能自动打开${cause ? `(${cause})` : ''},请手动复制这个地址到浏览器完成${action}:`,
497
499
  ` ${url}`,
498
500
  kind === 'pay'
499
501
  ? '(这是我们网站的收银台页面:打开后用微信扫码支付;支付成功后页面会自动尝试关闭,没关就手动关掉即可。)'
@@ -519,10 +521,10 @@ export function browserFailedHintLines(url, { kind = 'auth', agent = false, caus
519
521
  export function browserPasteText(url, { kind = 'auth', agent = false } = {}) {
520
522
  // 已弃用:见上面的参数说明(保留形参只是不想为了一个布尔值去改所有调用方)
521
523
  void agent;
522
- const 收尾 = '(完成后页面会自动关闭;没关就手动关掉即可)';
524
+ const closingHint = '(完成后页面会自动关闭;没关就手动关掉即可)';
523
525
  return kind === 'pay'
524
- ? `已为你打开浏览器,请在那里完成支付(微信扫码):${url}${收尾}`
525
- : `已为你打开浏览器,请在那里完成授权:${url}${收尾}`;
526
+ ? `已为你打开浏览器,请在那里完成支付(微信扫码):${url}${closingHint}`
527
+ : `已为你打开浏览器,请在那里完成授权:${url}${closingHint}`;
526
528
  }
527
529
 
528
530
  /**
@@ -765,25 +767,43 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
765
767
  log(`⚠️ 读取授权服务器元数据失败(${e.message}),改用内置默认授权地址继续`);
766
768
  }
767
769
 
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
- ) {
770
+ /**
771
+ * 服务端声明的授权地址;拿不到就用配置基址 + `/authorize` 兜底。
772
+ * (下面紧接着要把它归一化到 canonical 授权站 —— 见 `canonicalOauthUrl()` 的说明)
773
+ */
774
+ let authorizeBase = (metadata && metadata.authorization_endpoint) || `${config.oauthWebBase}/authorize`;
775
+
776
+ /**
777
+ * ★★ 2026-10-18(域名统一到路径):授权页地址**以配置基址为 canonical**。
778
+ *
779
+ * 服务端 metadata 的 `authorization_endpoint` 由云函数 `oauth` 的 `ADMIN_WEB_BASE`
780
+ * 生成,**线上环境变量里仍是老地址** `https://oauth.wkgeo.com` —— 只改 CLI 的兜底
781
+ * 常量没用,那个字段永远优先。所以这里统一过一道 `canonicalOauthUrl()`:
782
+ * * 平台内的地址 ⇒ 把 pathname 挪到 `config.oauthWebBase`(默认 `https://wkgeo.com/oauth`)下;
783
+ * * 平台外的地址(含 `localhost`,历史上真被坑过)⇒ 回落到配置基址的 `/authorize`。
784
+ * 回滚老站只要设 `WKONGAI_OAUTH_WEB_BASE=https://oauth.wkgeo.com`,不用改代码。
785
+ *
786
+ * ⚠️ 下面两步的顺序不能颠倒:
787
+ * 先归一化(决定"打开哪个站"),再补尾斜杠(决定"302 会不会丢 query")。
788
+ * 尾斜杠规则见 `withTrailingSlash()` 的说明(托管侧对目录路径 302 且丢 query)。
789
+ */
790
+ const canonicalAuthorize = canonicalOauthUrl(
791
+ authorizeBase,
792
+ config.oauthWebBase,
793
+ { fallbackPath: '/authorize' },
794
+ );
795
+ if (canonicalAuthorize.changed) {
779
796
  log(
780
- `⚠️ 服务端返回的授权地址不是本平台域名(${authorizeBase}),已改用 ${config.oauthWebBase}/oauth/authorize`,
797
+ `⚠️ 服务端返回的授权地址不在当前授权站(${authorizeBase}),` +
798
+ `已改用 ${config.oauthWebBase} 下的同一路径:${canonicalAuthorize.url}`,
781
799
  );
782
- authorizeBase = `${config.oauthWebBase}/oauth/authorize`;
783
800
  }
801
+ authorizeBase = canonicalAuthorize.url;
784
802
 
785
803
  /**
786
- * ★ 授权页地址**从构造时就带尾斜杠**(`/admin/oauth/authorize/?client_id=…`)。
804
+ * ★ 授权页地址**从构造时就带尾斜杠**(`…/authorize/?client_id=…`;
805
+ * 老站是 `/admin/oauth/authorize/?client_id=…`,子域时代是 `/authorize/?…`,
806
+ * 2026-10-18 路径化后是 `/oauth/authorize/?…`)。
787
807
  *
788
808
  * 为什么:前端是静态导出(每个路由一个目录),托管侧(OSS 静态托管 / 阿里云 ESA)
789
809
  * 对**不带尾斜杠**的目录路径会 302,而那个 302 **不携带 query** ——
@@ -900,9 +920,9 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
900
920
  const handOff = (reason) => {
901
921
  if (settled || switchReason) return;
902
922
  switchReason = reason;
903
- const 原因 = reason === 'callback_first_wait' ? '首轮等待没收到回调' : '等待超时';
923
+ const reasonText = reason === 'callback_first_wait' ? '首轮等待没收到回调' : '等待超时';
904
924
  log('');
905
- log(`⚠️ 本地回调没有等到结果(${原因}):已自动改用【设备码模式】继续(不需干等,两条路并行)。`);
925
+ log(`⚠️ 本地回调没有等到结果(${reasonText}):已自动改用【设备码模式】继续(不需干等,两条路并行)。`);
906
926
  log(
907
927
  'ℹ️ 本机回调监听**继续保留**:如果你之后才在浏览器里点【同意授权】,那一次照样能直接完成登录' +
908
928
  '(不会再出现"连接被拒绝 / ERR_CONNECTION_REFUSED")。',
@@ -0,0 +1,163 @@
1
+ /**
2
+ * 授权站页面地址的「归一化」——**唯一入口**。
3
+ *
4
+ * ## 为什么需要它(2026-10-18 域名统一到路径)
5
+ * 平台把三个业务前端统一到了路径:
6
+ * * 客户后台 `https://wkgeo.com/cms/` (老:`https://cms.wkgeo.com/`)
7
+ * * 会员站 `https://wkgeo.com/user/` (老:`https://user.wkgeo.com/`)
8
+ * * 授权站 `https://wkgeo.com/oauth/` (老:`https://oauth.wkgeo.com/`)
9
+ * * 收银台 `https://pay.wkgeo.com/` —— **支付独立,没动**
10
+ * 老地址一律**继续可用**(老板要求并存),但 CLI 的 canonical 地址要切到新路径。
11
+ *
12
+ * ## 问题在哪
13
+ * CLI 的授权地址有两个来源:
14
+ * 1. 线上 metadata 的 `authorization_endpoint` / `device_verification_uri`
15
+ * —— 由云函数 `tencent/functions/oauth/index.js` 的 `ADMIN_WEB_BASE` 决定,
16
+ * 它的**线上环境变量里还写着老地址**,所以在服务端切完并重新部署之前,
17
+ * 这两个字段一直返回 `https://oauth.wkgeo.com/...`;
18
+ * 2. CLI 自己的兜底 `${config.oauthWebBase}/authorize`。
19
+ * 只改 (2) 没用 —— (1) 永远优先,用户看到的还是老子域。
20
+ *
21
+ * ## 采取的口径
22
+ * **配置基址(`WKONGAI_OAUTH_WEB_BASE` / `--web-base`)是 canonical,服务端只提供"路径"。**
23
+ * 拿到任何平台内的地址后,把它的 pathname 挪到当前配置的基址下:
24
+ * 老站 `/authorize/` + 新基址 `https://wkgeo.com/oauth` → `https://wkgeo.com/oauth/authorize/`
25
+ * 新站 `/oauth/authorize/` + 老基址 `https://oauth.wkgeo.com` → `https://oauth.wkgeo.com/authorize/`
26
+ * 这样:
27
+ * * **默认走新路径**(`DEFAULT_OAUTH_WEB_BASE` 已改成 `https://wkgeo.com/oauth`);
28
+ * * **回滚只要一条环境变量**:`WKONGAI_OAUTH_WEB_BASE=https://oauth.wkgeo.com`
29
+ * (不用改代码、不用发版);
30
+ * * 服务端将来把 `ADMIN_WEB_BASE` 切过来之后,这里**不动**也仍然正确(幂等)。
31
+ *
32
+ * ## 边界(不许放宽)
33
+ * * 只处理 http/https;
34
+ * * 只认**本平台域名**:`wkgeo.com` / `*.wkgeo.com` / `wkgeo.cn` / `*.wkgeo.cn`
35
+ * —— 历史上真的被 `authorization_endpoint=http://localhost:8000` 坑过一次,
36
+ * 打开用户本机端口是**最坏**的一种失败;
37
+ * * 平台之外的地址一律不跟随,直接回落到基址 + `fallbackPath`,并如实返回 `reason`
38
+ * 让调用方打日志(不要静默纠正)。
39
+ */
40
+
41
+ /** 平台根域(裸域也认:路径化之后授权站的 host 就是 `wkgeo.com`) */
42
+ const PLATFORM_ROOTS = ['wkgeo.com', 'wkgeo.cn'];
43
+
44
+ /**
45
+ * 已知的「站前缀」——路径化之后各站挂在同一个域的不同前缀下。
46
+ *
47
+ * 只列 `/oauth`:它是**授权站自己**的前缀。归一化时如果目标基址不带 `/oauth`(回滚到
48
+ * 老站 `oauth.wkgeo.com`,它的 `/authorize` 在站根),就必须把它剥掉,
49
+ * 否则会拼出 `https://oauth.wkgeo.com/oauth/authorize/`(老站没有这个路由 → 404)。
50
+ * 其它站前缀(`/user`、`/cms`)不该出现在授权地址里,不做猜测。
51
+ */
52
+ const OAUTH_PATH_PREFIX = '/oauth';
53
+
54
+ /**
55
+ * hostname 是否属于本平台。
56
+ * @param {string} hostname
57
+ * @returns {boolean}
58
+ */
59
+ export function isPlatformHost(hostname) {
60
+ const h = String(hostname || '').toLowerCase().replace(/\.$/, '');
61
+ if (!h) return false;
62
+ return PLATFORM_ROOTS.some((root) => h === root || h.endsWith(`.${root}`));
63
+ }
64
+
65
+ /** `https://a/b/` 的路径部分,去掉结尾斜杠;空路径返回 `''` */
66
+ function trimBasePath(pathname) {
67
+ return String(pathname || '').replace(/\/+$/, '');
68
+ }
69
+
70
+ /**
71
+ * 把 pathname 里与基址重复的段、以及已知站前缀剥掉,只留下"站内路由"。
72
+ * 例:基址 `/oauth`,输入 `/oauth/authorize/` → `/authorize/`
73
+ * 基址 ``(老站根),输入 `/oauth/authorize/` → `/authorize/`
74
+ */
75
+ function siteRelativePath(pathname, basePath) {
76
+ let p = String(pathname || '/');
77
+ if (basePath && (p === basePath || p.startsWith(`${basePath}/`))) {
78
+ p = p.slice(basePath.length) || '/';
79
+ }
80
+ if (basePath !== OAUTH_PATH_PREFIX && (p === OAUTH_PATH_PREFIX || p.startsWith(`${OAUTH_PATH_PREFIX}/`))) {
81
+ p = p.slice(OAUTH_PATH_PREFIX.length) || '/';
82
+ }
83
+ if (!p.startsWith('/')) p = `/${p}`;
84
+ return p;
85
+ }
86
+
87
+ /**
88
+ * 归一化一个「用户浏览器要打开的授权站页面地址」。
89
+ *
90
+ * @param {string} rawUrl 服务端给的地址(可能是老站 / 新路径 / 本机地址 / 垃圾)
91
+ * @param {string} oauthWebBase canonical 授权站基址(`config.oauthWebBase`)
92
+ * @param {object} [opts]
93
+ * @param {string} [opts.fallbackPath] 拿不到可用地址时用的站内路径(`/authorize` / `/device`)
94
+ * @returns {{url: string, changed: boolean, reason: 'ok'|'other_site'|'not_platform'|'unparsable'}}
95
+ * * `changed` —— 地址被改写过(调用方应打一条提示,别静默)
96
+ * * `reason` —— 为什么改:
97
+ * `other_site` 平台内另一个站(典型:老 `oauth.wkgeo.com`)
98
+ * `not_platform` 平台之外的域名(含 `localhost` / 局域网 IP)
99
+ * `unparsable` 根本不是 URL
100
+ */
101
+ export function canonicalOauthUrl(rawUrl, oauthWebBase, { fallbackPath = '/' } = {}) {
102
+ const baseText = String(oauthWebBase || '').trim();
103
+ if (!baseText) {
104
+ // 没有基址就什么都不做 —— 调用方自己的默认值已经兜住了
105
+ return { url: String(rawUrl || ''), changed: false, reason: 'ok' };
106
+ }
107
+
108
+ let base;
109
+ try {
110
+ base = new URL(baseText);
111
+ } catch {
112
+ return { url: String(rawUrl || ''), changed: false, reason: 'ok' };
113
+ }
114
+ const basePath = trimBasePath(base.pathname);
115
+
116
+ /** 用基址 + 站内路径拼出最终地址(保留基址自身可能带的端口) */
117
+ const buildFromBase = (relPath, search = '', hash = '') =>
118
+ `${base.origin}${basePath}${relPath === '/' ? '/' : relPath}${search}${hash}`;
119
+
120
+ let u;
121
+ try {
122
+ u = new URL(String(rawUrl || ''));
123
+ } catch {
124
+ return { url: buildFromBase(fallbackPath), changed: true, reason: 'unparsable' };
125
+ }
126
+
127
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
128
+ return { url: buildFromBase(fallbackPath), changed: true, reason: 'not_platform' };
129
+ }
130
+
131
+ /*
132
+ * 本机/局域网地址:历史上 `authorization_endpoint` 真被配成 `http://localhost:8000`
133
+ * (云函数默认值没配环境变量),打开的是**用户自己机器**的端口 —— 必须纠偏。
134
+ */
135
+ const host = u.hostname.toLowerCase();
136
+ const isLoopback =
137
+ host === 'localhost' ||
138
+ host === '127.0.0.1' ||
139
+ host === '::1' ||
140
+ host === '[::1]' ||
141
+ /^127\./.test(host) ||
142
+ /^10\./.test(host) ||
143
+ /^192\.168\./.test(host) ||
144
+ /^172\.(1[6-9]|2\d|3[01])\./.test(host);
145
+
146
+ if (!isLoopback && !isPlatformHost(host)) {
147
+ return { url: buildFromBase(fallbackPath), changed: true, reason: 'not_platform' };
148
+ }
149
+ if (isLoopback) {
150
+ return { url: buildFromBase(fallbackPath), changed: true, reason: 'not_platform' };
151
+ }
152
+
153
+ const relPath = siteRelativePath(u.pathname, basePath);
154
+ const normalized = buildFromBase(relPath, u.search, u.hash);
155
+
156
+ /**
157
+ * `changed` 的判定:**host 或前缀变了**才算改写。
158
+ * 同一个地址原样返回(服务端已经配对了、或者已经是新路径)时不能让调用方误报"纠正过"。
159
+ */
160
+ const sameHost = u.origin === base.origin;
161
+ const changed = !sameHost || u.pathname !== `${basePath}${relPath}`;
162
+ return { url: normalized, changed, reason: changed ? 'other_site' : 'ok' };
163
+ }
package/lib/runtime.mjs CHANGED
@@ -17,6 +17,8 @@
17
17
  import { parseArgv, coerceFlags, checkPositionals, resolveCommand } from './args.mjs';
18
18
  import { resolveConfig } from './config.mjs';
19
19
  import { createApi } from './api.mjs';
20
+ import { createCms } from './cms.mjs';
21
+ import { readSiteState, writeSiteState } from './site-state.mjs';
20
22
  import { requireCredentials, readCredentials } from './credentials.mjs';
21
23
  import { createPalette, renderTable, safeJson } from './format.mjs';
22
24
  import { UsageError, EXIT } from './errors.mjs';
@@ -169,6 +171,14 @@ export function createRuntime({
169
171
  * 或改名 —— 那是并行跑的其它用例依赖的真文件)。单测传入一个不存在的路径即可稳定覆盖该分支。
170
172
  */
171
173
  skillUrl = undefined,
174
+ /**
175
+ * `runScript` —— **同步**跑一个外部命令的注入点(目前只有 `wkgeo import` 用)。
176
+ *
177
+ * 为什么和 `spawnImpl` 分开:`spawnImpl` 是 `child_process.spawn`(异步、给浏览器用的),
178
+ * 而导入是一个"人盯着看进度"的阻塞动作,默认实现是 `spawnSync`。
179
+ * 单测注入它就能验证"参数是怎么拼的、退出码怎么转述",**不真的去起子进程抓网站**。
180
+ */
181
+ runScript = undefined,
172
182
  } = {}) {
173
183
  const startedAt = now();
174
184
  const { positionals, flags: rawFlags } = parseArgv(argv);
@@ -196,6 +206,14 @@ export function createRuntime({
196
206
 
197
207
  const api = createApi({ config, fetchImpl });
198
208
 
209
+ /**
210
+ * CMS 客户端(栏目 / 内容 / 素材 / 模板)。
211
+ *
212
+ * 它的地址、鉴权头、错误翻译都在 `lib/cms.mjs` 里;这里只把运行时依赖给它。
213
+ * 懒不懒加载无所谓:`createCms` 只是记住 base 与超时,不发任何请求。
214
+ */
215
+ const cms = createCms({ config, fetchImpl });
216
+
199
217
  /**
200
218
  * 代理检测结果:**懒加载 + 记忆化**。
201
219
  *
@@ -223,8 +241,12 @@ export function createRuntime({
223
241
  const meta = () => {
224
242
  const m = {
225
243
  base: config.base,
244
+ /** CMS 面是另一个地址:出问题时"打的是哪个 CMS"必须能从信封里看出来 */
245
+ cms_base: config.cmsBase,
226
246
  client_id: config.clientId,
227
247
  credentials_file: config.credentialsFile,
248
+ /** 本地"当前站点"记录(排障时要能一眼看到它到底读的哪个文件) */
249
+ site_file: config.siteStateFile,
228
250
  duration_ms: Math.max(0, now() - startedAt),
229
251
  };
230
252
  // 只有"真的探测过"才带这个字段:没探测过 = 结论未知,不该编一个 false 出来
@@ -244,6 +266,10 @@ export function createRuntime({
244
266
  env,
245
267
  now,
246
268
  meta,
269
+ /** CMS 接口基址(`site use` / 报错提示里要用;**不是**平台面那个 base) */
270
+ cmsBase: config.cmsBase,
271
+ /** 本地"当前站点"记录文件(`wkgeo site use` 写它) */
272
+ siteStateFile: config.siteStateFile,
247
273
  /** ★ 代理检测(懒加载 + 记忆化;见上面 proxyState 的说明) */
248
274
  proxyInfo,
249
275
  /** 透传给 login/device 的依赖(见上面 createRuntime 的说明) */
@@ -276,6 +302,61 @@ export function createRuntime({
276
302
  const creds = opts.credentials || runtime.requireAuth({ allowExpired: opts.allowExpired });
277
303
  return api.data(action, payload, { token: creds.access_token });
278
304
  },
305
+ /**
306
+ * **本次要操作哪个站**(CMS 命令的站点来源)。
307
+ *
308
+ * 优先级(高 → 低):
309
+ * 1. `--site <站点ID>`(一次性覆盖,不改本地记录);
310
+ * 2. 环境变量 `WKONGAI_SITE`(CI / 脚本里固定下来);
311
+ * 3. 本地 `wkgeo site use` 选中的那个(`~/.wkongai/config.json`);
312
+ * 4. **都没有 ⇒ `id: ''`(不带 `x-cms-site` 头)**,由服务端按令牌的成员关系判定
313
+ * (一个成员站 → 就用它;多个 → 服务端回 400 让我们指定)。
314
+ *
315
+ * ⚠️ 为什么第 4 档不直接报错:Bearer 桥**故意**支持"只有一个成员站就自动用它"
316
+ * (见 docs/功能-CMS认证.md 三·优先级 3),单站用户不该被迫先跑一次 `site use`。
317
+ * 但**多站**时绝不替用户猜:服务端回 400,`lib/cms.mjs` 会把它翻译成
318
+ * "先 `wkgeo site use <站点ID>` 或加 `--site`"(`kind: cms_site_required`)。
319
+ *
320
+ * ⚠️ 为什么 `--site` 必须是**站点 id(uuid)**:CMS 的 `x-cms-site` 先按 `id::text` 查,
321
+ * 再按 Host 兜底 —— CLI 没有 Host,传域名等于没传(会被当成"没指定")。
322
+ * 这种"看起来传了其实没生效"的事最难查,所以在本地就挡住并告诉他改用 `site use <域名>`。
323
+ *
324
+ * @returns {{id:string, label:string|null, source:'flag'|'env'|'local'|'none'}}
325
+ * @throws {UsageError} `--site` / `WKONGAI_SITE` 不是 uuid
326
+ */
327
+ cmsSite() {
328
+ const flagSite = flags.site !== undefined && flags.site !== '' ? String(flags.site).trim() : '';
329
+ const envSite = String((env && env.WKONGAI_SITE) || '').trim();
330
+ const explicit = flagSite || envSite;
331
+ if (explicit) {
332
+ if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(explicit)) {
333
+ throw new UsageError(
334
+ `${flagSite ? '--site' : 'WKONGAI_SITE'} 要填**站点 ID(uuid)**,收到:${explicit}\n` +
335
+ `传域名请先解析成 ID:\`wkgeo site use ${explicit}\`(之后不带 --site 就直接用这个站)`,
336
+ );
337
+ }
338
+ return { id: explicit, label: null, source: flagSite ? 'flag' : 'env' };
339
+ }
340
+ const saved = readSiteState(config.siteStateFile);
341
+ if (saved && saved.id) return { id: saved.id, label: saved.label || null, source: 'local' };
342
+ return { id: '', label: null, source: 'none' };
343
+ },
344
+ /** 调 CMS 动作:自动带 `Authorization: Bearer <token>` +(有站点时)`x-cms-site` */
345
+ cmsCall(action, payload = {}) {
346
+ const creds = runtime.requireAuth();
347
+ const site = runtime.cmsSite();
348
+ return cms.call(action, payload, { token: creds.access_token, siteId: site.id || undefined });
349
+ },
350
+ /** 读本地"当前站点"记录(没选过 = null) */
351
+ readSiteState() {
352
+ return readSiteState(config.siteStateFile);
353
+ },
354
+ /** 写本地"当前站点"记录(给了就覆盖) */
355
+ writeSiteState(one) {
356
+ return writeSiteState(config.siteStateFile, one, { now: now() });
357
+ },
358
+ /** ★ `wkgeo import` 起子进程的注入点(生产 = `spawnSync`,见 lib/local-import.mjs) */
359
+ runScript,
279
360
  /** 成功收尾(JSON 模式输出信封;人类模式什么都不做,由命令自己打印) */
280
361
  done(data) {
281
362
  out.emitOk(spec.name, data);