wpops 0.1.1 → 0.3.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/lib/commands.js CHANGED
@@ -2,7 +2,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { basename, join } from 'node:path';
3
3
  import { createInterface } from 'node:readline';
4
4
  import { loadEnv, configHome } from './env.js';
5
- import { buildConfig, createClient } from './client.js';
5
+ import { buildConfig, createClient, WpError } from './client.js';
6
+ import { stripTags, printJson, dry, warn, pick, listRequest, parseJson, readJsonFile } from './util.js';
7
+ import { UsageError } from './errors.js';
6
8
 
7
9
  const CONTENT = {
8
10
  posts: '/wp-json/wp/v2/posts',
@@ -14,37 +16,29 @@ const TAX = {
14
16
  tags: '/wp-json/wp/v2/tags',
15
17
  };
16
18
 
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();
19
+ /** 内置类型走映射;其余按 rest_base 直接拼,支持任意 show_in_rest 的 CPT。 */
20
+ function contentPath(type) {
21
+ return CONTENT[type] || `/wp-json/wp/v2/${String(type).replace(/^\/+/, '')}`;
23
22
  }
24
23
 
25
- function printJson(value) {
26
- console.log(JSON.stringify(value, null, 2));
24
+ function taxPath(tax) {
25
+ return TAX[tax] || `/wp-json/wp/v2/${String(tax).replace(/^\/+/, '')}`;
27
26
  }
28
27
 
29
- function dry(message) {
30
- console.log(`[dry-run] ${message}`);
31
- }
28
+ const MEDIA = '/wp-json/wp/v2/media';
29
+ const COMMENTS = '/wp-json/wp/v2/comments';
30
+ const USERS = '/wp-json/wp/v2/users';
31
+ const MENUS = '/wp-json/wp/v2/menus';
32
+ const MENU_ITEMS = '/wp-json/wp/v2/menu-items';
32
33
 
33
- function printItem(data, flags) {
34
+ /** 内容写操作的结果输出:草稿/待审等非公开状态额外回显后台编辑(预览)链接。 */
35
+ function printItem(data, flags, client) {
34
36
  if (flags.json) return printJson(data);
35
37
  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);
38
+ if (data.link) console.log(` 链接:${data.link}`);
39
+ if (data.status && data.status !== 'publish' && client?.root) {
40
+ console.log(` 编辑/预览:${client.root}/wp-admin/post.php?post=${Number(data.id)}&action=edit`);
46
41
  }
47
- return query;
48
42
  }
49
43
 
50
44
  // ------------------------------------------------------------------ doctor
@@ -216,10 +210,8 @@ export async function listContent(client, type, flags) {
216
210
  context: flags.context,
217
211
  ...pick(flags, CONTENT_FILTERS[type] || []),
218
212
  };
219
- const { data, headers } = await client.request('GET', CONTENT[type], { query });
213
+ const { data, total, totalPages } = await listRequest(client, contentPath(type), query, flags);
220
214
  if (flags.json) return printJson(data);
221
- const total = headers.get('x-wp-total');
222
- const totalPages = headers.get('x-wp-totalpages');
223
215
  console.log(
224
216
  `${type} 共 ${total ?? '?'} 条 / ${totalPages ?? '?'} 页;第 ${page} 页${totalPages ? `/${totalPages}` : ''},本页 ${data.length} 条:`,
225
217
  );
@@ -232,7 +224,7 @@ export async function listContent(client, type, flags) {
232
224
  }
233
225
 
234
226
  export async function getContent(client, type, id, flags) {
235
- const { data } = await client.request('GET', `${CONTENT[type]}/${Number(id)}`, {
227
+ const { data } = await client.request('GET', `${contentPath(type)}/${Number(id)}`, {
236
228
  query: { context: flags.context || 'edit' },
237
229
  });
238
230
  if (flags.json) return printJson(data);
@@ -265,24 +257,68 @@ function contentBody(flags) {
265
257
  export async function createContent(client, type, flags) {
266
258
  const body = contentBody(flags);
267
259
  if (body.title === undefined && body.content === undefined) {
268
- throw new Error('至少需要 --title 或 --content/--from-file');
260
+ throw new UsageError('至少需要 --title 或 --content/--from-file');
269
261
  }
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);
262
+ // 未显式指定状态时按草稿创建,不依赖服务端默认(避免"直接发布")。
263
+ if (body.status === undefined) body.status = 'draft';
264
+ if (flags['dry-run']) return dry(`将创建 ${type}(status=${body.status}) ${JSON.stringify(body)}`);
265
+ const { data } = await client.request('POST', contentPath(type), { body });
266
+ printItem(data, flags, client);
273
267
  }
274
268
 
275
269
  export async function updateContent(client, type, id, flags) {
276
270
  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);
271
+ if (!Object.keys(body).length) throw new UsageError('没有可更新的字段');
272
+ // 修改已发布内容的正文(未显式改状态)会立即影响线上:先提示可回滚(--yes 跳过预检与提示)。
273
+ if (body.status === undefined && !flags.yes) {
274
+ try {
275
+ const { data: current } = await client.request('GET', `${contentPath(type)}/${Number(id)}`, {
276
+ query: { context: 'edit' },
277
+ });
278
+ if (current.status === 'publish') {
279
+ warn(
280
+ `正在修改已发布内容 ${type} #${id};已生成修订,回滚:wpops revisions restore ${id} <rev> --type ${type}`,
281
+ flags,
282
+ );
283
+ }
284
+ } catch {
285
+ // 预检失败(权限/网络)不阻断写入,也不提示。
286
+ }
287
+ }
288
+ if (flags['dry-run']) return dry(`将更新 ${type} #${id} ${JSON.stringify(body)}`);
289
+ const { data } = await client.request('POST', `${contentPath(type)}/${Number(id)}`, { body });
290
+ printItem(data, flags, client);
291
+ }
292
+
293
+ /** 显式发布 / 下线为草稿。 */
294
+ export async function setContentStatus(client, type, id, status, flags) {
295
+ const label = status === 'publish' ? '发布' : '下线为草稿';
296
+ if (flags['dry-run']) return dry(`将${label} ${type} #${id}`);
297
+ const { data } = await client.request('POST', `${contentPath(type)}/${Number(id)}`, { body: { status } });
298
+ if (flags.json) return printJson(data);
299
+ console.log(`✓ #${data.id} → ${data.status} ${data.link || ''}`);
300
+ if (data.status !== 'publish' && client.root) {
301
+ console.log(` 编辑/预览:${client.root}/wp-admin/post.php?post=${Number(data.id)}&action=edit`);
302
+ }
303
+ }
304
+
305
+ /** 导出原始正文 content.raw 到文件(或 stdout),便于本地改排版后再 `update --from-file`。 */
306
+ export async function exportContent(client, type, id, flags) {
307
+ const { data } = await client.request('GET', `${contentPath(type)}/${Number(id)}`, {
308
+ query: { context: 'edit' },
309
+ });
310
+ const raw = data.content?.raw ?? stripTags(data.content?.rendered || '');
311
+ if (flags.file !== undefined) {
312
+ writeFileSync(String(flags.file), raw, 'utf8');
313
+ console.log(`✓ 已导出 ${type} #${id} → ${flags.file}`);
314
+ return;
315
+ }
316
+ process.stdout.write(raw.endsWith('\n') ? raw : `${raw}\n`);
281
317
  }
282
318
 
283
319
  export async function deleteContent(client, type, id, flags) {
284
320
  if (flags['dry-run']) return dry(`删除 ${type} #${id}${flags.force ? '(彻底)' : '(回收站)'}`);
285
- const { data } = await client.request('DELETE', `${CONTENT[type]}/${Number(id)}`, {
321
+ const { data } = await client.request('DELETE', `${contentPath(type)}/${Number(id)}`, {
286
322
  query: { force: flags.force ? 'true' : undefined },
287
323
  });
288
324
  if (flags.json) return printJson(data);
@@ -299,10 +335,8 @@ export async function listMedia(client, flags) {
299
335
  page,
300
336
  ...pick(flags, ['media_type', 'mime_type', 'parent', 'author', 'after', 'before', 'orderby', 'order', 'offset', 'include', 'exclude']),
301
337
  };
302
- const { data, headers } = await client.request('GET', MEDIA, { query });
338
+ const { data, total, totalPages } = await listRequest(client, MEDIA, query, flags);
303
339
  if (flags.json) return printJson(data);
304
- const total = headers.get('x-wp-total');
305
- const totalPages = headers.get('x-wp-totalpages');
306
340
  console.log(
307
341
  `媒体共 ${total ?? '?'} 项 / ${totalPages ?? '?'} 页;第 ${page} 页${totalPages ? `/${totalPages}` : ''},本页 ${data.length} 项:`,
308
342
  );
@@ -344,7 +378,7 @@ export async function updateMedia(client, id, flags) {
344
378
  if (flags.description !== undefined) body.description = String(flags.description);
345
379
  if (flags.post !== undefined) body.post = Number(flags.post);
346
380
  if (!Object.keys(body).length) {
347
- throw new Error('没有可更新的字段(--title/--alt/--caption/--description/--post)');
381
+ throw new UsageError('没有可更新的字段(--title/--alt/--caption/--description/--post)');
348
382
  }
349
383
  if (flags['dry-run']) return dry(`更新媒体 #${id} ${JSON.stringify(body)}`);
350
384
  const { data } = await client.request('POST', `${MEDIA}/${Number(id)}`, { body });
@@ -352,6 +386,56 @@ export async function updateMedia(client, id, flags) {
352
386
  console.log(`✓ 已更新媒体 #${data.id}`);
353
387
  }
354
388
 
389
+ /** 图像编辑(裁剪/旋转),POST /media/<id>/edit。缺 --src 时自动取当前 source_url。 */
390
+ export async function editMediaImage(client, id, flags) {
391
+ const body = {};
392
+ if (flags.src !== undefined) body.src = String(flags.src);
393
+ if (flags.rotation !== undefined) body.rotation = Number(flags.rotation);
394
+ if (flags['dest-width'] !== undefined) body.width = Number(flags['dest-width']);
395
+ if (flags['dest-height'] !== undefined) body.height = Number(flags['dest-height']);
396
+ if (flags.x !== undefined) body.x = Number(flags.x);
397
+ if (flags.y !== undefined) body.y = Number(flags.y);
398
+ const modifiers = [];
399
+ if (flags.crop) modifiers.push('crop');
400
+ if (flags.modifiers !== undefined) {
401
+ modifiers.push(...String(flags.modifiers).split(',').map((s) => s.trim()).filter(Boolean));
402
+ }
403
+ if (modifiers.length) body.modifiers = modifiers;
404
+ if (flags.title !== undefined) body.title = String(flags.title);
405
+ if (flags.alt !== undefined) body.alt_text = String(flags.alt);
406
+ if (flags.caption !== undefined) body.caption = String(flags.caption);
407
+ if (flags.description !== undefined) body.description = String(flags.description);
408
+ if (flags.post !== undefined) body.post = Number(flags.post);
409
+ if (!Object.keys(body).length) {
410
+ throw new UsageError('图像编辑需要至少一个参数(--rotation/--crop/--x/--y/--dest-width/--dest-height/--modifiers/--src/...)');
411
+ }
412
+ if (flags['dry-run']) return dry(`编辑媒体 #${id} ${JSON.stringify(body)}`);
413
+ if (body.src === undefined) {
414
+ const { data } = await client.request('GET', `${MEDIA}/${Number(id)}`, { query: { context: 'edit' } });
415
+ body.src = data.source_url;
416
+ }
417
+ if (!body.src) throw new UsageError('无法确定图片来源,请显式传 --src <url>');
418
+ const { data } = await client.request('POST', `${MEDIA}/${Number(id)}/edit`, { body });
419
+ if (flags.json) return printJson(data);
420
+ console.log(`✓ 已编辑媒体 #${data.id} ${data.source_url || ''}`);
421
+ }
422
+
423
+ /** 从 URL 导入媒体:sideload,POST /media(带 url)。 */
424
+ export async function sideloadMedia(client, flags) {
425
+ if (flags.url === undefined) throw new UsageError('media sideload 需要 --url <图片地址>');
426
+ const body = { url: String(flags.url) };
427
+ if (flags.title !== undefined) body.title = String(flags.title);
428
+ if (flags.alt !== undefined) body.alt_text = String(flags.alt);
429
+ if (flags.caption !== undefined) body.caption = String(flags.caption);
430
+ if (flags.description !== undefined) body.description = String(flags.description);
431
+ if (flags.slug !== undefined) body.slug = String(flags.slug);
432
+ if (flags.post !== undefined) body.post = Number(flags.post);
433
+ if (flags['dry-run']) return dry(`从 URL 导入媒体 ${body.url}`);
434
+ const { data } = await client.request('POST', MEDIA, { body });
435
+ if (flags.json) return printJson(data);
436
+ console.log(`✓ 已导入媒体 #${data.id} ${data.source_url || ''}`);
437
+ }
438
+
355
439
  export async function deleteMedia(client, id, flags) {
356
440
  if (flags['dry-run']) return dry(`删除媒体 #${id}${flags.force ? '(彻底)' : '(回收站)'}`);
357
441
  const { data } = await client.request('DELETE', `${MEDIA}/${Number(id)}`, {
@@ -392,6 +476,14 @@ export async function setPluginStatus(client, plugin, activate, flags) {
392
476
  console.log(`✓ ${data.name} → ${data.status === 'active' ? '已启用' : '已停用'}`);
393
477
  }
394
478
 
479
+ export async function deletePlugin(client, plugin, flags) {
480
+ if (flags['dry-run']) return dry(`删除插件 ${plugin}`);
481
+ if (!flags.force) throw new UsageError('删除插件需要 --force(不可撤销)');
482
+ const { data } = await client.request('DELETE', `/wp-json/wp/v2/plugins/${encodeURIComponent(plugin)}`);
483
+ if (flags.json) return printJson(data);
484
+ console.log(data.deleted ? `✓ 已删除插件 ${plugin}` : `? 插件 ${plugin} 未删除`);
485
+ }
486
+
395
487
  // ------------------------------------------------------------------ themes
396
488
 
397
489
  export async function listThemes(client, flags) {
@@ -415,26 +507,25 @@ export async function activateTheme(client, stylesheet, flags) {
415
507
  // ------------------------------------------------------------------ taxonomy
416
508
 
417
509
  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
- });
510
+ const query = {
511
+ per_page: flags['per-page'] ?? 100,
512
+ page: flags.page,
513
+ search: flags.search,
514
+ orderby: flags.orderby,
515
+ order: flags.order,
516
+ parent: flags.parent,
517
+ hide_empty: flags['hide-empty'] ? 'true' : 'false',
518
+ };
519
+ const { data, total } = await listRequest(client, taxPath(tax), query, flags);
429
520
  if (flags.json) return printJson(data);
430
- console.log(`${tax} 共 ${headers.get('x-wp-total') ?? '?'} 个:`);
521
+ console.log(`${tax} 共 ${total ?? '?'} 个:`);
431
522
  for (const t of data) {
432
523
  console.log(` #${t.id} ${t.name} (${t.count}) slug=${t.slug}${t.parent ? ` parent=#${t.parent}` : ''}`);
433
524
  }
434
525
  }
435
526
 
436
527
  export async function getTaxonomy(client, tax, id) {
437
- const { data } = await client.request('GET', `${TAX[tax]}/${Number(id)}`);
528
+ const { data } = await client.request('GET', `${taxPath(tax)}/${Number(id)}`);
438
529
  printJson(data);
439
530
  }
440
531
 
@@ -449,42 +540,224 @@ function taxonomyBody(flags) {
449
540
 
450
541
  export async function createTaxonomy(client, tax, flags) {
451
542
  const body = taxonomyBody(flags);
452
- if (body.name === undefined) throw new Error(`创建 ${tax} 需要 --name`);
543
+ if (body.name === undefined) throw new UsageError(`创建 ${tax} 需要 --name`);
453
544
  if (flags['dry-run']) return dry(`创建 ${tax} ${JSON.stringify(body)}`);
454
- const { data } = await client.request('POST', TAX[tax], { body });
545
+ const { data } = await client.request('POST', taxPath(tax), { body });
455
546
  if (flags.json) return printJson(data);
456
547
  console.log(`✓ 已创建 ${tax} #${data.id} ${data.name}`);
457
548
  }
458
549
 
459
550
  export async function updateTaxonomy(client, tax, id, flags) {
460
551
  const body = taxonomyBody(flags);
461
- if (!Object.keys(body).length) throw new Error('没有可更新的字段(--name/--slug/--parent/--description)');
552
+ if (!Object.keys(body).length) throw new UsageError('没有可更新的字段(--name/--slug/--parent/--description)');
462
553
  if (flags['dry-run']) return dry(`更新 ${tax} #${id} ${JSON.stringify(body)}`);
463
- const { data } = await client.request('POST', `${TAX[tax]}/${Number(id)}`, { body });
554
+ const { data } = await client.request('POST', `${taxPath(tax)}/${Number(id)}`, { body });
464
555
  if (flags.json) return printJson(data);
465
556
  console.log(`✓ 已更新 ${tax} #${data.id} ${data.name}`);
466
557
  }
467
558
 
468
559
  export async function deleteTaxonomy(client, tax, id, flags) {
469
- if (!flags.force) throw new Error(`删除 ${tax} 需要 --force(不可撤销)`);
470
560
  if (flags['dry-run']) return dry(`删除 ${tax} #${id}`);
471
- const { data } = await client.request('DELETE', `${TAX[tax]}/${Number(id)}`, { query: { force: 'true' } });
561
+ if (!flags.force) throw new UsageError(`删除 ${tax} 需要 --force(不可撤销)`);
562
+ const { data } = await client.request('DELETE', `${taxPath(tax)}/${Number(id)}`, { query: { force: 'true' } });
472
563
  if (flags.json) return printJson(data);
473
564
  console.log(data.deleted ? `✓ 已删除 ${tax} #${id}` : `? ${tax} #${id} 未删除`);
474
565
  }
475
566
 
567
+ // ------------------------------------------------------------------ menus
568
+
569
+ const MENU_PLUGIN_HINT =
570
+ '菜单接口由插件提供(WordPress 核心 REST 没有 /menus、/menu-items);请确认站点已装并启用对应插件。';
571
+
572
+ /** 菜单端点请求:路由不存在时补充"需插件"的提示,便于排障。 */
573
+ async function menuRequest(client, method, path, opts) {
574
+ try {
575
+ return await client.request(method, path, opts);
576
+ } catch (err) {
577
+ if (err?.code === 'rest_no_route') {
578
+ throw new WpError(`${err.message} —— ${MENU_PLUGIN_HINT}`, {
579
+ status: err.status,
580
+ code: err.code,
581
+ data: err.data,
582
+ method: err.method,
583
+ path: err.path,
584
+ });
585
+ }
586
+ throw err;
587
+ }
588
+ }
589
+
590
+ export async function listMenus(client, flags) {
591
+ const { data, headers } = await menuRequest(client, 'GET', MENUS, {
592
+ query: { per_page: flags['per-page'] ?? 100, page: flags.page },
593
+ });
594
+ if (flags.json) return printJson(data);
595
+ console.log(`菜单共 ${headers.get('x-wp-total') ?? data.length} 个:`);
596
+ for (const m of data) {
597
+ console.log(` #${m.id} ${m.name} slug=${m.slug} locations=${(m.locations || []).join(',') || '—'}`);
598
+ }
599
+ }
600
+
601
+ export async function getMenu(client, id, flags) {
602
+ const { data } = await menuRequest(client, 'GET', `${MENUS}/${Number(id)}`);
603
+ if (flags.json) return printJson(data);
604
+ console.log(`#${data.id} ${data.name} slug=${data.slug}`);
605
+ console.log(` 位置:${(data.locations || []).join(', ') || '—'}`);
606
+ console.log(` 自动添加:${data.auto_add ? '是' : '否'}`);
607
+ if (data.description) console.log(` 描述:${data.description}`);
608
+ }
609
+
610
+ /** 菜单字段转请求体(locations/auto_add 用于给菜单绑定主题位置)。 */
611
+ function menuBody(flags) {
612
+ const body = {};
613
+ if (flags.name !== undefined) body.name = String(flags.name);
614
+ if (flags.slug !== undefined) body.slug = String(flags.slug);
615
+ if (flags.description !== undefined) body.description = String(flags.description);
616
+ if (flags.locations !== undefined) {
617
+ body.locations = String(flags.locations).split(',').map((s) => s.trim()).filter(Boolean);
618
+ }
619
+ if (flags['auto-add'] !== undefined) body.auto_add = Boolean(flags['auto-add']);
620
+ return body;
621
+ }
622
+
623
+ export async function createMenu(client, flags) {
624
+ const body = menuBody(flags);
625
+ if (body.name === undefined) throw new UsageError('创建菜单需要 --name');
626
+ if (flags['dry-run']) return dry(`创建菜单 ${JSON.stringify(body)}`);
627
+ const { data } = await menuRequest(client, 'POST', MENUS, { body });
628
+ if (flags.json) return printJson(data);
629
+ console.log(`✓ 已创建菜单 #${data.id} ${data.name}`);
630
+ }
631
+
632
+ export async function updateMenu(client, id, flags) {
633
+ const body = menuBody(flags);
634
+ if (!Object.keys(body).length) {
635
+ throw new UsageError('没有可更新的字段(--name/--slug/--description/--locations/--auto-add)');
636
+ }
637
+ if (body.locations !== undefined) {
638
+ warn('正在修改菜单位置绑定,会立即影响站点导航', flags);
639
+ }
640
+ if (flags['dry-run']) return dry(`更新菜单 #${id} ${JSON.stringify(body)}`);
641
+ const { data } = await menuRequest(client, 'POST', `${MENUS}/${Number(id)}`, { body });
642
+ if (flags.json) return printJson(data);
643
+ console.log(`✓ 已更新菜单 #${data.id} ${data.name} 位置:${(data.locations || []).join(',') || '—'}`);
644
+ }
645
+
646
+ export async function deleteMenu(client, id, flags) {
647
+ warn('删除菜单会影响站点导航', flags);
648
+ if (flags['dry-run']) return dry(`删除菜单 #${id}`);
649
+ if (!flags.force) throw new UsageError('删除菜单需要 --force(不可撤销)');
650
+ const { data } = await menuRequest(client, 'DELETE', `${MENUS}/${Number(id)}`, { query: { force: 'true' } });
651
+ if (flags.json) return printJson(data);
652
+ console.log(data.deleted ? `✓ 已删除菜单 #${id}` : `? 菜单 #${id} 未删除`);
653
+ }
654
+
655
+ /** 把 flags 中的菜单项字段转成 REST 请求体(注意数值/数组类型)。 */
656
+ function menuItemBody(flags) {
657
+ const body = {};
658
+ if (flags.title !== undefined) body.title = String(flags.title);
659
+ if (flags.url !== undefined) body.url = String(flags.url);
660
+ if (flags.menus !== undefined) body.menus = Number(flags.menus);
661
+ if (flags['menu-order'] !== undefined) body.menu_order = Number(flags['menu-order']);
662
+ if (flags.parent !== undefined) body.parent = Number(flags.parent);
663
+ if (flags.status !== undefined) body.status = String(flags.status);
664
+ if (flags.type !== undefined) body.type = String(flags.type);
665
+ if (flags.object !== undefined) body.object = String(flags.object);
666
+ if (flags['object-id'] !== undefined) body.object_id = Number(flags['object-id']);
667
+ if (flags.target !== undefined) body.target = String(flags.target);
668
+ if (flags.classes !== undefined) {
669
+ body.classes = String(flags.classes).split(',').map((s) => s.trim()).filter(Boolean);
670
+ }
671
+ if (flags.description !== undefined) body.description = String(flags.description);
672
+ if (flags['attr-title'] !== undefined) body.attr_title = String(flags['attr-title']);
673
+ if (flags.xfn !== undefined) body.xfn = String(flags.xfn).split(',').map((s) => s.trim()).filter(Boolean);
674
+ return body;
675
+ }
676
+
677
+ export async function listMenuItems(client, flags) {
678
+ const perPage = Number(flags['per-page'] ?? 100) || 100;
679
+ const page = Number(flags.page ?? 1) || 1;
680
+ const query = {
681
+ per_page: perPage,
682
+ page,
683
+ ...pick(flags, ['menus', 'search', 'status', 'orderby', 'order', 'menu-order']),
684
+ };
685
+ const { data, total, totalPages } = await listRequest(
686
+ client,
687
+ MENU_ITEMS,
688
+ query,
689
+ flags,
690
+ (method, path, opts) => menuRequest(client, method, path, opts),
691
+ );
692
+ if (flags.json) return printJson(data);
693
+ console.log(
694
+ `菜单项共 ${total ?? '?'} 项 / ${totalPages ?? '?'} 页;第 ${page} 页${totalPages ? `/${totalPages}` : ''},本页 ${data.length} 项:`,
695
+ );
696
+ for (const item of data) {
697
+ const title = stripTags(item.title?.rendered || '(无标题)');
698
+ console.log(
699
+ ` #${item.id} [${item.status}] order=${item.menu_order} parent=${item.parent} ${item.type}/${item.object} ${title} ${item.url}`,
700
+ );
701
+ }
702
+ if (totalPages && page < Number(totalPages)) {
703
+ console.log(` …下一页:wpops menu-items list --page ${page + 1} --per-page ${perPage}`);
704
+ }
705
+ }
706
+
707
+ export async function getMenuItem(client, id, flags) {
708
+ const { data } = await menuRequest(client, 'GET', `${MENU_ITEMS}/${Number(id)}`);
709
+ if (flags.json) return printJson(data);
710
+ console.log(`#${data.id} [${data.status}] ${stripTags(data.title?.rendered || '')}`);
711
+ console.log(` 顺序:${data.menu_order} 父级:#${data.parent} 类型:${data.type}/${data.object} object_id=${data.object_id}`);
712
+ console.log(` 链接:${data.url}`);
713
+ if (data.attr_title) console.log(` attr_title:${data.attr_title}`);
714
+ if (data.target) console.log(` target:${data.target}`);
715
+ if (data.classes?.filter(Boolean).length) console.log(` classes:${data.classes.filter(Boolean).join(',')}`);
716
+ if (data.description) console.log(` 描述:${data.description}`);
717
+ }
718
+
719
+ export async function createMenuItem(client, flags) {
720
+ const body = menuItemBody(flags);
721
+ if (body.title === undefined) throw new UsageError('创建菜单项需要 --title');
722
+ if (body.url === undefined && body.object === undefined) {
723
+ throw new UsageError('创建菜单项需要 --url(自定义链接)或 --object/--object-id(指向现有内容)');
724
+ }
725
+ if (flags['dry-run']) return dry(`创建菜单项 ${JSON.stringify(body)}`);
726
+ const { data } = await menuRequest(client, 'POST', MENU_ITEMS, { body });
727
+ if (flags.json) return printJson(data);
728
+ console.log(`✓ 已创建菜单项 #${data.id} [${data.status}] ${stripTags(data.title?.rendered || '')}`);
729
+ }
730
+
731
+ export async function updateMenuItem(client, id, flags) {
732
+ const body = menuItemBody(flags);
733
+ if (!Object.keys(body).length) {
734
+ throw new UsageError('没有可更新的字段(--title/--url/--menus/--menu-order/--parent/--status/--type/--object/--object-id/--target/--classes/--description/--attr-title)');
735
+ }
736
+ if (flags['dry-run']) return dry(`更新菜单项 #${id} ${JSON.stringify(body)}`);
737
+ const { data } = await menuRequest(client, 'POST', `${MENU_ITEMS}/${Number(id)}`, { body });
738
+ if (flags.json) return printJson(data);
739
+ console.log(`✓ 已更新菜单项 #${data.id} ${stripTags(data.title?.rendered || '')}`);
740
+ }
741
+
742
+ export async function deleteMenuItem(client, id, flags) {
743
+ if (flags['dry-run']) return dry(`删除菜单项 #${id}`);
744
+ if (!flags.force) throw new UsageError('删除菜单项需要 --force(不可撤销)');
745
+ const { data } = await menuRequest(client, 'DELETE', `${MENU_ITEMS}/${Number(id)}`, { query: { force: 'true' } });
746
+ if (flags.json) return printJson(data);
747
+ console.log(data.deleted ? `✓ 已删除菜单项 #${id}` : `? 菜单项 #${id} 未删除`);
748
+ }
749
+
476
750
  // ------------------------------------------------------------------ comments
477
751
 
478
752
  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
- });
753
+ const query = {
754
+ per_page: flags['per-page'] ?? 10,
755
+ page: flags.page,
756
+ ...pick(flags, ['post', 'status', 'search', 'author', 'orderby', 'order', 'type']),
757
+ };
758
+ const { data, total } = await listRequest(client, COMMENTS, query, flags);
486
759
  if (flags.json) return printJson(data);
487
- console.log(`评论共 ${headers.get('x-wp-total') ?? '?'} 条,本页 ${data.length}:`);
760
+ console.log(`评论共 ${total ?? '?'} 条,本页 ${data.length}:`);
488
761
  for (const c of data) {
489
762
  console.log(` #${c.id} [${c.status}] post#${c.post} ${stripTags(c.author_name || '')}: ${stripTags(c.content?.rendered || '')}`);
490
763
  }
@@ -495,12 +768,29 @@ export async function getComment(client, id) {
495
768
  printJson(data);
496
769
  }
497
770
 
771
+ export async function createComment(client, flags) {
772
+ const post = flags.post ?? flags['post-id'];
773
+ if (post === undefined) throw new UsageError('创建评论需要 --post <id>');
774
+ const body = { post: Number(post) };
775
+ if (flags['from-file'] !== undefined) body.content = readFileSync(String(flags['from-file']), 'utf8');
776
+ else if (flags.content !== undefined) body.content = String(flags.content);
777
+ if (body.content === undefined) throw new UsageError('创建评论需要 --content 或 --from-file');
778
+ if (flags['author-name'] !== undefined) body.author_name = String(flags['author-name']);
779
+ if (flags['author-email'] !== undefined) body.author_email = String(flags['author-email']);
780
+ if (flags.parent !== undefined) body.parent = Number(flags.parent);
781
+ if (flags.status !== undefined) body.status = String(flags.status);
782
+ if (flags['dry-run']) return dry(`创建评论 ${JSON.stringify(body)}`);
783
+ const { data } = await client.request('POST', COMMENTS, { body });
784
+ if (flags.json) return printJson(data);
785
+ console.log(`✓ 已创建评论 #${data.id} post#${data.post} [${data.status}]`);
786
+ }
787
+
498
788
  export async function updateComment(client, id, flags) {
499
789
  const body = {};
500
790
  if (flags.status !== undefined) body.status = String(flags.status);
501
791
  if (flags.content !== undefined) body.content = String(flags.content);
502
792
  if (!Object.keys(body).length) {
503
- throw new Error('没有可更新的字段(--status approve|hold|spam|trash / --content)');
793
+ throw new UsageError('没有可更新的字段(--status approve|hold|spam|trash / --content)');
504
794
  }
505
795
  if (flags['dry-run']) return dry(`更新评论 #${id} ${JSON.stringify(body)}`);
506
796
  const { data } = await client.request('POST', `${COMMENTS}/${Number(id)}`, { body });
@@ -520,16 +810,15 @@ export async function deleteComment(client, id, flags) {
520
810
  // ------------------------------------------------------------------ users
521
811
 
522
812
  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
- });
813
+ const query = {
814
+ per_page: flags['per-page'] ?? 10,
815
+ page: flags.page,
816
+ context: 'edit',
817
+ ...pick(flags, ['search', 'roles', 'orderby', 'order', 'who']),
818
+ };
819
+ const { data, total } = await listRequest(client, USERS, query, flags);
531
820
  if (flags.json) return printJson(data);
532
- console.log(`用户共 ${headers.get('x-wp-total') ?? '?'} 个,本页 ${data.length}:`);
821
+ console.log(`用户共 ${total ?? '?'} 个,本页 ${data.length}:`);
533
822
  for (const u of data) {
534
823
  console.log(` #${u.id} ${u.name} (@${u.slug}) ${(u.roles || []).join(',')} ${u.email || ''}`);
535
824
  }
@@ -541,8 +830,8 @@ export async function getUser(client, id) {
541
830
  }
542
831
 
543
832
  export async function createUser(client, flags) {
544
- if (flags.username === undefined) throw new Error('创建用户需要 --username');
545
- if (flags.email === undefined) throw new Error('创建用户需要 --email');
833
+ if (flags.username === undefined) throw new UsageError('创建用户需要 --username');
834
+ if (flags.email === undefined) throw new UsageError('创建用户需要 --email');
546
835
  const body = { username: String(flags.username), email: String(flags.email) };
547
836
  if (flags.password !== undefined) body.password = String(flags.password);
548
837
  if (flags.name !== undefined) body.name = String(flags.name);
@@ -559,7 +848,7 @@ export async function updateUser(client, id, flags) {
559
848
  if (flags[key] !== undefined) body[key] = String(flags[key]);
560
849
  }
561
850
  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/...)');
851
+ if (!Object.keys(body).length) throw new UsageError('没有可更新的字段(--name/--email/--password/--role/...)');
563
852
  if (flags['dry-run']) return dry(`更新用户 #${id} ${JSON.stringify({ ...body, password: body.password ? '***' : undefined })}`);
564
853
  const { data } = await client.request('POST', `${USERS}/${Number(id)}`, { body });
565
854
  if (flags.json) return printJson(data);
@@ -567,8 +856,8 @@ export async function updateUser(client, id, flags) {
567
856
  }
568
857
 
569
858
  export async function deleteUser(client, id, flags) {
570
- if (!flags.force) throw new Error('删除用户需要 --force(不可撤销)');
571
859
  if (flags['dry-run']) return dry(`删除用户 #${id}${flags.reassign ? `(内容转交 #${flags.reassign})` : ''}`);
860
+ if (!flags.force) throw new UsageError('删除用户需要 --force(不可撤销)');
572
861
  const { data } = await client.request('DELETE', `${USERS}/${Number(id)}`, {
573
862
  query: { force: 'true', reassign: flags.reassign },
574
863
  });
@@ -581,8 +870,11 @@ export async function deleteUser(client, id, flags) {
581
870
  export async function raw(client, method, path, flags) {
582
871
  const upper = String(method).toUpperCase();
583
872
  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'));
873
+ if (flags.data !== undefined) body = parseJson(flags.data);
874
+ else if (flags['data-file'] !== undefined) body = readJsonFile(flags['data-file']);
875
+ if (body !== undefined && ['GET', 'HEAD'].includes(upper)) {
876
+ throw new UsageError(`${upper} 请求不能带 --data(--data 仅用于写操作)`, { group: 'raw', action: upper });
877
+ }
586
878
 
587
879
  if (flags['dry-run'] && !['GET', 'HEAD', 'OPTIONS'].includes(upper)) {
588
880
  return dry(`${upper} ${path}${body !== undefined ? ` ${JSON.stringify(body)}` : ''}`);
@@ -643,7 +935,7 @@ function promptHidden(query) {
643
935
  export async function setup(flags) {
644
936
  const tty = Boolean(process.stdin.isTTY);
645
937
  const need = (what) => {
646
- if (!tty) throw new Error(`非交互模式请提供 ${what}`);
938
+ if (!tty) throw new UsageError(`非交互模式请提供 ${what}`);
647
939
  };
648
940
 
649
941
  let url = flags.url;
@@ -652,7 +944,7 @@ export async function setup(flags) {
652
944
  url = await promptLine('站点 URL(https://...):');
653
945
  }
654
946
  url = String(url).replace(/\/+$/, '');
655
- if (!/^https:\/\//i.test(url)) throw new Error('URL 必须以 https:// 开头');
947
+ if (!/^https:\/\//i.test(url)) throw new UsageError('URL 必须以 https:// 开头');
656
948
 
657
949
  let user = flags.user;
658
950
  if (!user) {
@@ -665,7 +957,7 @@ export async function setup(flags) {
665
957
  need('--password');
666
958
  password = await promptHidden('应用密码(输入不回显):');
667
959
  }
668
- if (!password) throw new Error('应用密码为空');
960
+ if (!password) throw new UsageError('应用密码为空');
669
961
 
670
962
  const defaultName = (() => {
671
963
  try {
@@ -685,7 +977,7 @@ export async function setup(flags) {
685
977
  mkdirSync(dir, { recursive: true });
686
978
  const file = join(dir, `${name}.env`);
687
979
  if (existsSync(file) && !flags.force) {
688
- throw new Error(`已存在 ${file};要覆盖请加 --force`);
980
+ throw new UsageError(`已存在 ${file};要覆盖请加 --force`);
689
981
  }
690
982
 
691
983
  const content = [