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,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);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「当前选中站点」的本地状态:**`<凭据目录>/config.json`**(默认 `~/.wkongai/config.json`)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:CMS(栏目 / 内容 / 素材 / 模板)的每个动作都必须落在一个站点上,
|
|
5
|
+
* 而 CMS 的 `site_id` **只从会话或 `x-cms-site` 头里取**(body 里的 `site_id` 后端故意忽略,
|
|
6
|
+
* 见 `tencent/functions/cms/index.js` 的 `resolveSite`)。CLI 没有 Cookie 会话,
|
|
7
|
+
* 所以只能把"我这次要操作哪个站"记在本地,之后每个请求带 `x-cms-site`。
|
|
8
|
+
*
|
|
9
|
+
* 为什么不放进 `credentials.json`:
|
|
10
|
+
* * 那个文件是**凭据**(0600、只存令牌与账号),删除它 = 退出登录;
|
|
11
|
+
* * "选了哪个站"只是操作上下文,`logout` 不该把它一起带走,反过来重新登录也不该丢。
|
|
12
|
+
* 分成两个文件之后,两件事的语义各自清楚。
|
|
13
|
+
*
|
|
14
|
+
* 为什么放在凭据文件同目录:`--credentials` / `WKONGAI_CREDENTIALS` 已经在做"换一套身份"的隔离
|
|
15
|
+
* (单测、多人共机、联调都靠它),站点选择跟着走最不容易串;
|
|
16
|
+
* 默认情况下就是规划文档里写的 `~/.wkongai/config.json`。
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ 这个文件**不含任何秘密**(只有站点 id / 名字),但仍然用原子写 + 0600:
|
|
19
|
+
* 权限口径统一,省得以后往里加别的东西时忘了。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { CliError } from './errors.mjs';
|
|
26
|
+
|
|
27
|
+
/** 状态文件版本(将来改结构时用来判断要不要迁移;现在只写不读) */
|
|
28
|
+
export const SITE_STATE_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
/** 文件名(与规划文档一致:`~/.wkongai/config.json`) */
|
|
31
|
+
export const SITE_STATE_FILE_NAME = 'config.json';
|
|
32
|
+
|
|
33
|
+
/** 读原始 JSON 对象;文件不存在 / 坏掉都返回 `{}`(写路径用,绝不因为"读不懂"而写不进去) */
|
|
34
|
+
function readRawObjectQuiet(file) {
|
|
35
|
+
try {
|
|
36
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
37
|
+
const parsed = JSON.parse(text);
|
|
38
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
39
|
+
} catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 读当前选中的站点。
|
|
46
|
+
*
|
|
47
|
+
* @param {string} file 状态文件路径
|
|
48
|
+
* @returns {{id:string, label:string, saved_at:string|null}|null} 没选过 → `null`
|
|
49
|
+
* @throws {CliError} 文件存在但**不是合法 JSON / 内容形状不对**(明确报错,不静默当"没选"——
|
|
50
|
+
* 否则用户会一直看到"还没有选择站点",却不知道自己有个坏文件在那儿)
|
|
51
|
+
*/
|
|
52
|
+
export function readSiteState(file) {
|
|
53
|
+
if (!file) return null;
|
|
54
|
+
let text;
|
|
55
|
+
try {
|
|
56
|
+
text = fs.readFileSync(file, 'utf8');
|
|
57
|
+
} catch (e) {
|
|
58
|
+
if (e && e.code === 'ENOENT') return null;
|
|
59
|
+
throw new CliError(`读取本地站点配置失败:${e.message}`, {
|
|
60
|
+
kind: 'config',
|
|
61
|
+
hint: `文件位置:${file}`,
|
|
62
|
+
cause: e,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(text);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
throw new CliError(`本地站点配置不是合法 JSON:${file}`, {
|
|
70
|
+
kind: 'config',
|
|
71
|
+
hint: '删掉它,或重新运行 `wkgeo site use <站点ID>`(会覆盖写入)',
|
|
72
|
+
cause: e,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const one = parsed && typeof parsed === 'object' ? parsed.cms_site : null;
|
|
76
|
+
if (!one || typeof one !== 'object' || !one.id) return null;
|
|
77
|
+
return {
|
|
78
|
+
id: String(one.id),
|
|
79
|
+
label: String(one.label || ''),
|
|
80
|
+
saved_at: one.saved_at || null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 记住"当前站点"(**保留文件里的其它键**:以后往里放别的本地设置时不用改这里)。
|
|
86
|
+
*
|
|
87
|
+
* @param {string} file
|
|
88
|
+
* @param {{id:string, label?:string}} site
|
|
89
|
+
*/
|
|
90
|
+
export function writeSiteState(file, site, { now = Date.now() } = {}) {
|
|
91
|
+
const id = String((site && site.id) || '').trim();
|
|
92
|
+
if (!id) throw new CliError('内部错误:写本地站点配置时缺少站点 id');
|
|
93
|
+
const dir = path.dirname(file);
|
|
94
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
95
|
+
|
|
96
|
+
const payload = {
|
|
97
|
+
version: SITE_STATE_VERSION,
|
|
98
|
+
...readRawObjectQuiet(file),
|
|
99
|
+
cms_site: {
|
|
100
|
+
id,
|
|
101
|
+
label: String((site && site.label) || ''),
|
|
102
|
+
saved_at: new Date(now).toISOString(),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
107
|
+
const fd = fs.openSync(tmp, 'w', 0o600);
|
|
108
|
+
try {
|
|
109
|
+
fs.writeFileSync(fd, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
110
|
+
} finally {
|
|
111
|
+
fs.closeSync(fd);
|
|
112
|
+
}
|
|
113
|
+
fs.renameSync(tmp, file);
|
|
114
|
+
return payload.cms_site;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 取消选中(`wkgeo site use --clear`)。
|
|
119
|
+
*
|
|
120
|
+
* @returns {boolean} 之前有没有选中过(用来决定提示文案)
|
|
121
|
+
*/
|
|
122
|
+
export function clearSiteState(file) {
|
|
123
|
+
const raw = readRawObjectQuiet(file);
|
|
124
|
+
const had = Boolean(raw.cms_site && raw.cms_site.id);
|
|
125
|
+
delete raw.cms_site;
|
|
126
|
+
const dir = path.dirname(file);
|
|
127
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
128
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
129
|
+
const fd = fs.openSync(tmp, 'w', 0o600);
|
|
130
|
+
try {
|
|
131
|
+
fs.writeFileSync(fd, JSON.stringify({ version: SITE_STATE_VERSION, ...raw }, null, 2) + '\n', 'utf8');
|
|
132
|
+
} finally {
|
|
133
|
+
fs.closeSync(fd);
|
|
134
|
+
}
|
|
135
|
+
fs.renameSync(tmp, file);
|
|
136
|
+
return had;
|
|
137
|
+
}
|