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.
- package/README.md +129 -12
- package/SKILL.md +131 -4
- package/lib/args.mjs +5 -0
- package/lib/cli.mjs +5 -0
- package/lib/cms.mjs +317 -0
- package/lib/commands/auth.mjs +42 -11
- 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 +42 -22
- 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,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `wkgeo import` —— 把老网站内容搬进 CMS。
|
|
3
|
+
*
|
|
4
|
+
* ## 现在的真实能力(**如实写,不画饼**)
|
|
5
|
+
* 后端**还没有**带鉴权的 HTTP 导入入口(`tencent/functions/cms` 的动作表里没有 import 类动作),
|
|
6
|
+
* 所以这条命令走的是**本机运维脚本** `tencent/cloudbase/import-site.mjs`:
|
|
7
|
+
* * 它直连数据库(`service_role`),**需要运维权限**;
|
|
8
|
+
* * 默认 **dry-run**(只抓取 + 打印计划 + 写中间 JSON),加 `--apply` 才写库;
|
|
9
|
+
* * 导入内容默认 `draft`,核对完再发布。
|
|
10
|
+
*
|
|
11
|
+
* ## 为什么还要把它做成命令
|
|
12
|
+
* AI / 运维要的是"一条命令、参数一致、默认安全":
|
|
13
|
+
* `wkgeo import --url=https://old-site.com --json` 就是"先看看会导入什么",
|
|
14
|
+
* 看完再 `--apply`。脚本的参数一个字母都没改(见 `buildImportArgs`),
|
|
15
|
+
* 所以文档里已有的用法仍然成立。
|
|
16
|
+
*
|
|
17
|
+
* ## 安全约束(**写在命令里,不交给 AI 自觉**)
|
|
18
|
+
* 1. 默认 dry-run:不带 `--apply` 永不写库;
|
|
19
|
+
* 2. 目标站必须明确(`--site` 或本地 `wkgeo site use` 选中的那个)——
|
|
20
|
+
* **不允许**让脚本自己猜,导错站的代价太高;
|
|
21
|
+
* 3. 脚本找不到时**明确说是缺什么**(不是"未知错误")。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { CliError, UsageError } from '../errors.mjs';
|
|
25
|
+
import { buildImportArgs, resolveImportScript, runNodeCommand, tailText } from '../local-import.mjs';
|
|
26
|
+
|
|
27
|
+
export const importCommand = {
|
|
28
|
+
name: 'import',
|
|
29
|
+
summary: '把老网站内容导入本平台(默认 dry-run;需要运维权限的本机脚本)',
|
|
30
|
+
usage:
|
|
31
|
+
'wkgeo import --url=<老站网址> [--site <站点ID>] [--dry-run | --apply] [--status draft|published]\n' +
|
|
32
|
+
' [--out <中间JSON>] [--max-pages 40] [--delay 250] [--include 正则] [--exclude 正则] [--overwrite] [--no-probe]',
|
|
33
|
+
flags: {
|
|
34
|
+
url: { type: 'string', required: true, desc: '老站首页地址(http/https)' },
|
|
35
|
+
site: { type: 'string', desc: '目标站(站点 ID / 域名 / 站点名;默认用 `wkgeo site use` 选中的站点)' },
|
|
36
|
+
'dry-run': { type: 'boolean', desc: '只抓取 + 打印计划,不写库(**默认就是它**)' },
|
|
37
|
+
apply: { type: 'boolean', desc: '真写库:建栏目 / 传素材 / 建内容 / 写正文(与 --dry-run 二选一)' },
|
|
38
|
+
status: { type: 'string', desc: '导入内容的初始状态:draft 草稿(默认)/ published 已发布' },
|
|
39
|
+
out: { type: 'string', desc: '中间 JSON 的落盘路径(默认 .tmp-build/import-<host>.json)' },
|
|
40
|
+
'max-pages': { type: 'string', desc: '最多抓多少页(默认 40)' },
|
|
41
|
+
delay: { type: 'string', desc: '每页间隔毫秒(默认 250;robots 的 Crawl-delay 更大时以它为准)' },
|
|
42
|
+
include: { type: 'string', desc: '只抓路径匹配这个正则的页面(首页总会抓)' },
|
|
43
|
+
exclude: { type: 'string', desc: '跳过路径匹配这个正则的页面' },
|
|
44
|
+
overwrite: { type: 'boolean', desc: '已存在的同名内容也覆盖正文(默认跳过,只增不毁)' },
|
|
45
|
+
'no-probe': { type: 'boolean', desc: '不做可访问性探测(脚本的 --no-probe)' },
|
|
46
|
+
},
|
|
47
|
+
examples: [
|
|
48
|
+
'wkgeo import --url=https://old-site.com --json # 先看计划(默认 dry-run,不写库)',
|
|
49
|
+
'wkgeo import --url=https://old-site.com --site 8f3c… --apply --json',
|
|
50
|
+
'wkgeo import --url=https://old-site.com --max-pages 80 --delay 500 --out .tmp-build/plan.json',
|
|
51
|
+
],
|
|
52
|
+
notes: [
|
|
53
|
+
'⚠️ **需要运维权限**:当前这条命令调用的是本仓库的本机脚本 `tencent/cloudbase/import-site.mjs`(service_role 直连数据库),' +
|
|
54
|
+
'客户 / AI 端不应当用它;带鉴权的 HTTP 导入入口后端还没做(见 docs/规划-CLI与AI建站.md §5.2)。',
|
|
55
|
+
'默认 **dry-run**:不带 `--apply` 永不写库;导入内容默认 `draft`(`--status published` 才直接上线)。',
|
|
56
|
+
'脚本位置可用 `WKONGAI_IMPORT_SCRIPT` 指定;找不到脚本时会明确报"没有本地导入器"。',
|
|
57
|
+
'`import` 不转发全局的 `--timeout`(那个是登录回调用的),单请求超时由脚本自己的默认值决定。',
|
|
58
|
+
],
|
|
59
|
+
async run(ctx) {
|
|
60
|
+
const { out, flags } = ctx;
|
|
61
|
+
|
|
62
|
+
if (flags.apply === true && flags.dryRun === true) {
|
|
63
|
+
throw new UsageError('--apply 与 --dry-run 只能二选一(默认就是 dry-run)');
|
|
64
|
+
}
|
|
65
|
+
const url = String(flags.url || '').trim();
|
|
66
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
67
|
+
throw new UsageError(`--url 要以 http:// 或 https:// 开头,收到:${url || '(空)'}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 目标站:显式 `--site` > 本地选中的站。都没有就报错(**绝不猜**) */
|
|
71
|
+
let siteRef = String(flags.site || '').trim();
|
|
72
|
+
let siteSource = siteRef ? 'flag' : '';
|
|
73
|
+
if (!siteRef) {
|
|
74
|
+
const local = ctx.readSiteState();
|
|
75
|
+
if (local && local.id) {
|
|
76
|
+
siteRef = local.id;
|
|
77
|
+
siteSource = 'local';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!siteRef) {
|
|
81
|
+
throw new UsageError(
|
|
82
|
+
'还没有指定目标站:加 `--site <站点ID或域名>`,或先 `wkgeo site use <站点ID>` 选一个\n' +
|
|
83
|
+
'(导入会往这个站写栏目/内容,导错站的代价很高,所以不替你猜)',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 找脚本。⚠️ **不传 moduleUrl**:让 `resolveImportScript` 用它自己的模块位置做相对解析
|
|
89
|
+
* (本文件在 `cli/lib/commands/`,层级与 `cli/lib/` 不同,传错会解析到错目录)。
|
|
90
|
+
*/
|
|
91
|
+
const resolved = resolveImportScript({ env: ctx.env, cwd: process.cwd() });
|
|
92
|
+
if (resolved.error) {
|
|
93
|
+
throw new CliError(resolved.error.message, { kind: resolved.error.kind, hint: resolved.error.hint });
|
|
94
|
+
}
|
|
95
|
+
const script = resolved.script;
|
|
96
|
+
|
|
97
|
+
const apply = flags.apply === true;
|
|
98
|
+
const args = buildImportArgs({
|
|
99
|
+
url,
|
|
100
|
+
site: siteRef,
|
|
101
|
+
apply,
|
|
102
|
+
status: flags.status,
|
|
103
|
+
out: flags.out,
|
|
104
|
+
maxPages: flags.maxPages,
|
|
105
|
+
delay: flags.delay,
|
|
106
|
+
include: flags.include,
|
|
107
|
+
exclude: flags.exclude,
|
|
108
|
+
overwrite: flags.overwrite === true,
|
|
109
|
+
noProbe: flags.noProbe === true,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (!out.json) {
|
|
113
|
+
out.head(`老站导入(${apply ? '写库 --apply' : 'dry-run,不写库'})`);
|
|
114
|
+
out.line(` 老站:${url}`);
|
|
115
|
+
out.line(` 目标站:${siteRef}${siteSource === 'local' ? '(来自 `wkgeo site use`)' : ''}`);
|
|
116
|
+
out.line(` 脚本:${script}`);
|
|
117
|
+
out.warn(' 这一步走的是**本机运维脚本**(service_role 直连数据库):需要运维/管理员权限。');
|
|
118
|
+
out.line('');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const runScript = typeof ctx.runScript === 'function' ? ctx.runScript : runNodeCommand;
|
|
122
|
+
const res = runScript(process.execPath, [script, ...args], {
|
|
123
|
+
/** JSON 模式:子进程输出收进信封(否则 stdout 里会混进一堆日志,破坏"只有一个 JSON"的约定) */
|
|
124
|
+
stdio: out.json ? ['inherit', 'pipe', 'pipe'] : 'inherit',
|
|
125
|
+
encoding: 'utf8',
|
|
126
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
127
|
+
cwd: process.cwd(),
|
|
128
|
+
env: ctx.env || process.env,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const status = res && typeof res.status === 'number' ? res.status : 1;
|
|
132
|
+
const data = {
|
|
133
|
+
url,
|
|
134
|
+
site: siteRef,
|
|
135
|
+
site_source: siteSource,
|
|
136
|
+
dry_run: !apply,
|
|
137
|
+
apply,
|
|
138
|
+
script,
|
|
139
|
+
exit_code: status,
|
|
140
|
+
/** 只在 JSON 模式里有内容(人类模式是直通终端) */
|
|
141
|
+
log_tail: out.json ? tailText(res && res.stdout, 4000) || '' : '',
|
|
142
|
+
stderr_tail: out.json ? tailText(res && res.stderr, 2000) || '' : '',
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
if (status !== 0) {
|
|
146
|
+
const tail = tailText((res && (res.stderr || res.stdout)) || '', 800).trim();
|
|
147
|
+
throw new CliError(`导入脚本以退出码 ${status} 结束${tail ? `:${tail.split('\n').slice(-3).join(' / ')}` : ''}`, {
|
|
148
|
+
kind: 'import_failed',
|
|
149
|
+
code: status,
|
|
150
|
+
hint:
|
|
151
|
+
'看上面的日志定位(常见:--url 打不开 / 目标站不存在 / 没有数据库凭证 / 触发了 robots 限制)。' +
|
|
152
|
+
'dry-run 阶段失败不会动数据库,可以放心重试。',
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!out.json) {
|
|
157
|
+
out.ok(apply ? '导入完成(已写库)' : '计划已生成(dry-run,未写库)');
|
|
158
|
+
out.info(apply ? ' 导入的内容默认是草稿:核对后用 `wkgeo contents list --status draft` 查看,再 `--status published` 上线。' : ' 下一步:核对计划无误后加 `--apply` 真写库(同一份 plan,看到的和写的一致)。');
|
|
159
|
+
} else {
|
|
160
|
+
ctx.done(data);
|
|
161
|
+
}
|
|
162
|
+
return data;
|
|
163
|
+
},
|
|
164
|
+
};
|
package/lib/commands/index.mjs
CHANGED
|
@@ -9,7 +9,28 @@
|
|
|
9
9
|
import { GLOBAL_FLAGS } from '../args.mjs';
|
|
10
10
|
import { loginCommand, logoutCommand, whoamiCommand, configCommand } from './auth.mjs';
|
|
11
11
|
import { sitesListCommand, sitesCreateCommand, sitesDeleteCommand } from './sites.mjs';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
siteUseCommand,
|
|
14
|
+
categoriesListCommand,
|
|
15
|
+
categoriesGetCommand,
|
|
16
|
+
categoriesCreateCommand,
|
|
17
|
+
categoriesUpdateCommand,
|
|
18
|
+
categoriesDeleteCommand,
|
|
19
|
+
categoriesBindTemplateCommand,
|
|
20
|
+
contentsListCommand,
|
|
21
|
+
contentsGetCommand,
|
|
22
|
+
contentsCreateCommand,
|
|
23
|
+
contentsUpdateCommand,
|
|
24
|
+
contentsDeleteCommand,
|
|
25
|
+
assetsListCommand,
|
|
26
|
+
assetsUploadCommand,
|
|
27
|
+
templatesListCommand,
|
|
28
|
+
templatesGetCommand,
|
|
29
|
+
templatesSaveCommand,
|
|
30
|
+
publishCommand,
|
|
31
|
+
} from './cms.mjs';
|
|
32
|
+
import { importCommand } from './import.mjs';
|
|
33
|
+
import { walletCommand, walletTxnsCommand, walletRechargeCommand, walletRechargeLinkCommand } from './billing.mjs';
|
|
13
34
|
import {
|
|
14
35
|
productsListCommand,
|
|
15
36
|
ordersCreateCommand,
|
|
@@ -28,9 +49,42 @@ export const COMMANDS = [
|
|
|
28
49
|
sitesCreateCommand,
|
|
29
50
|
sitesListCommand,
|
|
30
51
|
sitesDeleteCommand,
|
|
52
|
+
/**
|
|
53
|
+
* ★ 2026-10-18(CMS 面):先选站,再操作内容。
|
|
54
|
+
* `site use` 排在最前面 —— 它是后面所有 CMS 命令的前置(后端只认站点成员,
|
|
55
|
+
* 而 CLI 没有 Cookie 会话,站点上下文只能来自 `--site` 或本地这条记录)。
|
|
56
|
+
*/
|
|
57
|
+
siteUseCommand,
|
|
58
|
+
categoriesListCommand,
|
|
59
|
+
categoriesGetCommand,
|
|
60
|
+
categoriesCreateCommand,
|
|
61
|
+
categoriesUpdateCommand,
|
|
62
|
+
categoriesDeleteCommand,
|
|
63
|
+
categoriesBindTemplateCommand,
|
|
64
|
+
contentsListCommand,
|
|
65
|
+
contentsGetCommand,
|
|
66
|
+
contentsCreateCommand,
|
|
67
|
+
contentsUpdateCommand,
|
|
68
|
+
contentsDeleteCommand,
|
|
69
|
+
assetsListCommand,
|
|
70
|
+
assetsUploadCommand,
|
|
71
|
+
templatesListCommand,
|
|
72
|
+
templatesGetCommand,
|
|
73
|
+
templatesSaveCommand,
|
|
74
|
+
publishCommand,
|
|
75
|
+
/**
|
|
76
|
+
* `import`:老站导入。**当前走本机运维脚本**(需要 service_role 权限),
|
|
77
|
+
* 带鉴权的 HTTP 入口还在后端待办 —— 这是唯一一条"不在云函数里"的命令(见命令自己的说明)。
|
|
78
|
+
*/
|
|
79
|
+
importCommand,
|
|
31
80
|
walletCommand,
|
|
32
81
|
walletTxnsCommand,
|
|
33
82
|
walletRechargeCommand,
|
|
83
|
+
/**
|
|
84
|
+
* ★ 2026-10-02(方案 A):**独立充值页**链接 —— 用户在收银台上自己选金额并支付。
|
|
85
|
+
* 与 `wallet recharge` 并列(那条是"金额定死在命令行里"的老路,行为一字未改)。
|
|
86
|
+
*/
|
|
87
|
+
walletRechargeLinkCommand,
|
|
34
88
|
productsListCommand,
|
|
35
89
|
ordersCreateCommand,
|
|
36
90
|
ordersCheckCommand,
|
|
@@ -96,6 +150,17 @@ export function renderHelp(name = null) {
|
|
|
96
150
|
out.push(line('命令参数:'));
|
|
97
151
|
for (const [k, v] of own) out.push(line(` --${k}${v.type === 'boolean' ? '' : ' <值>'}${v.required ? '(必填)' : ''} ${v.desc || ''}`));
|
|
98
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* 示例(2026-10-18 起每条新命令都带上)。
|
|
155
|
+
*
|
|
156
|
+
* 为什么帮助里必须有示例:AI 端拿到 `--help` 之后要能**直接照抄一条命令**跑起来;
|
|
157
|
+
* 只有参数名和一句说明时,它还得自己推参数顺序和取值形态(容易把 `--site` 填成域名)。
|
|
158
|
+
*/
|
|
159
|
+
if (spec.examples && spec.examples.length) {
|
|
160
|
+
out.push('');
|
|
161
|
+
out.push(line('示例:'));
|
|
162
|
+
for (const e of spec.examples) out.push(line(` ${e}`));
|
|
163
|
+
}
|
|
99
164
|
/**
|
|
100
165
|
* 命令自带的"特别说明"(可选)。目前只有 `skill` 用 ——
|
|
101
166
|
* 它**不受**"非终端自动 JSON"约定约束,必须在自己的帮助里说清楚,
|
|
@@ -107,7 +172,7 @@ export function renderHelp(name = null) {
|
|
|
107
172
|
for (const n of spec.notes) out.push(line(` ${n}`));
|
|
108
173
|
}
|
|
109
174
|
out.push('');
|
|
110
|
-
out.push(line('通用参数:--json / --text / --base
|
|
175
|
+
out.push(line('通用参数:--json / --text / --base(平台面)/ --cms-base(CMS 面)/ --client-id / --credentials / --verbose / --help'));
|
|
111
176
|
out.push(line('(默认:输出到管道/非终端时自动使用 --json)'));
|
|
112
177
|
out.push('');
|
|
113
178
|
out.push(line('带单位的通用参数(写清单位,避免"600 被当成 600 毫秒"这类误解):'));
|
|
@@ -129,7 +194,11 @@ export function renderHelp(name = null) {
|
|
|
129
194
|
out.push('');
|
|
130
195
|
for (const [g, list] of groups) {
|
|
131
196
|
out.push(`${g}:`);
|
|
132
|
-
|
|
197
|
+
/**
|
|
198
|
+
* 列宽给到 26:`categories bind-template`(24 字符)是现在最长的命令名,
|
|
199
|
+
* 还按 16 排会让它把说明文字顶到下一列去(2026-10-18 加 CMS 命令时发现)。
|
|
200
|
+
*/
|
|
201
|
+
for (const c of list) out.push(` ${c.name.padEnd(26)} ${c.summary}`);
|
|
133
202
|
out.push('');
|
|
134
203
|
}
|
|
135
204
|
out.push('常用示例:');
|
|
@@ -140,8 +209,16 @@ export function renderHelp(name = null) {
|
|
|
140
209
|
out.push(' wkgeo orders create --product storage_mb --units 1 # 下单(余额够就当场付清)');
|
|
141
210
|
out.push(' wkgeo wallet # 查余额 / wallet txns 看流水');
|
|
142
211
|
out.push(' wkgeo wallet recharge --amount-cents 1000 # 充值下单(微信扫码,需人工支付)');
|
|
212
|
+
out.push(' wkgeo wallet recharge-link # 生成独立充值页链接(用户在网页上自己选金额)');
|
|
143
213
|
out.push(' wkgeo orders check <订单ID> # 查支付结果 + 最新余额');
|
|
144
214
|
out.push(' wkgeo orders list # 我的订单');
|
|
215
|
+
out.push(' wkgeo site use <站点ID或域名> # ★ 选站(栏目/内容/素材/模板命令的前置)');
|
|
216
|
+
out.push(' wkgeo categories list # 栏目树(之后 contents / assets / templates 都类似)');
|
|
217
|
+
out.push(' wkgeo contents create --category 新闻 --title 标题 --body "<p>…</p>" # 写一篇内容');
|
|
218
|
+
out.push(' wkgeo assets upload ./banner.jpg # 传图(直传 OSS,返回素材 id)');
|
|
219
|
+
out.push(' wkgeo templates list # 本站模板 html 清单');
|
|
220
|
+
out.push(' wkgeo publish # 发布状态(前台按需渲染:published 即上线)');
|
|
221
|
+
out.push(' wkgeo import --url=https://old-site.com # 老站导入(默认 dry-run,需运维权限)');
|
|
145
222
|
/**
|
|
146
223
|
* ⚠️ 这条示例是为了让 AI 端**能自己发现**手册入口:
|
|
147
224
|
* 手册一直随包分发(package.json 的 files 里有 SKILL.md),但在加 `skill` 命令之前
|
|
@@ -150,7 +227,9 @@ export function renderHelp(name = null) {
|
|
|
150
227
|
out.push(' wkgeo skill # 打印给 AI 端的操作手册(随包分发)');
|
|
151
228
|
out.push('');
|
|
152
229
|
out.push('通用参数:--json(机器可读;非终端下默认开启)/ --text(强制人类可读)');
|
|
153
|
-
out.push(' --base <url
|
|
230
|
+
out.push(' --base <url>(平台面)/ --cms-base <url>(CMS 面)/ --client-id <id> / --credentials <path>');
|
|
231
|
+
out.push(' --verbose / --help / --version');
|
|
232
|
+
out.push('CMS 命令专用:--site <站点ID>(一次性指定站点,覆盖 `wkgeo site use` 选中的)');
|
|
154
233
|
out.push('');
|
|
155
234
|
out.push('带单位的通用参数(写清单位,避免"600 被当成 600 毫秒"这类误解):');
|
|
156
235
|
for (const l of renderTimedGlobalFlags()) out.push(l);
|
package/lib/config.mjs
CHANGED
|
@@ -21,21 +21,47 @@ export const DEFAULT_BASE =
|
|
|
21
21
|
'https://wkongai-d9gh0hpfs6ee72622-1251938240.ap-shanghai.app.tcloudbase.com';
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
24
|
+
* CMS 接口基址(**客户站后台那一套动作**:栏目 / 内容 / 素材 / 模板)。
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* 为什么和 `DEFAULT_BASE` 分开:
|
|
27
|
+
* 平台面(站点 / 钱包 / 订单)在 CloudBase 网关 `/api/data`;
|
|
28
|
+
* CMS 面是**另一个云函数**,线上挂在自家域名下:`POST https://api.wkgeo.com/cms/<action>`。
|
|
29
|
+
* 两者域名、路径都不一样,硬合成一个 base 只会写出"改了一处另一处悄悄坏掉"的代码。
|
|
30
|
+
*
|
|
31
|
+
* ★ 2026-10-18 路径规范:域名已经含 `api`,路径里不再重复 —— 新写法是 `/cms/<action>`。
|
|
32
|
+
* 网关(ESA 重写规则)会把 `/cms/...` 换回 `/api/cms/...`,云函数不用改;
|
|
33
|
+
* 老写法 `https://api.wkgeo.com/api/cms` **继续兼容**,回退只改这一行。
|
|
34
|
+
*
|
|
35
|
+
* ⚠️ 这里只放**公开地址**。CMS 的鉴权是 Bearer 令牌(用户登录态),没有任何密钥进 CLI。
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_CMS_BASE = 'https://api.wkgeo.com/cms';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 前端页面基址(**2026-10-18 域名统一到路径**之后的 canonical 地址)。
|
|
41
|
+
*
|
|
42
|
+
* ★ 2026-10-02(三站拆分):三个业务页各有独立子域
|
|
28
43
|
* * 授权站 `https://oauth.wkgeo.com` —— 同意页 / 设备码页
|
|
29
44
|
* * 收银台 `https://pay.wkgeo.com` —— 支付页
|
|
30
45
|
* * 会员站 `https://user.wkgeo.com` —— 登录页 / 我的订单等
|
|
31
46
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
47
|
+
* ★★ 2026-10-18(域名统一到路径):授权站与会员站改挂主域前缀,**老子域继续可用**:
|
|
48
|
+
* * 授权站 `https://wkgeo.com/oauth/` (老:`https://oauth.wkgeo.com/`)
|
|
49
|
+
* * 会员站 `https://wkgeo.com/user/` (老:`https://user.wkgeo.com/`)
|
|
50
|
+
* * 收银台 `https://pay.wkgeo.com/` —— **支付独立,一行没动**
|
|
51
|
+
* 老板要求老地址并存,所以这三个常量是"**默认走新路径**",回滚只需一条环境变量:
|
|
52
|
+
* `WKONGAI_OAUTH_WEB_BASE=https://oauth.wkgeo.com`(见 README 的配置表)。
|
|
53
|
+
*
|
|
54
|
+
* ⚠️ 授权页地址不直接信任线上 metadata 的 `authorization_endpoint`:
|
|
55
|
+
* 1. 它由云函数 `oauth` 的 `ADMIN_WEB_BASE` 生成,**线上环境变量里还是老地址**,
|
|
56
|
+
* 服务端切完并重新部署之前一直返回 `https://oauth.wkgeo.com/...`;
|
|
57
|
+
* 2. 线上还曾经把它配成 http://localhost:8000(云函数默认值没配环境变量),
|
|
58
|
+
* 那种情况下打开的是**用户本机端口**,是最坏的一种失败。
|
|
59
|
+
* ⇒ 所以统一走 `oauth-urls.mjs` 的 `canonicalOauthUrl()`:
|
|
60
|
+
* **这里(配置基址)是 canonical,服务端只提供路径**。
|
|
35
61
|
*/
|
|
36
|
-
export const DEFAULT_OAUTH_WEB_BASE = 'https://
|
|
62
|
+
export const DEFAULT_OAUTH_WEB_BASE = 'https://wkgeo.com/oauth';
|
|
37
63
|
export const DEFAULT_PAY_WEB_BASE = 'https://pay.wkgeo.com';
|
|
38
|
-
export const DEFAULT_USER_WEB_BASE = 'https://
|
|
64
|
+
export const DEFAULT_USER_WEB_BASE = 'https://wkgeo.com/user';
|
|
39
65
|
|
|
40
66
|
/**
|
|
41
67
|
* ⚠️ 兼容保留:老站(管理端)基址。
|
|
@@ -55,7 +81,15 @@ export const DEFAULT_CLIENT_ID = 'wkgeo-cli';
|
|
|
55
81
|
* 刻意**不申请** billing:manage / user:write 这类"动钱/改权限"的能力 —— 能力越小越好,
|
|
56
82
|
* 而且那些动作在服务端本来就要二次验证(OAuth 令牌没有密码入口,永远过不了),申请了也是废的。
|
|
57
83
|
*/
|
|
58
|
-
export const DEFAULT_SCOPE =
|
|
84
|
+
export const DEFAULT_SCOPE =
|
|
85
|
+
/**
|
|
86
|
+
* 2026-10-18(老板批准方案 A):默认 scope 扩到"能真正管一个站"——
|
|
87
|
+
* * `site:read/write` —— 栏目、内容、站点信息、参数、字段、语言、插件、会员
|
|
88
|
+
* * `attachment:read/write` —— 素材上传/列表(**删除**另要 `attachment:delete`,破坏性动作不默认给)
|
|
89
|
+
* * `template:read/write` —— 模板文件读写(模板能插任意 HTML,服务端故意与 `site:write` 分开)
|
|
90
|
+
* 这几个 scope 平台侧的 `oauth_client.allowed_scopes` 里都已允许(迁移 `20261018110000`)。
|
|
91
|
+
*/
|
|
92
|
+
'site:read site:write attachment:read attachment:write template:read template:write billing:self';
|
|
59
93
|
|
|
60
94
|
/** 回调只监听回环;端口与服务端登记的 redirect_uri 一致(服务端对回环地址忽略端口比对) */
|
|
61
95
|
export const DEFAULT_CALLBACK_HOST = '127.0.0.1';
|
|
@@ -73,6 +107,18 @@ export function defaultCredentialsFile(env = process.env) {
|
|
|
73
107
|
return path.join(home, '.wkongai', 'credentials.json');
|
|
74
108
|
}
|
|
75
109
|
|
|
110
|
+
/**
|
|
111
|
+
* 本地站点状态文件:**凭据文件同目录**的 `config.json`(默认 `~/.wkongai/config.json`)。
|
|
112
|
+
*
|
|
113
|
+
* 为什么不塞进凭据文件(见 `site-state.mjs` 的说明):那是"身份",这是"操作上下文",
|
|
114
|
+
* `logout` 不该把它一起删掉。跟着凭据目录走,是为了让 `--credentials` / `WKONGAI_CREDENTIALS`
|
|
115
|
+
* 的隔离语义保持一致(换一套身份 = 换一套站点选择),单测与多人共机都不会串。
|
|
116
|
+
*/
|
|
117
|
+
export function defaultSiteStateFile(env = process.env, credentialsFile = '') {
|
|
118
|
+
const creds = credentialsFile || defaultCredentialsFile(env);
|
|
119
|
+
return path.join(path.dirname(creds), 'config.json');
|
|
120
|
+
}
|
|
121
|
+
|
|
76
122
|
/** 去掉结尾斜杠:所有拼接都假设 base 不带尾斜杠,混着写会拼出 `//api/data` */
|
|
77
123
|
export function stripTrailingSlash(u) {
|
|
78
124
|
return String(u || '').replace(/\/+$/, '');
|
|
@@ -82,18 +128,21 @@ export function stripTrailingSlash(u) {
|
|
|
82
128
|
* 给「页面 URL」补上尾斜杠 —— **本项目所有会被托管侧 302 的地址都必须经过它**(唯一构造入口)。
|
|
83
129
|
*
|
|
84
130
|
* ## 为什么必须有这个函数(真机实测过的坑)
|
|
85
|
-
* 前端是静态导出(`exportStatic`),产物是「每个路由一个目录」(`dist
|
|
131
|
+
* 前端是静态导出(`exportStatic`),产物是「每个路由一个目录」(`dist-oauth/authorize/index.html`)。
|
|
86
132
|
* 托管侧(OSS 静态托管 / 阿里云 ESA)对**不带尾斜杠**的目录路径会返回 **302**,
|
|
87
133
|
* 而这个 302 **不携带原始 query**:
|
|
88
134
|
* ```
|
|
89
135
|
* GET /admin/oauth/authorize?client_id=…&state=… → 302 /admin/oauth/authorize/ ← query 全丢 ❌
|
|
90
136
|
* GET /admin/oauth/authorize/?client_id=…&state=… → 200,参数完好 ✔
|
|
91
137
|
* ```
|
|
138
|
+
* (★ 2026-10-02:上面测的是老站路径;授权站去 `/oauth` 前缀后是 `/authorize`,
|
|
139
|
+
* 收银台去 `/pay` 前缀后是站根 `/`(站根是真实 `index.html`,不触发这个 302)。坑的形态没变。)
|
|
92
140
|
* 后果是授权页显示「授权请求不完整」,而且**前端救不回来**:302 发生在任何 JS 执行之前,
|
|
93
141
|
* 带参数的地址根本没到过浏览器。
|
|
94
142
|
*
|
|
95
143
|
* ## 行为(有单测钉住)
|
|
96
|
-
* * 尾斜杠必须加在 **`?` 之前**(`/oauth/device?user_code=x` → `/oauth/device/?user_code=x
|
|
144
|
+
* * 尾斜杠必须加在 **`?` 之前**(`/oauth/device?user_code=x` → `/oauth/device/?user_code=x`,
|
|
145
|
+
* 授权站去前缀后是 `/device?user_code=x` → `/device/?user_code=x`),
|
|
97
146
|
* 加到 `?` 后面就变成另一个含义了;
|
|
98
147
|
* * 已经带尾斜杠**不重复拼**(不会出现 `//`);
|
|
99
148
|
* * 路径里的连续斜杠折叠(`/oauth//device` → `/oauth/device/`),
|
|
@@ -195,11 +244,11 @@ export function resolveConfig(flags = {}, env = process.env) {
|
|
|
195
244
|
* (联调 / 回滚老站时一句环境变量搞定,不用改代码);
|
|
196
245
|
* 2. `WKONGAI_OAUTH_WEB_BASE` / `WKONGAI_PAY_WEB_BASE` / `WKONGAI_USER_WEB_BASE`
|
|
197
246
|
* —— 按站单独覆盖;
|
|
198
|
-
* 3. 各自的内置默认值(授权站
|
|
247
|
+
* 3. 各自的内置默认值(授权站 `/oauth`、收银台子域、会员站 `/user`)。
|
|
199
248
|
*/
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
|
|
249
|
+
const topOverride = stripTrailingSlash(pick(flags.webBase, 'WKONGAI_WEB_BASE', ''));
|
|
250
|
+
const getWebBase = (envKey, dflt) =>
|
|
251
|
+
topOverride || stripTrailingSlash(pick(undefined, envKey, dflt));
|
|
203
252
|
|
|
204
253
|
/**
|
|
205
254
|
* `--timeout` 的单位是**秒**(见 parseDurationMs 的说明),而环境变量
|
|
@@ -213,21 +262,31 @@ export function resolveConfig(flags = {}, env = process.env) {
|
|
|
213
262
|
? parseDurationMs(env.WKONGAI_LOGIN_TIMEOUT_MS, { defaultUnit: 'ms', flag: 'WKONGAI_LOGIN_TIMEOUT_MS' })
|
|
214
263
|
: DEFAULT_LOGIN_TIMEOUT_MS;
|
|
215
264
|
|
|
265
|
+
/**
|
|
266
|
+
* 凭据文件路径先算出来:站点状态文件放在**它同目录**(见 defaultSiteStateFile 的说明),
|
|
267
|
+
* 这样 `--credentials` 换一套身份时,选中的站点也跟着换,不会串站。
|
|
268
|
+
*/
|
|
269
|
+
const credentialsFile = flags.credentials
|
|
270
|
+
? path.resolve(String(flags.credentials))
|
|
271
|
+
: defaultCredentialsFile(env);
|
|
272
|
+
|
|
216
273
|
return {
|
|
217
274
|
base: stripTrailingSlash(pick(flags.base, 'WKONGAI_BASE', DEFAULT_BASE)),
|
|
218
275
|
/** ⚠️ 兼容字段:老站(管理端)基址。三站拆分后没有代码用它打开页面,但保留以免破坏自动化 */
|
|
219
|
-
webBase:
|
|
276
|
+
webBase: topOverride || stripTrailingSlash(pick(undefined, 'WKONGAI_WEB_BASE', DEFAULT_WEB_BASE)),
|
|
220
277
|
/** 授权站基址(同意页 / 设备码页) */
|
|
221
|
-
oauthWebBase:
|
|
278
|
+
oauthWebBase: getWebBase('WKONGAI_OAUTH_WEB_BASE', DEFAULT_OAUTH_WEB_BASE),
|
|
222
279
|
/** 收银台基址(支付页) */
|
|
223
|
-
payWebBase:
|
|
280
|
+
payWebBase: getWebBase('WKONGAI_PAY_WEB_BASE', DEFAULT_PAY_WEB_BASE),
|
|
224
281
|
/** 会员站基址(登录页 / 我的订单等) */
|
|
225
|
-
userWebBase:
|
|
282
|
+
userWebBase: getWebBase('WKONGAI_USER_WEB_BASE', DEFAULT_USER_WEB_BASE),
|
|
283
|
+
/** CMS 接口基址(栏目 / 内容 / 素材 / 模板;**和平台面是两个地址**) */
|
|
284
|
+
cmsBase: stripTrailingSlash(pick(flags.cmsBase, 'WKONGAI_CMS_BASE', DEFAULT_CMS_BASE)),
|
|
226
285
|
clientId: String(pick(flags.clientId, 'WKONGAI_CLIENT_ID', DEFAULT_CLIENT_ID)),
|
|
227
286
|
scope: splitScope(scopeRaw).join(' '),
|
|
228
|
-
credentialsFile
|
|
229
|
-
|
|
230
|
-
|
|
287
|
+
credentialsFile,
|
|
288
|
+
/** 本地"当前站点"记录(`wkgeo site use` 写它;**不是**凭据) */
|
|
289
|
+
siteStateFile: defaultSiteStateFile(env, credentialsFile),
|
|
231
290
|
callbackHost: String(pick(flags.callbackHost, 'WKONGAI_CALLBACK_HOST', DEFAULT_CALLBACK_HOST)),
|
|
232
291
|
callbackPort: toInt(pick(flags.callbackPort, 'WKONGAI_CALLBACK_PORT', DEFAULT_CALLBACK_PORT), DEFAULT_CALLBACK_PORT),
|
|
233
292
|
callbackPath: String(pick(flags.callbackPath, 'WKONGAI_CALLBACK_PATH', DEFAULT_CALLBACK_PATH)),
|
|
@@ -249,6 +308,7 @@ export function describeConfigSources(flags = {}, env = process.env) {
|
|
|
249
308
|
};
|
|
250
309
|
return {
|
|
251
310
|
base: src(flags.base, 'WKONGAI_BASE'),
|
|
311
|
+
cms_base: src(flags.cmsBase, 'WKONGAI_CMS_BASE'),
|
|
252
312
|
client_id: src(flags.clientId, 'WKONGAI_CLIENT_ID'),
|
|
253
313
|
scope: src(flags.scope, 'WKONGAI_SCOPE'),
|
|
254
314
|
credentials_file: flags.credentials
|
package/lib/device.mjs
CHANGED
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
import { createApi } from './api.mjs';
|
|
51
51
|
import { writeCredentials, credentialsFromToken } from './credentials.mjs';
|
|
52
52
|
import { withTrailingSlash } from './config.mjs';
|
|
53
|
+
import { canonicalOauthUrl } from './oauth-urls.mjs';
|
|
53
54
|
import { CliError, EXIT, loginHint } from './errors.mjs';
|
|
54
55
|
import { defaultOpenBrowser, resolveBrowserIntent, browserOpenedText, browserFailedHintLines } from './login.mjs';
|
|
55
56
|
|
|
@@ -136,16 +137,24 @@ export async function requestDeviceCode({ config, deps = {} }) {
|
|
|
136
137
|
|
|
137
138
|
const userCode = String(body.user_code);
|
|
138
139
|
/**
|
|
139
|
-
* ⚠️ 兜底地址**必须带尾斜杠**:`${基址}/
|
|
140
|
+
* ⚠️ 兜底地址**必须带尾斜杠**:`${基址}/device`(不带斜杠)会踩同一个坑 ——
|
|
140
141
|
* 托管侧对目录路径做 302 且**丢掉 query**,`?user_code=…` 预填参数会被剥光,
|
|
141
142
|
* 用户就得自己手输短码(甚至显示"授权请求不完整")。
|
|
142
143
|
* 拼法统一走 `withTrailingSlash()`(cli/lib 里唯一入口,见 config.mjs)。
|
|
143
144
|
* 服务端 metadata 给的 `verification_uri` 也一样过一道:不能假设它永远配得对。
|
|
144
|
-
*
|
|
145
|
+
*
|
|
146
|
+
* ★★ 2026-10-18(域名统一到路径):授权站 canonical 变成 `https://wkgeo.com/oauth/`。
|
|
147
|
+
* 服务端 `DEVICE_VERIFY_URL`(`tencent/functions/oauth/index.js`)此刻仍返回老地址
|
|
148
|
+
* `https://oauth.wkgeo.com/device/`(`ADMIN_WEB_BASE` 的线上环境变量还没切),
|
|
149
|
+
* 只改兜底没用 —— 所以要**先归一化**到 `config.oauthWebBase`,再补尾斜杠。
|
|
150
|
+
* 回滚老站:`WKONGAI_OAUTH_WEB_BASE=https://oauth.wkgeo.com`,代码不用动。
|
|
145
151
|
*/
|
|
146
|
-
const
|
|
147
|
-
String(body.verification_uri || `${config.oauthWebBase}/
|
|
152
|
+
const canonicalVerify = canonicalOauthUrl(
|
|
153
|
+
String(body.verification_uri || `${config.oauthWebBase}/device`),
|
|
154
|
+
config.oauthWebBase,
|
|
155
|
+
{ fallbackPath: '/device' },
|
|
148
156
|
);
|
|
157
|
+
const verificationUri = withTrailingSlash(canonicalVerify.url);
|
|
149
158
|
/**
|
|
150
159
|
* `verification_uri_complete`(带短码预填的地址):服务端给了就用它的,
|
|
151
160
|
* **没给就自己拼一个**。
|
|
@@ -157,9 +166,17 @@ export async function requestDeviceCode({ config, deps = {} }) {
|
|
|
157
166
|
* 参数名必须与确认页一致(`admin-web/src/pages/device/index.tsx` 读 `user_code`),
|
|
158
167
|
* 所以这里拼的是 `?user_code=<码>`;地址本身已经过 `withTrailingSlash()`
|
|
159
168
|
* (尾斜杠必须在 `?` **之前**,否则托管侧 302 会把 query 丢掉)。
|
|
169
|
+
*
|
|
170
|
+
* ★ 2026-10-18:服务端给的 `verification_uri_complete` 也要过归一化 ——
|
|
171
|
+
* 它由同一个 `ADMIN_WEB_BASE` 生成,此刻仍是老地址;用户点开哪个站必须由
|
|
172
|
+
* 当前配置的 canonical 授权站决定(`canonicalOauthUrl` 保留 query,只换站)。
|
|
160
173
|
*/
|
|
161
174
|
const verificationUriComplete = body.verification_uri_complete
|
|
162
|
-
? withTrailingSlash(
|
|
175
|
+
? withTrailingSlash(
|
|
176
|
+
canonicalOauthUrl(String(body.verification_uri_complete), config.oauthWebBase, {
|
|
177
|
+
fallbackPath: '/device',
|
|
178
|
+
}).url,
|
|
179
|
+
)
|
|
163
180
|
: buildVerificationUriComplete(verificationUri, userCode);
|
|
164
181
|
const expiresInSec = Number(body.expires_in) > 0 ? Number(body.expires_in) : DEFAULT_DEVICE_EXPIRES_SEC;
|
|
165
182
|
const intervalSec = Number(body.interval) > 0 ? Number(body.interval) : DEFAULT_DEVICE_INTERVAL_SEC;
|
package/lib/index.mjs
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
export { run } from './cli.mjs';
|
|
9
9
|
export { createRuntime, createOutput, okEnvelope, errorEnvelope } from './runtime.mjs';
|
|
10
10
|
export { createApi } from './api.mjs';
|
|
11
|
+
/** CMS 面(栏目 / 内容 / 素材 / 模板)的客户端与"直传 OSS"那一步 */
|
|
12
|
+
export { createCms, uploadToOss, CMS_ACTIONS } from './cms.mjs';
|
|
13
|
+
/** 本地"当前站点"(`wkgeo site use` 写的 `~/.wkongai/config.json`) */
|
|
14
|
+
export { readSiteState, writeSiteState, clearSiteState } from './site-state.mjs';
|
|
11
15
|
export {
|
|
12
16
|
runLogin,
|
|
13
17
|
openInBrowser,
|
|
@@ -24,6 +28,7 @@ export {
|
|
|
24
28
|
withTrailingSlash,
|
|
25
29
|
parseDurationMs,
|
|
26
30
|
DEFAULT_BASE,
|
|
31
|
+
DEFAULT_CMS_BASE,
|
|
27
32
|
DEFAULT_CLIENT_ID,
|
|
28
33
|
DEFAULT_SCOPE,
|
|
29
34
|
} from './config.mjs';
|