wpops 0.1.0
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/.env.example +19 -0
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/bin/wpops.js +322 -0
- package/lib/args.js +85 -0
- package/lib/client.js +136 -0
- package/lib/commands.js +704 -0
- package/lib/env.js +99 -0
- package/lib/skill.js +37 -0
- package/package.json +47 -0
- package/scripts/postinstall.mjs +15 -0
- package/skills/wpops/SKILL.md +54 -0
package/lib/commands.js
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { loadEnv, configHome } from './env.js';
|
|
5
|
+
import { buildConfig, createClient } from './client.js';
|
|
6
|
+
|
|
7
|
+
const CONTENT = {
|
|
8
|
+
posts: '/wp-json/wp/v2/posts',
|
|
9
|
+
pages: '/wp-json/wp/v2/pages',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const TAX = {
|
|
13
|
+
categories: '/wp-json/wp/v2/categories',
|
|
14
|
+
tags: '/wp-json/wp/v2/tags',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const MEDIA = '/wp-json/wp/v2/media';
|
|
18
|
+
const COMMENTS = '/wp-json/wp/v2/comments';
|
|
19
|
+
const USERS = '/wp-json/wp/v2/users';
|
|
20
|
+
|
|
21
|
+
function stripTags(html = '') {
|
|
22
|
+
return String(html).replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function printJson(value) {
|
|
26
|
+
console.log(JSON.stringify(value, null, 2));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function dry(message) {
|
|
30
|
+
console.log(`[dry-run] ${message}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function printItem(data, flags) {
|
|
34
|
+
if (flags.json) return printJson(data);
|
|
35
|
+
console.log(`✓ #${data.id} [${data.status}] ${stripTags(data.title?.rendered || '')}`);
|
|
36
|
+
if (data.link) console.log(` ${data.link}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 把 flags 中的指定键转成 REST 查询参数(- 转 _)。 */
|
|
40
|
+
function pick(flags, keys) {
|
|
41
|
+
const query = {};
|
|
42
|
+
for (const key of keys) {
|
|
43
|
+
const value = flags[key];
|
|
44
|
+
if (value === undefined || value === null || value === false) continue;
|
|
45
|
+
query[key.replace(/-/g, '_')] = String(value);
|
|
46
|
+
}
|
|
47
|
+
return query;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ------------------------------------------------------------------ doctor
|
|
51
|
+
|
|
52
|
+
export async function doctor(client, cfg, flags) {
|
|
53
|
+
const checks = [];
|
|
54
|
+
const add = (label, status, detail = '') => checks.push({ label, status, detail });
|
|
55
|
+
|
|
56
|
+
const problems = [];
|
|
57
|
+
if (!cfg.url) problems.push('WP_URL 未设置');
|
|
58
|
+
if (!cfg.user) problems.push('WP_USER 未设置');
|
|
59
|
+
if (!cfg.password) problems.push('WP_APP_PASSWORD 未设置');
|
|
60
|
+
if (cfg.url && !/^https:\/\//i.test(cfg.url)) problems.push('WP_URL 非 https');
|
|
61
|
+
if (problems.length) {
|
|
62
|
+
add('配置', 'fail', problems.join('; '));
|
|
63
|
+
renderDoctor(checks);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
add('配置', 'ok', `${cfg.url}(用户 ${cfg.user})`);
|
|
67
|
+
|
|
68
|
+
const probe = async (label, fn, fmt) => {
|
|
69
|
+
try {
|
|
70
|
+
const value = await fn();
|
|
71
|
+
add(label, 'ok', fmt ? fmt(value) : '');
|
|
72
|
+
return value;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
add(label, err.status ? 'fail' : 'error', err.message);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const root = await probe(
|
|
80
|
+
'REST 根 /wp-json/',
|
|
81
|
+
async () => (await client.request('GET', '/wp-json/')).data,
|
|
82
|
+
(d) => `${d.name || '?'} — ${d.url || '?'}`,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (root?.authentication) {
|
|
86
|
+
add('认证方式', 'ok', Object.keys(root.authentication).join(', ') || '(无)');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const me = await probe(
|
|
90
|
+
'当前用户 /wp/v2/users/me',
|
|
91
|
+
async () => (await client.request('GET', '/wp-json/wp/v2/users/me', { query: { context: 'edit' } })).data,
|
|
92
|
+
(d) => `${d.name} (@${d.slug}) 角色:${(d.roles || []).join(', ')}`,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (me?.capabilities) {
|
|
96
|
+
const caps = me.capabilities;
|
|
97
|
+
const labels = {
|
|
98
|
+
edit_posts: '编辑文章',
|
|
99
|
+
publish_posts: '发布文章',
|
|
100
|
+
upload_files: '上传媒体',
|
|
101
|
+
manage_categories: '管理分类',
|
|
102
|
+
manage_options: '管理选项(管理员)',
|
|
103
|
+
install_plugins: '安装插件',
|
|
104
|
+
activate_plugins: '启用插件',
|
|
105
|
+
install_themes: '安装主题',
|
|
106
|
+
switch_themes: '切换主题',
|
|
107
|
+
};
|
|
108
|
+
const have = Object.entries(labels)
|
|
109
|
+
.filter(([key]) => caps[key])
|
|
110
|
+
.map(([, label]) => label);
|
|
111
|
+
add('能力', 'ok', have.join('、') || '—');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
await probe(
|
|
115
|
+
'文章 /wp/v2/posts',
|
|
116
|
+
async () => (await client.request('GET', '/wp-json/wp/v2/posts', { query: { per_page: 1, context: 'edit' } })).headers.get('x-wp-total'),
|
|
117
|
+
(n) => `可读写,共 ${n ?? '?'} 篇`,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
await probe(
|
|
121
|
+
'媒体 /wp/v2/media',
|
|
122
|
+
async () => (await client.request('GET', MEDIA, { query: { per_page: 1 } })).headers.get('x-wp-total'),
|
|
123
|
+
(n) => `可读写,共 ${n ?? '?'} 项`,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
await probe(
|
|
127
|
+
'评论 /wp/v2/comments',
|
|
128
|
+
async () => (await client.request('GET', COMMENTS, { query: { per_page: 1 } })).headers.get('x-wp-total'),
|
|
129
|
+
(n) => `可读写,共 ${n ?? '?'} 条`,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
await probe(
|
|
133
|
+
'插件 /wp/v2/plugins',
|
|
134
|
+
async () => {
|
|
135
|
+
const { data } = await client.request('GET', '/wp-json/wp/v2/plugins');
|
|
136
|
+
return Array.isArray(data) ? data.length : 0;
|
|
137
|
+
},
|
|
138
|
+
(n) => `可安装/启停,已装 ${n} 个`,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
await probe(
|
|
142
|
+
'主题 /wp/v2/themes',
|
|
143
|
+
async () => {
|
|
144
|
+
const { data } = await client.request('GET', '/wp-json/wp/v2/themes');
|
|
145
|
+
return Array.isArray(data) ? data.length : 0;
|
|
146
|
+
},
|
|
147
|
+
(n) => `可列出/切换,共 ${n} 个`,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
await probe(
|
|
151
|
+
'版本探测',
|
|
152
|
+
async () => {
|
|
153
|
+
const res = await fetch(cfg.url + '/', { redirect: 'follow' });
|
|
154
|
+
const html = await res.text();
|
|
155
|
+
const m = html.match(/name=["']generator["'][^>]*content=["']WordPress\s+([^"']+)["']/i);
|
|
156
|
+
return m ? m[1] : null;
|
|
157
|
+
},
|
|
158
|
+
(v) => (v ? `WordPress ${v}` : '未暴露(常见于隐藏版本)'),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
renderDoctor(checks);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function renderDoctor(checks) {
|
|
165
|
+
const mark = { ok: '✓', warn: '!', fail: '✗', error: '✗' };
|
|
166
|
+
console.log('\nwpops doctor\n' + '─'.repeat(44));
|
|
167
|
+
for (const c of checks) {
|
|
168
|
+
console.log(`${mark[c.status] || '?'} ${c.label}${c.detail ? ` — ${c.detail}` : ''}`);
|
|
169
|
+
}
|
|
170
|
+
console.log('');
|
|
171
|
+
const failed = checks.filter((c) => c.status === 'fail' || c.status === 'error');
|
|
172
|
+
if (!failed.length) {
|
|
173
|
+
console.log('连接正常,凭据可用。');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
process.exitCode = 1;
|
|
177
|
+
console.log('存在问题,请看上面的 ✗。');
|
|
178
|
+
if (failed.some((c) => /401/.test(c.detail || ''))) {
|
|
179
|
+
console.log('提示:401 通常是凭据错误,或主机没有把 Authorization 头传给 PHP');
|
|
180
|
+
console.log(' (Apache/CGI/FastCGI 常见)。可在主机侧启用 HTTP_AUTHORIZATION 透传。');
|
|
181
|
+
}
|
|
182
|
+
if (failed.some((c) => /filesystem|credentials|无法连接/i.test(c.detail || ''))) {
|
|
183
|
+
console.log('提示:插件安装/启停要求文件系统可写(FS_METHOD 直连,或提供 FTP 凭据)。');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ------------------------------------------------------------------ users(me)
|
|
188
|
+
|
|
189
|
+
export async function me(client, flags) {
|
|
190
|
+
const { data } = await client.request('GET', `${USERS}/me`, { query: { context: 'edit' } });
|
|
191
|
+
if (flags.json) return printJson(data);
|
|
192
|
+
console.log(`#${data.id} ${data.name} (@${data.slug})`);
|
|
193
|
+
console.log(`角色:${(data.roles || []).join(', ')}`);
|
|
194
|
+
console.log(`邮箱:${data.email || '—'}`);
|
|
195
|
+
if (data.capabilities) {
|
|
196
|
+
const on = Object.entries(data.capabilities).filter(([, v]) => v).map(([k]) => k);
|
|
197
|
+
console.log(`能力:${on.join(', ')}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ------------------------------------------------------------------ content
|
|
202
|
+
|
|
203
|
+
const CONTENT_FILTERS = {
|
|
204
|
+
posts: ['orderby', 'order', 'author', 'after', 'before', 'categories', 'tags', 'slug', 'exclude', 'include', 'offset', 'sticky'],
|
|
205
|
+
pages: ['orderby', 'order', 'author', 'after', 'before', 'slug', 'parent', 'menu_order', 'exclude', 'include', 'offset'],
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export async function listContent(client, type, flags) {
|
|
209
|
+
const perPage = Number(flags['per-page'] ?? 10) || 10;
|
|
210
|
+
const page = Number(flags.page ?? 1) || 1;
|
|
211
|
+
const query = {
|
|
212
|
+
per_page: perPage,
|
|
213
|
+
page,
|
|
214
|
+
status: flags.status,
|
|
215
|
+
search: flags.search,
|
|
216
|
+
context: flags.context,
|
|
217
|
+
...pick(flags, CONTENT_FILTERS[type] || []),
|
|
218
|
+
};
|
|
219
|
+
const { data, headers } = await client.request('GET', CONTENT[type], { query });
|
|
220
|
+
if (flags.json) return printJson(data);
|
|
221
|
+
const total = headers.get('x-wp-total');
|
|
222
|
+
const totalPages = headers.get('x-wp-totalpages');
|
|
223
|
+
console.log(
|
|
224
|
+
`${type} 共 ${total ?? '?'} 条 / ${totalPages ?? '?'} 页;第 ${page} 页${totalPages ? `/${totalPages}` : ''},本页 ${data.length} 条:`,
|
|
225
|
+
);
|
|
226
|
+
for (const item of data) {
|
|
227
|
+
console.log(` #${item.id} [${item.status}] ${stripTags(item.title?.rendered || '(无标题)')}`);
|
|
228
|
+
}
|
|
229
|
+
if (totalPages && page < Number(totalPages)) {
|
|
230
|
+
console.log(` …下一页:wpops ${type} list --page ${page + 1} --per-page ${perPage}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function getContent(client, type, id, flags) {
|
|
235
|
+
const { data } = await client.request('GET', `${CONTENT[type]}/${Number(id)}`, {
|
|
236
|
+
query: { context: flags.context || 'edit' },
|
|
237
|
+
});
|
|
238
|
+
if (flags.json) return printJson(data);
|
|
239
|
+
console.log(`#${data.id} ${stripTags(data.title?.rendered || '')}`);
|
|
240
|
+
console.log(`状态:${data.status} 链接:${data.link}`);
|
|
241
|
+
console.log('─'.repeat(44));
|
|
242
|
+
console.log(data.content?.raw ?? stripTags(data.content?.rendered || ''));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function contentBody(flags) {
|
|
246
|
+
const body = {};
|
|
247
|
+
if (flags.title !== undefined) body.title = String(flags.title);
|
|
248
|
+
if (flags['from-file'] !== undefined) body.content = readFileSync(String(flags['from-file']), 'utf8');
|
|
249
|
+
else if (flags.content !== undefined) body.content = String(flags.content);
|
|
250
|
+
if (flags.status !== undefined) body.status = String(flags.status);
|
|
251
|
+
if (flags.slug !== undefined) body.slug = String(flags.slug);
|
|
252
|
+
if (flags.excerpt !== undefined) body.excerpt = String(flags.excerpt);
|
|
253
|
+
if (flags['featured-media'] !== undefined) body.featured_media = Number(flags['featured-media']);
|
|
254
|
+
if (flags.author !== undefined) body.author = Number(flags.author);
|
|
255
|
+
if (flags.date !== undefined) body.date = String(flags.date);
|
|
256
|
+
if (flags.categories) {
|
|
257
|
+
body.categories = String(flags.categories).split(',').map((n) => Number(n.trim())).filter(Boolean);
|
|
258
|
+
}
|
|
259
|
+
if (flags.tags) {
|
|
260
|
+
body.tags = String(flags.tags).split(',').map((n) => Number(n.trim())).filter(Boolean);
|
|
261
|
+
}
|
|
262
|
+
return body;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function createContent(client, type, flags) {
|
|
266
|
+
const body = contentBody(flags);
|
|
267
|
+
if (body.title === undefined && body.content === undefined) {
|
|
268
|
+
throw new Error('至少需要 --title 或 --content/--from-file');
|
|
269
|
+
}
|
|
270
|
+
if (flags['dry-run']) return dry(`创建 ${type} ${JSON.stringify(body)}`);
|
|
271
|
+
const { data } = await client.request('POST', CONTENT[type], { body });
|
|
272
|
+
printItem(data, flags);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function updateContent(client, type, id, flags) {
|
|
276
|
+
const body = contentBody(flags);
|
|
277
|
+
if (!Object.keys(body).length) throw new Error('没有可更新的字段');
|
|
278
|
+
if (flags['dry-run']) return dry(`更新 ${type} #${id} ${JSON.stringify(body)}`);
|
|
279
|
+
const { data } = await client.request('POST', `${CONTENT[type]}/${Number(id)}`, { body });
|
|
280
|
+
printItem(data, flags);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function deleteContent(client, type, id, flags) {
|
|
284
|
+
if (flags['dry-run']) return dry(`删除 ${type} #${id}${flags.force ? '(彻底)' : '(回收站)'}`);
|
|
285
|
+
const { data } = await client.request('DELETE', `${CONTENT[type]}/${Number(id)}`, {
|
|
286
|
+
query: { force: flags.force ? 'true' : undefined },
|
|
287
|
+
});
|
|
288
|
+
if (flags.json) return printJson(data);
|
|
289
|
+
console.log(data.deleted ? `✓ 已彻底删除 #${id}` : `✓ #${id} 已移入回收站`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ------------------------------------------------------------------ media
|
|
293
|
+
|
|
294
|
+
export async function listMedia(client, flags) {
|
|
295
|
+
const perPage = Number(flags['per-page'] ?? 10) || 10;
|
|
296
|
+
const page = Number(flags.page ?? 1) || 1;
|
|
297
|
+
const query = {
|
|
298
|
+
per_page: perPage,
|
|
299
|
+
page,
|
|
300
|
+
...pick(flags, ['media_type', 'mime_type', 'parent', 'author', 'after', 'before', 'orderby', 'order', 'offset', 'include', 'exclude']),
|
|
301
|
+
};
|
|
302
|
+
const { data, headers } = await client.request('GET', MEDIA, { query });
|
|
303
|
+
if (flags.json) return printJson(data);
|
|
304
|
+
const total = headers.get('x-wp-total');
|
|
305
|
+
const totalPages = headers.get('x-wp-totalpages');
|
|
306
|
+
console.log(
|
|
307
|
+
`媒体共 ${total ?? '?'} 项 / ${totalPages ?? '?'} 页;第 ${page} 页${totalPages ? `/${totalPages}` : ''},本页 ${data.length} 项:`,
|
|
308
|
+
);
|
|
309
|
+
for (const m of data) {
|
|
310
|
+
console.log(` #${m.id} ${m.title?.rendered || m.slug} ${m.source_url}`);
|
|
311
|
+
}
|
|
312
|
+
if (totalPages && page < Number(totalPages)) {
|
|
313
|
+
console.log(` …下一页:wpops media list --page ${page + 1} --per-page ${perPage}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function getMedia(client, id, flags) {
|
|
318
|
+
const { data } = await client.request('GET', `${MEDIA}/${Number(id)}`, { query: { context: flags.context || 'edit' } });
|
|
319
|
+
printJson(data);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function uploadMedia(client, file, flags) {
|
|
323
|
+
const name = basename(file);
|
|
324
|
+
if (flags['dry-run']) return dry(`上传媒体 ${file}${flags.title ? ` title="${flags.title}"` : ''}`);
|
|
325
|
+
const buffer = readFileSync(file);
|
|
326
|
+
const form = new FormData();
|
|
327
|
+
form.append('file', new Blob([buffer]), name);
|
|
328
|
+
if (flags.title) form.append('title', String(flags.title));
|
|
329
|
+
if (flags.alt) form.append('alt_text', String(flags.alt));
|
|
330
|
+
if (flags.caption) form.append('caption', String(flags.caption));
|
|
331
|
+
const { data } = await client.request('POST', MEDIA, {
|
|
332
|
+
form,
|
|
333
|
+
headers: { 'Content-Disposition': `attachment; filename="${name}"` },
|
|
334
|
+
});
|
|
335
|
+
if (flags.json) return printJson(data);
|
|
336
|
+
console.log(`✓ 已上传 #${data.id} ${data.source_url}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function updateMedia(client, id, flags) {
|
|
340
|
+
const body = {};
|
|
341
|
+
if (flags.title !== undefined) body.title = String(flags.title);
|
|
342
|
+
if (flags.alt !== undefined) body.alt_text = String(flags.alt);
|
|
343
|
+
if (flags.caption !== undefined) body.caption = String(flags.caption);
|
|
344
|
+
if (flags.description !== undefined) body.description = String(flags.description);
|
|
345
|
+
if (flags.post !== undefined) body.post = Number(flags.post);
|
|
346
|
+
if (!Object.keys(body).length) {
|
|
347
|
+
throw new Error('没有可更新的字段(--title/--alt/--caption/--description/--post)');
|
|
348
|
+
}
|
|
349
|
+
if (flags['dry-run']) return dry(`更新媒体 #${id} ${JSON.stringify(body)}`);
|
|
350
|
+
const { data } = await client.request('POST', `${MEDIA}/${Number(id)}`, { body });
|
|
351
|
+
if (flags.json) return printJson(data);
|
|
352
|
+
console.log(`✓ 已更新媒体 #${data.id}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export async function deleteMedia(client, id, flags) {
|
|
356
|
+
if (flags['dry-run']) return dry(`删除媒体 #${id}${flags.force ? '(彻底)' : '(回收站)'}`);
|
|
357
|
+
const { data } = await client.request('DELETE', `${MEDIA}/${Number(id)}`, {
|
|
358
|
+
query: { force: flags.force ? 'true' : undefined },
|
|
359
|
+
});
|
|
360
|
+
if (flags.json) return printJson(data);
|
|
361
|
+
console.log(data.deleted ? `✓ 已删除媒体 #${id}` : `✓ 媒体 #${id} 已移入回收站`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ------------------------------------------------------------------ plugins
|
|
365
|
+
|
|
366
|
+
export async function listPlugins(client, flags) {
|
|
367
|
+
const { data } = await client.request('GET', '/wp-json/wp/v2/plugins', {
|
|
368
|
+
query: { status: flags.status, search: flags.search },
|
|
369
|
+
});
|
|
370
|
+
if (flags.json) return printJson(data);
|
|
371
|
+
console.log(`已装插件 ${data.length} 个:`);
|
|
372
|
+
for (const p of data) {
|
|
373
|
+
console.log(` [${p.status === 'active' ? '启用' : '停用'}] ${p.name} ${p.version} (${p.plugin})`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export async function installPlugin(client, slug, flags) {
|
|
378
|
+
if (flags['dry-run']) return dry(`安装插件 ${slug}${flags.activate ? '(并启用)' : ''}`);
|
|
379
|
+
const { data } = await client.request('POST', '/wp-json/wp/v2/plugins', {
|
|
380
|
+
body: { slug: String(slug), status: flags.activate ? 'active' : 'inactive' },
|
|
381
|
+
});
|
|
382
|
+
if (flags.json) return printJson(data);
|
|
383
|
+
console.log(`✓ 已安装 ${data.name} ${data.version} [${data.status === 'active' ? '启用' : '停用'}]`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export async function setPluginStatus(client, plugin, activate, flags) {
|
|
387
|
+
if (flags['dry-run']) return dry(`${activate ? '启用' : '停用'}插件 ${plugin}`);
|
|
388
|
+
const { data } = await client.request('POST', `/wp-json/wp/v2/plugins/${encodeURIComponent(plugin)}`, {
|
|
389
|
+
body: { status: activate ? 'active' : 'inactive' },
|
|
390
|
+
});
|
|
391
|
+
if (flags.json) return printJson(data);
|
|
392
|
+
console.log(`✓ ${data.name} → ${data.status === 'active' ? '已启用' : '已停用'}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ------------------------------------------------------------------ themes
|
|
396
|
+
|
|
397
|
+
export async function listThemes(client, flags) {
|
|
398
|
+
const { data } = await client.request('GET', '/wp-json/wp/v2/themes');
|
|
399
|
+
if (flags.json) return printJson(data);
|
|
400
|
+
console.log(`主题 ${data.length} 个:`);
|
|
401
|
+
for (const t of data) {
|
|
402
|
+
console.log(` ${t.status === 'active' ? '*' : ' '} ${stripTags(t.name?.rendered || t.stylesheet)} ${t.version} (${t.stylesheet})`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export async function activateTheme(client, stylesheet, flags) {
|
|
407
|
+
if (flags['dry-run']) return dry(`切换主题 ${stylesheet}`);
|
|
408
|
+
const { data } = await client.request('POST', `/wp-json/wp/v2/themes/${encodeURIComponent(stylesheet)}`, {
|
|
409
|
+
body: { status: 'active' },
|
|
410
|
+
});
|
|
411
|
+
if (flags.json) return printJson(data);
|
|
412
|
+
console.log(`✓ 已切换到主题 ${stripTags(data.name?.rendered || stylesheet)}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ------------------------------------------------------------------ taxonomy
|
|
416
|
+
|
|
417
|
+
export async function listTaxonomy(client, tax, flags) {
|
|
418
|
+
const { data, headers } = await client.request('GET', TAX[tax], {
|
|
419
|
+
query: {
|
|
420
|
+
per_page: flags['per-page'] ?? 100,
|
|
421
|
+
page: flags.page,
|
|
422
|
+
search: flags.search,
|
|
423
|
+
orderby: flags.orderby,
|
|
424
|
+
order: flags.order,
|
|
425
|
+
parent: flags.parent,
|
|
426
|
+
hide_empty: flags['hide-empty'] ? 'true' : 'false',
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
if (flags.json) return printJson(data);
|
|
430
|
+
console.log(`${tax} 共 ${headers.get('x-wp-total') ?? '?'} 个:`);
|
|
431
|
+
for (const t of data) {
|
|
432
|
+
console.log(` #${t.id} ${t.name} (${t.count}) slug=${t.slug}${t.parent ? ` parent=#${t.parent}` : ''}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export async function getTaxonomy(client, tax, id) {
|
|
437
|
+
const { data } = await client.request('GET', `${TAX[tax]}/${Number(id)}`);
|
|
438
|
+
printJson(data);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function taxonomyBody(flags) {
|
|
442
|
+
const body = {};
|
|
443
|
+
if (flags.name !== undefined) body.name = String(flags.name);
|
|
444
|
+
if (flags.slug !== undefined) body.slug = String(flags.slug);
|
|
445
|
+
if (flags.parent !== undefined) body.parent = Number(flags.parent);
|
|
446
|
+
if (flags.description !== undefined) body.description = String(flags.description);
|
|
447
|
+
return body;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export async function createTaxonomy(client, tax, flags) {
|
|
451
|
+
const body = taxonomyBody(flags);
|
|
452
|
+
if (body.name === undefined) throw new Error(`创建 ${tax} 需要 --name`);
|
|
453
|
+
if (flags['dry-run']) return dry(`创建 ${tax} ${JSON.stringify(body)}`);
|
|
454
|
+
const { data } = await client.request('POST', TAX[tax], { body });
|
|
455
|
+
if (flags.json) return printJson(data);
|
|
456
|
+
console.log(`✓ 已创建 ${tax} #${data.id} ${data.name}`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export async function updateTaxonomy(client, tax, id, flags) {
|
|
460
|
+
const body = taxonomyBody(flags);
|
|
461
|
+
if (!Object.keys(body).length) throw new Error('没有可更新的字段(--name/--slug/--parent/--description)');
|
|
462
|
+
if (flags['dry-run']) return dry(`更新 ${tax} #${id} ${JSON.stringify(body)}`);
|
|
463
|
+
const { data } = await client.request('POST', `${TAX[tax]}/${Number(id)}`, { body });
|
|
464
|
+
if (flags.json) return printJson(data);
|
|
465
|
+
console.log(`✓ 已更新 ${tax} #${data.id} ${data.name}`);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export async function deleteTaxonomy(client, tax, id, flags) {
|
|
469
|
+
if (!flags.force) throw new Error(`删除 ${tax} 需要 --force(不可撤销)`);
|
|
470
|
+
if (flags['dry-run']) return dry(`删除 ${tax} #${id}`);
|
|
471
|
+
const { data } = await client.request('DELETE', `${TAX[tax]}/${Number(id)}`, { query: { force: 'true' } });
|
|
472
|
+
if (flags.json) return printJson(data);
|
|
473
|
+
console.log(data.deleted ? `✓ 已删除 ${tax} #${id}` : `? ${tax} #${id} 未删除`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ------------------------------------------------------------------ comments
|
|
477
|
+
|
|
478
|
+
export async function listComments(client, flags) {
|
|
479
|
+
const { data, headers } = await client.request('GET', COMMENTS, {
|
|
480
|
+
query: {
|
|
481
|
+
per_page: flags['per-page'] ?? 10,
|
|
482
|
+
page: flags.page,
|
|
483
|
+
...pick(flags, ['post', 'status', 'search', 'author', 'orderby', 'order', 'type']),
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
if (flags.json) return printJson(data);
|
|
487
|
+
console.log(`评论共 ${headers.get('x-wp-total') ?? '?'} 条,本页 ${data.length}:`);
|
|
488
|
+
for (const c of data) {
|
|
489
|
+
console.log(` #${c.id} [${c.status}] post#${c.post} ${stripTags(c.author_name || '')}: ${stripTags(c.content?.rendered || '')}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export async function getComment(client, id) {
|
|
494
|
+
const { data } = await client.request('GET', `${COMMENTS}/${Number(id)}`);
|
|
495
|
+
printJson(data);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export async function updateComment(client, id, flags) {
|
|
499
|
+
const body = {};
|
|
500
|
+
if (flags.status !== undefined) body.status = String(flags.status);
|
|
501
|
+
if (flags.content !== undefined) body.content = String(flags.content);
|
|
502
|
+
if (!Object.keys(body).length) {
|
|
503
|
+
throw new Error('没有可更新的字段(--status approve|hold|spam|trash / --content)');
|
|
504
|
+
}
|
|
505
|
+
if (flags['dry-run']) return dry(`更新评论 #${id} ${JSON.stringify(body)}`);
|
|
506
|
+
const { data } = await client.request('POST', `${COMMENTS}/${Number(id)}`, { body });
|
|
507
|
+
if (flags.json) return printJson(data);
|
|
508
|
+
console.log(`✓ 评论 #${data.id} → ${data.status}`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export async function deleteComment(client, id, flags) {
|
|
512
|
+
if (flags['dry-run']) return dry(`删除评论 #${id}${flags.force ? '(彻底)' : '(回收站)'}`);
|
|
513
|
+
const { data } = await client.request('DELETE', `${COMMENTS}/${Number(id)}`, {
|
|
514
|
+
query: { force: flags.force ? 'true' : undefined },
|
|
515
|
+
});
|
|
516
|
+
if (flags.json) return printJson(data);
|
|
517
|
+
console.log(data.deleted ? `✓ 已彻底删除评论 #${id}` : `✓ 评论 #${id} 已移入回收站`);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ------------------------------------------------------------------ users
|
|
521
|
+
|
|
522
|
+
export async function listUsers(client, flags) {
|
|
523
|
+
const { data, headers } = await client.request('GET', USERS, {
|
|
524
|
+
query: {
|
|
525
|
+
per_page: flags['per-page'] ?? 10,
|
|
526
|
+
page: flags.page,
|
|
527
|
+
context: 'edit',
|
|
528
|
+
...pick(flags, ['search', 'roles', 'orderby', 'order', 'who']),
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
if (flags.json) return printJson(data);
|
|
532
|
+
console.log(`用户共 ${headers.get('x-wp-total') ?? '?'} 个,本页 ${data.length}:`);
|
|
533
|
+
for (const u of data) {
|
|
534
|
+
console.log(` #${u.id} ${u.name} (@${u.slug}) ${(u.roles || []).join(',')} ${u.email || ''}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export async function getUser(client, id) {
|
|
539
|
+
const { data } = await client.request('GET', `${USERS}/${Number(id)}`, { query: { context: 'edit' } });
|
|
540
|
+
printJson(data);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export async function createUser(client, flags) {
|
|
544
|
+
if (flags.username === undefined) throw new Error('创建用户需要 --username');
|
|
545
|
+
if (flags.email === undefined) throw new Error('创建用户需要 --email');
|
|
546
|
+
const body = { username: String(flags.username), email: String(flags.email) };
|
|
547
|
+
if (flags.password !== undefined) body.password = String(flags.password);
|
|
548
|
+
if (flags.name !== undefined) body.name = String(flags.name);
|
|
549
|
+
if (flags.role !== undefined) body.roles = String(flags.role).split(',').map((s) => s.trim()).filter(Boolean);
|
|
550
|
+
if (flags['dry-run']) return dry(`创建用户 ${body.username} <${body.email}> roles=${(body.roles || ['subscriber']).join(',')}`);
|
|
551
|
+
const { data } = await client.request('POST', USERS, { body });
|
|
552
|
+
if (flags.json) return printJson(data);
|
|
553
|
+
console.log(`✓ 已创建用户 #${data.id} ${data.name} (@${data.slug})`);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export async function updateUser(client, id, flags) {
|
|
557
|
+
const body = {};
|
|
558
|
+
for (const key of ['name', 'email', 'password', 'first_name', 'last_name', 'url', 'description', 'slug', 'nickname']) {
|
|
559
|
+
if (flags[key] !== undefined) body[key] = String(flags[key]);
|
|
560
|
+
}
|
|
561
|
+
if (flags.role !== undefined) body.roles = String(flags.role).split(',').map((s) => s.trim()).filter(Boolean);
|
|
562
|
+
if (!Object.keys(body).length) throw new Error('没有可更新的字段(--name/--email/--password/--role/...)');
|
|
563
|
+
if (flags['dry-run']) return dry(`更新用户 #${id} ${JSON.stringify({ ...body, password: body.password ? '***' : undefined })}`);
|
|
564
|
+
const { data } = await client.request('POST', `${USERS}/${Number(id)}`, { body });
|
|
565
|
+
if (flags.json) return printJson(data);
|
|
566
|
+
console.log(`✓ 已更新用户 #${data.id} ${data.name}`);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export async function deleteUser(client, id, flags) {
|
|
570
|
+
if (!flags.force) throw new Error('删除用户需要 --force(不可撤销)');
|
|
571
|
+
if (flags['dry-run']) return dry(`删除用户 #${id}${flags.reassign ? `(内容转交 #${flags.reassign})` : ''}`);
|
|
572
|
+
const { data } = await client.request('DELETE', `${USERS}/${Number(id)}`, {
|
|
573
|
+
query: { force: 'true', reassign: flags.reassign },
|
|
574
|
+
});
|
|
575
|
+
if (flags.json) return printJson(data);
|
|
576
|
+
console.log(data.deleted ? `✓ 已删除用户 #${id}` : `? 用户 #${id}`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ------------------------------------------------------------------ raw
|
|
580
|
+
|
|
581
|
+
export async function raw(client, method, path, flags) {
|
|
582
|
+
const upper = String(method).toUpperCase();
|
|
583
|
+
let body;
|
|
584
|
+
if (flags.data !== undefined) body = JSON.parse(String(flags.data));
|
|
585
|
+
else if (flags['data-file'] !== undefined) body = JSON.parse(readFileSync(String(flags['data-file']), 'utf8'));
|
|
586
|
+
|
|
587
|
+
if (flags['dry-run'] && !['GET', 'HEAD', 'OPTIONS'].includes(upper)) {
|
|
588
|
+
return dry(`${upper} ${path}${body !== undefined ? ` ${JSON.stringify(body)}` : ''}`);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const { status, data } = await client.request(upper, path, { body });
|
|
592
|
+
if (typeof data === 'string') {
|
|
593
|
+
if (flags.json) return printJson({ status, data });
|
|
594
|
+
console.log(data);
|
|
595
|
+
} else {
|
|
596
|
+
printJson(data);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ------------------------------------------------------------------ setup
|
|
601
|
+
|
|
602
|
+
function promptLine(query) {
|
|
603
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
604
|
+
return new Promise((resolve) => {
|
|
605
|
+
rl.question(query, (answer) => {
|
|
606
|
+
rl.close();
|
|
607
|
+
resolve(answer.trim());
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function promptHidden(query) {
|
|
613
|
+
return new Promise((resolve) => {
|
|
614
|
+
const stdin = process.stdin;
|
|
615
|
+
process.stdout.write(query);
|
|
616
|
+
const wasRaw = stdin.isRaw;
|
|
617
|
+
if (stdin.setRawMode) stdin.setRawMode(true);
|
|
618
|
+
stdin.resume();
|
|
619
|
+
let value = '';
|
|
620
|
+
const onData = (chunk) => {
|
|
621
|
+
for (const ch of chunk.toString('utf8')) {
|
|
622
|
+
if (ch === '\r' || ch === '\n') {
|
|
623
|
+
if (stdin.setRawMode) stdin.setRawMode(wasRaw);
|
|
624
|
+
stdin.removeListener('data', onData);
|
|
625
|
+
stdin.pause();
|
|
626
|
+
process.stdout.write('\n');
|
|
627
|
+
resolve(value);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (ch === '\u0003') {
|
|
631
|
+
process.stdout.write('\n');
|
|
632
|
+
process.exit(130);
|
|
633
|
+
}
|
|
634
|
+
if (ch === '\u007f' || ch === '\b') value = value.slice(0, -1);
|
|
635
|
+
else if (ch >= ' ') value += ch;
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
stdin.on('data', onData);
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** 交互式(或带 flag)配置一个站点,写入 sites/<名字>.env 并体检。 */
|
|
643
|
+
export async function setup(flags) {
|
|
644
|
+
const tty = Boolean(process.stdin.isTTY);
|
|
645
|
+
const need = (what) => {
|
|
646
|
+
if (!tty) throw new Error(`非交互模式请提供 ${what}`);
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
let url = flags.url;
|
|
650
|
+
if (!url) {
|
|
651
|
+
need('--url');
|
|
652
|
+
url = await promptLine('站点 URL(https://...):');
|
|
653
|
+
}
|
|
654
|
+
url = String(url).replace(/\/+$/, '');
|
|
655
|
+
if (!/^https:\/\//i.test(url)) throw new Error('URL 必须以 https:// 开头');
|
|
656
|
+
|
|
657
|
+
let user = flags.user;
|
|
658
|
+
if (!user) {
|
|
659
|
+
need('--user');
|
|
660
|
+
user = await promptLine('用户名:');
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
let password = flags.password;
|
|
664
|
+
if (!password) {
|
|
665
|
+
need('--password');
|
|
666
|
+
password = await promptHidden('应用密码(输入不回显):');
|
|
667
|
+
}
|
|
668
|
+
if (!password) throw new Error('应用密码为空');
|
|
669
|
+
|
|
670
|
+
const defaultName = (() => {
|
|
671
|
+
try {
|
|
672
|
+
return new URL(url).hostname.replace(/^www\./, '');
|
|
673
|
+
} catch {
|
|
674
|
+
return 'default';
|
|
675
|
+
}
|
|
676
|
+
})();
|
|
677
|
+
let name = flags.name;
|
|
678
|
+
if (!name && tty) {
|
|
679
|
+
const answer = await promptLine(`站点名(存为 sites/<名字>.env)[${defaultName}]:`);
|
|
680
|
+
name = answer || defaultName;
|
|
681
|
+
}
|
|
682
|
+
name = name || defaultName;
|
|
683
|
+
|
|
684
|
+
const dir = join(configHome(), 'sites');
|
|
685
|
+
mkdirSync(dir, { recursive: true });
|
|
686
|
+
const file = join(dir, `${name}.env`);
|
|
687
|
+
if (existsSync(file) && !flags.force) {
|
|
688
|
+
throw new Error(`已存在 ${file};要覆盖请加 --force`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const content = [
|
|
692
|
+
'# wpops 站点配置(由 wpops setup 生成)',
|
|
693
|
+
`WP_URL=${url}`,
|
|
694
|
+
`WP_USER=${user}`,
|
|
695
|
+
`WP_APP_PASSWORD=${password}`,
|
|
696
|
+
'',
|
|
697
|
+
].join('\n');
|
|
698
|
+
writeFileSync(file, content, 'utf8');
|
|
699
|
+
|
|
700
|
+
console.log(`\n✓ 已写入 ${file}\n正在体检...`);
|
|
701
|
+
const cfg = buildConfig(loadEnv(name));
|
|
702
|
+
const client = createClient(cfg);
|
|
703
|
+
return doctor(client, cfg, {});
|
|
704
|
+
}
|