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
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
|
+
}
|
package/lib/commands/auth.mjs
CHANGED
|
@@ -165,7 +165,7 @@ function deviceStoppedError() {
|
|
|
165
165
|
* 宁可让那次后台轮询在下一轮自己发现 `shouldStop()` 并退出,也不能把用户的登录卡在这里。
|
|
166
166
|
* ⚠️ `deviceSide.promise` 内部两个分支都已 catch(不会 reject),这里 await 它不会抛。
|
|
167
167
|
*/
|
|
168
|
-
async function
|
|
168
|
+
async function stopDeviceCodeFlow(deviceSide, cancelGate) {
|
|
169
169
|
if (!deviceSide.promise) return;
|
|
170
170
|
deviceSide.cancelled = true;
|
|
171
171
|
cancelGate.wakeAll();
|
|
@@ -434,7 +434,7 @@ export const loginCommand = {
|
|
|
434
434
|
|
|
435
435
|
const pendingFile = pendingDeviceFile(config.credentialsFile);
|
|
436
436
|
/** 申请到码之后立刻落盘:进程被 Ctrl+C / 终端被关掉,用户还能用 --device-continue 续上 */
|
|
437
|
-
const
|
|
437
|
+
const savePending = (device) => {
|
|
438
438
|
try {
|
|
439
439
|
savePendingDevice(pendingFile, { ...device, base: config.base, client_id: config.clientId });
|
|
440
440
|
return true;
|
|
@@ -494,7 +494,7 @@ export const loginCommand = {
|
|
|
494
494
|
if (!result && useDevice) {
|
|
495
495
|
try {
|
|
496
496
|
const device = await requestDeviceCode({ config, deps: loginDeps });
|
|
497
|
-
|
|
497
|
+
savePending(device);
|
|
498
498
|
if (noWait) {
|
|
499
499
|
/**
|
|
500
500
|
* ★ AI 端友好路径:打印完就退出(不轮询)。
|
|
@@ -588,11 +588,11 @@ export const loginCommand = {
|
|
|
588
588
|
/** 交接面:两边各填一半(`onSwitch` 由这里填,另两个由 runLogin 填;见 login.mjs 的 deps 说明) */
|
|
589
589
|
const handoff = { onSwitch: null, finishWithDevice: null, abort: null };
|
|
590
590
|
|
|
591
|
-
const
|
|
591
|
+
const runDeviceCodeFlow = async () => {
|
|
592
592
|
const device = await requestDeviceCode({ config, deps: loginDeps });
|
|
593
593
|
// 回调那条路已经赢了 → 一个用户可见的动作都不要再做(不打印短码、不弹浏览器、不落盘)
|
|
594
594
|
if (deviceSide.cancelled) throw deviceStoppedError();
|
|
595
|
-
|
|
595
|
+
savePending(device);
|
|
596
596
|
if (deviceSide.cancelled) throw deviceStoppedError();
|
|
597
597
|
announceDeviceCode({ config, log, deps: deviceDeps({ no_wait: false, proxy_warning: proxyPayload }), device });
|
|
598
598
|
return pollDeviceToken({
|
|
@@ -616,7 +616,7 @@ export const loginCommand = {
|
|
|
616
616
|
* 成功 → `finishWithDevice` 让回调流程带着这份结果收尾(监听留 grace);
|
|
617
617
|
* 失败 → `abort` 让回调流程用"两条路都不通"的错误结束(用户要看的是真原因)。
|
|
618
618
|
*/
|
|
619
|
-
deviceSide.promise =
|
|
619
|
+
deviceSide.promise = runDeviceCodeFlow().then(
|
|
620
620
|
(res) => {
|
|
621
621
|
deviceSide.result = res;
|
|
622
622
|
if (typeof handoff.finishWithDevice === 'function') handoff.finishWithDevice(res);
|
|
@@ -664,7 +664,7 @@ export const loginCommand = {
|
|
|
664
664
|
});
|
|
665
665
|
} catch (e) {
|
|
666
666
|
/** ★ 无论怎么失败,都先把设备码那条路收拾干净(不能留一个还在轮询的后台任务) */
|
|
667
|
-
await
|
|
667
|
+
await stopDeviceCodeFlow(deviceSide, cancelGate);
|
|
668
668
|
/** 设备码那条路也失败了 ⇒ 优先报"两条路都不通"(哪怕回调那边先抛了自己的错) */
|
|
669
669
|
if (deviceSide.combinedError) throw deviceSide.combinedError;
|
|
670
670
|
/**
|
|
@@ -680,9 +680,9 @@ export const loginCommand = {
|
|
|
680
680
|
autoDevice && !wantsDevice && (e.kind === 'callback_first_wait' || e.kind === 'timeout' || e.kind === 'listen');
|
|
681
681
|
if (!canSwitch) throw e;
|
|
682
682
|
log('');
|
|
683
|
-
const
|
|
683
|
+
const reason =
|
|
684
684
|
e.kind === 'timeout' ? '等待超时' : e.kind === 'listen' ? '本地端口监听失败' : '首轮等待没收到回调';
|
|
685
|
-
log(`⚠️ 本地回调没有等到结果(${
|
|
685
|
+
log(`⚠️ 本地回调没有等到结果(${reason}):${e.message}`);
|
|
686
686
|
if (e.kind === 'callback_first_wait') {
|
|
687
687
|
log('ℹ️ 等了这一小段时间没收到回调,已自动改用**设备码模式**继续(不需要本地回调)。');
|
|
688
688
|
} else {
|
|
@@ -696,7 +696,7 @@ export const loginCommand = {
|
|
|
696
696
|
loginTimeoutMs: config.loginTimeoutMs,
|
|
697
697
|
});
|
|
698
698
|
}
|
|
699
|
-
|
|
699
|
+
savePending(device);
|
|
700
700
|
announceDeviceCode({ config, log, deps: deviceDeps({ no_wait: false, proxy_warning: proxyPayload }), device });
|
|
701
701
|
// 这一次再失败就如实抛出(不无限重试:两条路都失败,用户该看到真原因了)
|
|
702
702
|
result = await pollDeviceToken({ config, log, deps: loginDeps, device });
|
|
@@ -730,7 +730,7 @@ export const loginCommand = {
|
|
|
730
730
|
*/
|
|
731
731
|
deviceSide.cancelled = true;
|
|
732
732
|
cancelGate.wakeAll();
|
|
733
|
-
await
|
|
733
|
+
await stopDeviceCodeFlow(deviceSide, cancelGate);
|
|
734
734
|
deletePendingDevice(pendingFile);
|
|
735
735
|
log('ℹ️ 本地回调已经拿到令牌:设备码那条路已停止(刚才那个短码作废,不用再去浏览器里确认)。');
|
|
736
736
|
}
|
|
@@ -903,8 +903,25 @@ export const configCommand = {
|
|
|
903
903
|
*/
|
|
904
904
|
const proxy = proxyWarningPayload(ctx.proxyInfo(), { cmdName: config.cmdName || 'wkgeo' });
|
|
905
905
|
|
|
906
|
+
/**
|
|
907
|
+
* 本地"当前站点"(`wkgeo site use` 选的)。
|
|
908
|
+
* ⚠️ 诊断命令**不能**因为一个坏文件就整体失败:读失败时如实报出来,其它信息照常给。
|
|
909
|
+
*/
|
|
910
|
+
let cmsSite = null;
|
|
911
|
+
let cmsSiteError = null;
|
|
912
|
+
try {
|
|
913
|
+
cmsSite = ctx.readSiteState();
|
|
914
|
+
} catch (e) {
|
|
915
|
+
cmsSiteError = (e && e.message) || String(e);
|
|
916
|
+
}
|
|
917
|
+
|
|
906
918
|
const data = {
|
|
907
919
|
base: config.base,
|
|
920
|
+
/**
|
|
921
|
+
* CMS 面(栏目/内容/素材/模板)是**另一个云函数、另一个域名**,
|
|
922
|
+
* 所以单独列一行 —— 排查"CMS 命令为什么 404"时第一眼看的就是它。
|
|
923
|
+
*/
|
|
924
|
+
cms_base: config.cmsBase,
|
|
908
925
|
/**
|
|
909
926
|
* ⚠️ `web_base` 是**兼容字段**:三站拆分后它指向老站(管理端),
|
|
910
927
|
* 已经没有任何代码用它打开页面 —— 保留只为不破坏既有自动化脚本。
|
|
@@ -920,6 +937,10 @@ export const configCommand = {
|
|
|
920
937
|
callback: `http://${config.callbackHost}:${config.callbackPort}${config.callbackPath}`,
|
|
921
938
|
http_timeout_ms: config.httpTimeoutMs,
|
|
922
939
|
login_timeout_ms: config.loginTimeoutMs,
|
|
940
|
+
/** 本地"当前站点"(CMS 命令用;`site use` 写的) */
|
|
941
|
+
cms_site: cmsSite,
|
|
942
|
+
cms_site_file: config.siteStateFile,
|
|
943
|
+
cms_site_error: cmsSiteError,
|
|
923
944
|
sources,
|
|
924
945
|
logged_in: !!creds,
|
|
925
946
|
expired: creds ? isExpired(creds, { now: ctx.now() }) : null,
|
|
@@ -934,6 +955,7 @@ export const configCommand = {
|
|
|
934
955
|
out.head('生效配置');
|
|
935
956
|
for (const [k, v] of Object.entries({
|
|
936
957
|
'API 基址': data.base,
|
|
958
|
+
'CMS 接口基址': data.cms_base,
|
|
937
959
|
'授权站基址': data.oauth_web_base,
|
|
938
960
|
'收银台基址': data.pay_web_base,
|
|
939
961
|
'会员站基址': data.user_web_base,
|
|
@@ -945,6 +967,15 @@ export const configCommand = {
|
|
|
945
967
|
})) {
|
|
946
968
|
out.line(` ${k}:${v}`);
|
|
947
969
|
}
|
|
970
|
+
out.line(
|
|
971
|
+
` 当前站点:${
|
|
972
|
+
data.cms_site && data.cms_site.id
|
|
973
|
+
? `${data.cms_site.label ? `${data.cms_site.label}(` : ''}${data.cms_site.id}${data.cms_site.label ? ')' : ''}`
|
|
974
|
+
: data.cms_site_error
|
|
975
|
+
? `⚠️ 读取失败:${data.cms_site_error}`
|
|
976
|
+
: '未选(CMS 命令前先 `wkgeo site use <站点ID或域名>`)'
|
|
977
|
+
}`,
|
|
978
|
+
);
|
|
948
979
|
out.line(` 登录状态:${data.logged_in ? (data.expired ? '已过期(需重新 login)' : '已登录') : '未登录'}`);
|
|
949
980
|
if (data.logged_in) {
|
|
950
981
|
out.line(` 账号:${(data.account && (data.account.email || data.account.user_id)) || '-'}`);
|