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/lib/cms.mjs ADDED
@@ -0,0 +1,317 @@
1
+ /**
2
+ * CMS 接口客户端 —— 客户站后台那一套动作(栏目 / 内容 / 素材 / 模板)。
3
+ *
4
+ * ## 接口形态(照 `tencent/functions/cms/index.js` 的动作表,别自己编)
5
+ * `POST {CMS_BASE}/<action>`,JSON body,响应统一信封 `{code, message, data}`,
6
+ * `code === 0` 才算成功 —— 与 `/api/data` 完全同一套约定。
7
+ * `CMS_BASE` 默认 `https://api.wkgeo.com/cms`(`--cms-base` / `WKONGAI_CMS_BASE` 可覆盖),
8
+ * 动作名就是动作表的键(`categoryList` / `contentSave` / `templateGet` …),**URL 最后一段就是动作名**:
9
+ * 云函数支持"从路径取动作"(`actionFromPath`),所以 `/api/cms/categoryList` 与
10
+ * `body.action = 'categoryList'` 等价;这里用路径写法,身体里只放业务参数(干净、可读)。
11
+ *
12
+ * ## 认证(**当前最大的外部依赖**)
13
+ * CLI 手里只有 OAuth 的 Bearer 令牌,而 CMS 原本只认 `cms_session` Cookie。
14
+ * 两条通道由**另一个任务**在 `tencent/functions/cms` 里加:"Bearer 令牌 → 用户 → 站点成员"。
15
+ * ⇒ 所以这里有两条硬规矩:
16
+ * 1. 请求一律带 `Authorization: Bearer <token>`(桥上线后立刻可用,CLI 侧一行不用改);
17
+ * 2. **桥还没上线时(服务端回 401)必须给一句人能照做的话**,不能把
18
+ * "登录已过期,请重新登录" 原样甩出去 —— 那句话会让用户以为是自己令牌的问题,
19
+ * 而真实原因多半是"服务端还没启用 CLI 认证"(见 `cmsAuthCliError`)。
20
+ *
21
+ * ## 站点边界
22
+ * 站 id 一律走 **`x-cms-site` 头**(`resolveSite` 的第一优先级)。
23
+ * ⚠️ 头的值必须是**站点 id(uuid)**:后端先按 `id::text` 精确查,再按 Host 兜底;
24
+ * CLI 没有 Host,所以传域名等于没传(这是踩不出响声的坑,`runtime.cmsSite()` 里提前挡掉)。
25
+ *
26
+ * ## 为什么错误翻译放在这一层
27
+ * 命令层有十几个,逐个写"401 怎么说"迟早写歪。所有 CMS 响应只在 `unwrap()` 一处判定,
28
+ * 命令层拿到的要么是 `data`,要么是一个**带中文 hint 的 CliError**。
29
+ */
30
+
31
+ import { request } from './http.mjs';
32
+ import { CliError, EXIT, classifyResponse, networkError, toCliError } from './errors.mjs';
33
+
34
+ /**
35
+ * 动作名(与 `tencent/functions/cms/index.js` 的 `actionTable` 一一对应)。
36
+ *
37
+ * 只列 CLI 用到的那些 —— 全表在云函数里,这里不是"权威清单",
38
+ * 但用它代替散落在命令层的中英混排字符串,避免 `categorylist` 这类大小写手滑(后端会回"未知动作")。
39
+ */
40
+ export const CMS_ACTIONS = {
41
+ // 概览(发布状态用)
42
+ overview: 'overview',
43
+ me: 'me',
44
+ // 栏目
45
+ categoryList: 'categoryList',
46
+ categoryGet: 'categoryGet',
47
+ categorySave: 'categorySave',
48
+ categoryDelete: 'categoryDelete',
49
+ // 内容
50
+ contentList: 'contentList',
51
+ contentGet: 'contentGet',
52
+ contentSave: 'contentSave',
53
+ contentDelete: 'contentDelete',
54
+ // 素材(图片直传:签名 → 直传 OSS → 登记)
55
+ assetSign: 'assetSign',
56
+ assetRegister: 'assetRegister',
57
+ assetList: 'assetList',
58
+ // 模板
59
+ templateList: 'templateList',
60
+ templateGet: 'templateGet',
61
+ templateFileSave: 'templateFileSave',
62
+ // 前台渲染(免登录,发布说明里要引用它)
63
+ pageRender: 'pageRender',
64
+ };
65
+
66
+ /**
67
+ * 「服务端没接受 CLI 的令牌」时给的话。
68
+ *
69
+ * ## 为什么不能只写"请重新登录"
70
+ * 把令牌问题和服务端没上线混成一句,会让用户白跑一遍浏览器授权:
71
+ * `whoami` 明明正常、`categories list` 却永远 401 —— 那是**桥没上线**,重登一万次也没用。
72
+ *
73
+ * ## 为什么同时给两种可能
74
+ * 本地能判定的只有"凭据没过期"(`requireAuth` 已经挡掉了本地过期的),
75
+ * 但服务端吊销 / 令牌被清掉是本地看不出来的。所以按可能性排序给两条路,
76
+ * 并明确说"CLI 侧不需要改"——省得用户去装新版、改 base。
77
+ */
78
+ function cmsAuthCliError(action, raw) {
79
+ const text = String((raw && raw.message) || '服务端没有给出原因');
80
+ return new CliError(`CMS 接口没有接受 CLI 的授权令牌(${action}):${text}`, {
81
+ kind: 'cms_auth',
82
+ code: (raw && raw.code !== undefined && raw.code !== null) ? raw.code : 401,
83
+ exitCode: EXIT.AUTH,
84
+ hint:
85
+ '① 最常见的原因是令牌已过期 / 被吊销:运行 `wkgeo login` 重新授权即可;' +
86
+ '② 若刚授权仍报 401(且 `wkgeo whoami` 正常),说明服务端是**旧部署** —— ' +
87
+ '还没启用 CLI 认证桥(OAuth Bearer 令牌 → CMS 站点成员),联系管理员升级云函数 `cms` 即可,CLI 侧不需要改。',
88
+ raw: { action, body: raw },
89
+ });
90
+ }
91
+
92
+ /**
93
+ * 「接口本身摸不到」时给的话(地址错 / 云函数没部署 / 网关返回 HTML)。
94
+ *
95
+ * 这一类**不是**令牌问题,绝不能引导去重新授权 —— 否则用户会陷入"重登 → 还是失败"的死循环。
96
+ */
97
+ function cmsUnavailableError(action, res) {
98
+ const status = res && res.status ? res.status : 0;
99
+ const snippet = String((res && res.text) || '').replace(/\s+/g, ' ').slice(0, 200);
100
+ return new CliError(`CMS 接口不可用(${action},HTTP ${status || '无响应'})${snippet ? `:${snippet}` : ''}`, {
101
+ kind: 'cms_unavailable',
102
+ code: status || null,
103
+ hint:
104
+ '检查 `wkgeo config` 里的 CMS 基址(默认 `https://api.wkgeo.com/cms`,' +
105
+ '可用 `--cms-base` / `WKONGAI_CMS_BASE` 临时切换);若地址没错,多半是云函数 `cms` 还没部署。',
106
+ });
107
+ }
108
+
109
+ /**
110
+ * **403 的三种含义要分开说**(Bearer 桥上线后这三种都会真实出现,混成一句就没法照做)。
111
+ *
112
+ * 1. 「你不是该站点的成员」/「你不是任何 CMS 站点的成员」→ 站点选错了 / 还没被邀请;
113
+ * 2. 「只读账号不能修改…」→ 站点角色是 viewer(有权限但只读);
114
+ * 3. 「该令牌没有执行「X」的权限(需要 site:write …)」→ **scope** 不够,得重新授权。
115
+ *
116
+ * 另外 400 有一种固定文案:「这个账号有 N 个站点,请用 x-cms-site 头或 body._site 指定」——
117
+ * 那是"没选站 + 多成员站",引导用户去 `wkgeo site use`。
118
+ */
119
+ function cmsPolicyError(action, body) {
120
+ const message = String((body && body.message) || '无权限');
121
+ const code = body && body.code !== undefined ? body.code : 403;
122
+
123
+ if (/不是该站点的成员|不是任何 CMS 站点的成员/.test(message)) {
124
+ return new CliError(`CMS 拒绝访问(${action}):${message}`, {
125
+ kind: 'cms_not_member',
126
+ code,
127
+ hint:
128
+ '站点 ID 不对或你不是它的成员:用 `wkgeo site use <正确的站点ID>` 重新选(`--site` 只影响一次);' +
129
+ '如果是别人邀请你协作的站,需要该站 owner 先在后台把你加进成员。',
130
+ });
131
+ }
132
+ if (/只读账号/.test(message)) {
133
+ return new CliError(`CMS 拒绝写入(${action}):${message}`, {
134
+ kind: 'cms_readonly',
135
+ code,
136
+ hint: '你这个站点角色是 viewer(只读):让该站 owner 在后台「用户与角色」里把你提升为 editor。',
137
+ });
138
+ }
139
+ if (/没有执行|需要\s*site:write|需要\s*template:write|需要\s*attachment/.test(message)) {
140
+ return new CliError(`CMS 拒绝执行(${action}):${message}`, {
141
+ kind: 'cms_scope',
142
+ code,
143
+ hint:
144
+ '令牌的能力(scope)不够:直接 `wkgeo login` 重新授权一次即可' +
145
+ '(CLI 现在的默认 scope 已含 site / template / attachment 三组);' +
146
+ '要精确控制就写 `wkgeo login --scope "<原有 scope> <缺的那个>"`。' +
147
+ '⚠️ 升级前授权的**旧令牌**不会自动获得新能力,必须重新 login。',
148
+ });
149
+ }
150
+ if (/请用\s*x-cms-site|指定站点/.test(message)) {
151
+ return new CliError(`还没有确定站点(${action}):${message}`, {
152
+ kind: 'cms_site_required',
153
+ code,
154
+ hint: '你的账号有多个站点,必须指明一个:`wkgeo site use <站点ID>`(记住),或本命令加 `--site <站点ID>`。',
155
+ });
156
+ }
157
+ return null;
158
+ }
159
+
160
+ /**
161
+ * 建一个 CMS 客户端。
162
+ *
163
+ * @param {object} args
164
+ * @param {object} args.config `resolveConfig()` 的结果(只用 `cmsBase` / `httpTimeoutMs`)
165
+ * @param {Function} [args.fetchImpl]
166
+ */
167
+ export function createCms({ config, fetchImpl = globalThis.fetch } = {}) {
168
+ const base = String((config && config.cmsBase) || '').replace(/\/+$/, '');
169
+ const timeoutMs = (config && config.httpTimeoutMs) || 30000;
170
+
171
+ async function raw(url, options = {}) {
172
+ return request(url, { ...options, fetchImpl, timeoutMs: options.timeoutMs ?? timeoutMs });
173
+ }
174
+
175
+ /**
176
+ * 判定成败 + 翻译错误(**全模块唯一一处**)。
177
+ * @returns `{code,message,data}` 里的 `data`(没有就是 null)
178
+ */
179
+ function unwrap(res, action) {
180
+ if (res.networkError) throw networkError(res.networkError, { raw: res });
181
+
182
+ /**
183
+ * 没有 JSON 响应体 = 打到的不是 CMS 云函数:
184
+ * * 网关 404 / 502 的 HTML 错误页;
185
+ * * 或者云函数没部署、路径没配。
186
+ * 这两种都不该按"业务错误"处理(也就不会有 code 可看)。
187
+ */
188
+ if (!res.body) {
189
+ if (res.status >= 200 && res.status < 300) {
190
+ // 200 但空体:极少见(网关吞了响应),当成功但无数据,别硬报错
191
+ return null;
192
+ }
193
+ throw cmsUnavailableError(action, res);
194
+ }
195
+
196
+ const cls = classifyResponse({
197
+ httpStatus: res.status,
198
+ body: res.body,
199
+ action,
200
+ retryAfterHeader:
201
+ res.headers && typeof res.headers.get === 'function' ? res.headers.get('retry-after') : null,
202
+ cmdName: (config && config.cmdName) || 'wkgeo',
203
+ });
204
+
205
+ if (cls.ok) {
206
+ const body = res.body;
207
+ return body && Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body;
208
+ }
209
+
210
+ /**
211
+ * 401 家族:可能是令牌真过期,也可能是桥没上线/没部署 —— 单独一段话(见 cmsAuthCliError)
212
+ */
213
+ if (cls.kind === 'expired' || cls.kind === 'revoked' || cls.kind === 'unauthenticated') {
214
+ throw cmsAuthCliError(action, res.body);
215
+ }
216
+
217
+ /**
218
+ * 403 / 400 里有几类**含义完全不同**的业务拒绝(不是成员 / 只读角色 / scope 不够 / 没定站),
219
+ * 通用 hint("scope 不足"或"去网页后台做")会把它们全说错 —— 先按文案细分。
220
+ */
221
+ if (cls.kind === 'forbidden' || cls.kind === 'bad_request' || cls.code === 403 || cls.code === 400) {
222
+ const policy = cmsPolicyError(action, res.body);
223
+ if (policy) throw policy;
224
+ }
225
+
226
+ /**
227
+ * 「未知动作」= CLI 与服务端版本不一致(新动作还没部署)。
228
+ * 这条错误原本是 `未知动作:templateFileSave`,对用户等于没说,所以补一句"是服务端缺口"。
229
+ */
230
+ if (String(cls.message || '').includes('未知动作')) {
231
+ throw new CliError(`服务端还没有这个动作:${action}`, {
232
+ kind: 'not_deployed',
233
+ code: cls.code,
234
+ hint: '这是**服务端缺口**(云函数 `cms` 版本落后),不是命令写错;等后端部署后重试,CLI 不需要升级。',
235
+ });
236
+ }
237
+
238
+ throw toCliError(cls, { action, raw: { status: res.status, body: res.body } });
239
+ }
240
+
241
+ return {
242
+ base,
243
+
244
+ /**
245
+ * 调一个 CMS 动作。
246
+ *
247
+ * @param {string} action 动作名(用 `CMS_ACTIONS` 里的常量,别手写)
248
+ * @param {object} [body] 业务参数(**不要**往这里塞 `site_id`:后端一律忽略,站点走 `x-cms-site`)
249
+ * @param {{token?:string, siteId?:string}} [opts]
250
+ * @returns {Promise<any>} 信封里的 `data`
251
+ */
252
+ async call(action, body = {}, { token, siteId } = {}) {
253
+ if (!action) throw new CliError('内部错误:缺少 CMS 动作名');
254
+ if (!base) throw new CliError('内部错误:CMS 基址为空(检查 WKONGAI_CMS_BASE)');
255
+ const headers = {
256
+ 'Content-Type': 'application/json',
257
+ Accept: 'application/json',
258
+ };
259
+ if (token) headers.Authorization = `Bearer ${token}`;
260
+ if (siteId) headers['x-cms-site'] = String(siteId);
261
+ const res = await raw(`${base}/${action}`, {
262
+ method: 'POST',
263
+ headers,
264
+ body: JSON.stringify(body || {}),
265
+ });
266
+ return unwrap(res, action);
267
+ },
268
+ };
269
+ }
270
+
271
+ /**
272
+ * 把文件真正传上 OSS(**直传**:CLI 不碰 AK,也不会把二进制塞进云函数请求体)。
273
+ *
274
+ * 流程(与 `tencent/functions/cms` 的 `signDirectUpload` 对应):
275
+ * ① `assetSign` 拿票据(key / uploadUrl / fields 都锁死,10 分钟过期);
276
+ * ② 用 `multipart/form-data` POST 到 OSS(`fields` 在前,`file` **必须是最后一个字段**,
277
+ * OSS 的 PostObject 要求如此);
278
+ * ③ 回 `assetRegister` 落库(它会回读一次 OSS,确认文件真的在)。
279
+ *
280
+ * ⚠️ `fields` 里有 `Content-Type`,而 multipart 的每个字段都要有自己的一段 — 直接
281
+ * `form.append('Content-Type', ...)` 是对的(OSS 把它当表单字段读),**不要**改成请求头,
282
+ * 改了就破坏 boundary。
283
+ *
284
+ * @param {object} args
285
+ * @param {{uploadUrl:string, fields:Record<string,string>, mime:string}} args.ticket
286
+ * @param {Buffer} args.buffer 文件字节
287
+ * @param {string} args.fileName 文件名(客户端名,用来给 OSS 那一段命名)
288
+ * @returns {Promise<{status:number, text:string}>}
289
+ */
290
+ export async function uploadToOss({ ticket, buffer, fileName, fetchImpl = globalThis.fetch, timeoutMs = 120000 }) {
291
+ const form = new FormData();
292
+ for (const [k, v] of Object.entries((ticket && ticket.fields) || {})) {
293
+ if (v === undefined || v === null) continue;
294
+ form.append(String(k), String(v));
295
+ }
296
+ /** `file` 必须最后追加(OSS PostObject 的约定) */
297
+ form.append('file', new Blob([buffer], { type: (ticket && ticket.mime) || 'application/octet-stream' }), String(fileName || 'file'));
298
+
299
+ const res = await request(String(ticket.uploadUrl), {
300
+ method: 'POST',
301
+ body: form,
302
+ /** ⚠️ 这里**绝不能**设 Content-Type:boundary 由 fetch 自己生成 */
303
+ headers: {},
304
+ fetchImpl,
305
+ timeoutMs,
306
+ });
307
+ if (res.networkError) throw networkError(`上传到素材库失败:${res.networkError}`, { raw: res });
308
+ if (res.status < 200 || res.status >= 300) {
309
+ const snippet = String(res.text || '').replace(/\s+/g, ' ').slice(0, 300);
310
+ throw new CliError(`上传到素材库失败(OSS HTTP ${res.status})${snippet ? `:${snippet}` : ''}`, {
311
+ kind: 'upload_failed',
312
+ code: res.status,
313
+ hint: '票据 10 分钟过期,重新运行本命令即可;若一直是 403,检查素材桶的跨域/签名配置(服务端 OSS_ACCESS_KEY_*)',
314
+ });
315
+ }
316
+ return { status: res.status, text: String(res.text || '').slice(0, 200) };
317
+ }