wpops 0.1.0 → 0.2.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, 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,21 +16,20 @@ 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
34
  function printItem(data, flags) {
34
35
  if (flags.json) return printJson(data);
@@ -36,17 +37,6 @@ function printItem(data, flags) {
36
37
  if (data.link) console.log(` ${data.link}`);
37
38
  }
38
39
 
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
40
  // ------------------------------------------------------------------ doctor
51
41
 
52
42
  export async function doctor(client, cfg, flags) {
@@ -216,10 +206,8 @@ export async function listContent(client, type, flags) {
216
206
  context: flags.context,
217
207
  ...pick(flags, CONTENT_FILTERS[type] || []),
218
208
  };
219
- const { data, headers } = await client.request('GET', CONTENT[type], { query });
209
+ const { data, total, totalPages } = await listRequest(client, contentPath(type), query, flags);
220
210
  if (flags.json) return printJson(data);
221
- const total = headers.get('x-wp-total');
222
- const totalPages = headers.get('x-wp-totalpages');
223
211
  console.log(
224
212
  `${type} 共 ${total ?? '?'} 条 / ${totalPages ?? '?'} 页;第 ${page} 页${totalPages ? `/${totalPages}` : ''},本页 ${data.length} 条:`,
225
213
  );
@@ -232,7 +220,7 @@ export async function listContent(client, type, flags) {
232
220
  }
233
221
 
234
222
  export async function getContent(client, type, id, flags) {
235
- const { data } = await client.request('GET', `${CONTENT[type]}/${Number(id)}`, {
223
+ const { data } = await client.request('GET', `${contentPath(type)}/${Number(id)}`, {
236
224
  query: { context: flags.context || 'edit' },
237
225
  });
238
226
  if (flags.json) return printJson(data);
@@ -265,24 +253,24 @@ function contentBody(flags) {
265
253
  export async function createContent(client, type, flags) {
266
254
  const body = contentBody(flags);
267
255
  if (body.title === undefined && body.content === undefined) {
268
- throw new Error('至少需要 --title 或 --content/--from-file');
256
+ throw new UsageError('至少需要 --title 或 --content/--from-file');
269
257
  }
270
258
  if (flags['dry-run']) return dry(`创建 ${type} ${JSON.stringify(body)}`);
271
- const { data } = await client.request('POST', CONTENT[type], { body });
259
+ const { data } = await client.request('POST', contentPath(type), { body });
272
260
  printItem(data, flags);
273
261
  }
274
262
 
275
263
  export async function updateContent(client, type, id, flags) {
276
264
  const body = contentBody(flags);
277
- if (!Object.keys(body).length) throw new Error('没有可更新的字段');
265
+ if (!Object.keys(body).length) throw new UsageError('没有可更新的字段');
278
266
  if (flags['dry-run']) return dry(`更新 ${type} #${id} ${JSON.stringify(body)}`);
279
- const { data } = await client.request('POST', `${CONTENT[type]}/${Number(id)}`, { body });
267
+ const { data } = await client.request('POST', `${contentPath(type)}/${Number(id)}`, { body });
280
268
  printItem(data, flags);
281
269
  }
282
270
 
283
271
  export async function deleteContent(client, type, id, flags) {
284
272
  if (flags['dry-run']) return dry(`删除 ${type} #${id}${flags.force ? '(彻底)' : '(回收站)'}`);
285
- const { data } = await client.request('DELETE', `${CONTENT[type]}/${Number(id)}`, {
273
+ const { data } = await client.request('DELETE', `${contentPath(type)}/${Number(id)}`, {
286
274
  query: { force: flags.force ? 'true' : undefined },
287
275
  });
288
276
  if (flags.json) return printJson(data);
@@ -299,10 +287,8 @@ export async function listMedia(client, flags) {
299
287
  page,
300
288
  ...pick(flags, ['media_type', 'mime_type', 'parent', 'author', 'after', 'before', 'orderby', 'order', 'offset', 'include', 'exclude']),
301
289
  };
302
- const { data, headers } = await client.request('GET', MEDIA, { query });
290
+ const { data, total, totalPages } = await listRequest(client, MEDIA, query, flags);
303
291
  if (flags.json) return printJson(data);
304
- const total = headers.get('x-wp-total');
305
- const totalPages = headers.get('x-wp-totalpages');
306
292
  console.log(
307
293
  `媒体共 ${total ?? '?'} 项 / ${totalPages ?? '?'} 页;第 ${page} 页${totalPages ? `/${totalPages}` : ''},本页 ${data.length} 项:`,
308
294
  );
@@ -344,7 +330,7 @@ export async function updateMedia(client, id, flags) {
344
330
  if (flags.description !== undefined) body.description = String(flags.description);
345
331
  if (flags.post !== undefined) body.post = Number(flags.post);
346
332
  if (!Object.keys(body).length) {
347
- throw new Error('没有可更新的字段(--title/--alt/--caption/--description/--post)');
333
+ throw new UsageError('没有可更新的字段(--title/--alt/--caption/--description/--post)');
348
334
  }
349
335
  if (flags['dry-run']) return dry(`更新媒体 #${id} ${JSON.stringify(body)}`);
350
336
  const { data } = await client.request('POST', `${MEDIA}/${Number(id)}`, { body });
@@ -352,6 +338,56 @@ export async function updateMedia(client, id, flags) {
352
338
  console.log(`✓ 已更新媒体 #${data.id}`);
353
339
  }
354
340
 
341
+ /** 图像编辑(裁剪/旋转),POST /media/<id>/edit。缺 --src 时自动取当前 source_url。 */
342
+ export async function editMediaImage(client, id, flags) {
343
+ const body = {};
344
+ if (flags.src !== undefined) body.src = String(flags.src);
345
+ if (flags.rotation !== undefined) body.rotation = Number(flags.rotation);
346
+ if (flags['dest-width'] !== undefined) body.width = Number(flags['dest-width']);
347
+ if (flags['dest-height'] !== undefined) body.height = Number(flags['dest-height']);
348
+ if (flags.x !== undefined) body.x = Number(flags.x);
349
+ if (flags.y !== undefined) body.y = Number(flags.y);
350
+ const modifiers = [];
351
+ if (flags.crop) modifiers.push('crop');
352
+ if (flags.modifiers !== undefined) {
353
+ modifiers.push(...String(flags.modifiers).split(',').map((s) => s.trim()).filter(Boolean));
354
+ }
355
+ if (modifiers.length) body.modifiers = modifiers;
356
+ if (flags.title !== undefined) body.title = String(flags.title);
357
+ if (flags.alt !== undefined) body.alt_text = String(flags.alt);
358
+ if (flags.caption !== undefined) body.caption = String(flags.caption);
359
+ if (flags.description !== undefined) body.description = String(flags.description);
360
+ if (flags.post !== undefined) body.post = Number(flags.post);
361
+ if (!Object.keys(body).length) {
362
+ throw new UsageError('图像编辑需要至少一个参数(--rotation/--crop/--x/--y/--dest-width/--dest-height/--modifiers/--src/...)');
363
+ }
364
+ if (flags['dry-run']) return dry(`编辑媒体 #${id} ${JSON.stringify(body)}`);
365
+ if (body.src === undefined) {
366
+ const { data } = await client.request('GET', `${MEDIA}/${Number(id)}`, { query: { context: 'edit' } });
367
+ body.src = data.source_url;
368
+ }
369
+ if (!body.src) throw new UsageError('无法确定图片来源,请显式传 --src <url>');
370
+ const { data } = await client.request('POST', `${MEDIA}/${Number(id)}/edit`, { body });
371
+ if (flags.json) return printJson(data);
372
+ console.log(`✓ 已编辑媒体 #${data.id} ${data.source_url || ''}`);
373
+ }
374
+
375
+ /** 从 URL 导入媒体:sideload,POST /media(带 url)。 */
376
+ export async function sideloadMedia(client, flags) {
377
+ if (flags.url === undefined) throw new UsageError('media sideload 需要 --url <图片地址>');
378
+ const body = { url: String(flags.url) };
379
+ if (flags.title !== undefined) body.title = String(flags.title);
380
+ if (flags.alt !== undefined) body.alt_text = String(flags.alt);
381
+ if (flags.caption !== undefined) body.caption = String(flags.caption);
382
+ if (flags.description !== undefined) body.description = String(flags.description);
383
+ if (flags.slug !== undefined) body.slug = String(flags.slug);
384
+ if (flags.post !== undefined) body.post = Number(flags.post);
385
+ if (flags['dry-run']) return dry(`从 URL 导入媒体 ${body.url}`);
386
+ const { data } = await client.request('POST', MEDIA, { body });
387
+ if (flags.json) return printJson(data);
388
+ console.log(`✓ 已导入媒体 #${data.id} ${data.source_url || ''}`);
389
+ }
390
+
355
391
  export async function deleteMedia(client, id, flags) {
356
392
  if (flags['dry-run']) return dry(`删除媒体 #${id}${flags.force ? '(彻底)' : '(回收站)'}`);
357
393
  const { data } = await client.request('DELETE', `${MEDIA}/${Number(id)}`, {
@@ -392,6 +428,14 @@ export async function setPluginStatus(client, plugin, activate, flags) {
392
428
  console.log(`✓ ${data.name} → ${data.status === 'active' ? '已启用' : '已停用'}`);
393
429
  }
394
430
 
431
+ export async function deletePlugin(client, plugin, flags) {
432
+ if (flags['dry-run']) return dry(`删除插件 ${plugin}`);
433
+ if (!flags.force) throw new UsageError('删除插件需要 --force(不可撤销)');
434
+ const { data } = await client.request('DELETE', `/wp-json/wp/v2/plugins/${encodeURIComponent(plugin)}`);
435
+ if (flags.json) return printJson(data);
436
+ console.log(data.deleted ? `✓ 已删除插件 ${plugin}` : `? 插件 ${plugin} 未删除`);
437
+ }
438
+
395
439
  // ------------------------------------------------------------------ themes
396
440
 
397
441
  export async function listThemes(client, flags) {
@@ -415,26 +459,25 @@ export async function activateTheme(client, stylesheet, flags) {
415
459
  // ------------------------------------------------------------------ taxonomy
416
460
 
417
461
  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
- });
462
+ const query = {
463
+ per_page: flags['per-page'] ?? 100,
464
+ page: flags.page,
465
+ search: flags.search,
466
+ orderby: flags.orderby,
467
+ order: flags.order,
468
+ parent: flags.parent,
469
+ hide_empty: flags['hide-empty'] ? 'true' : 'false',
470
+ };
471
+ const { data, total } = await listRequest(client, taxPath(tax), query, flags);
429
472
  if (flags.json) return printJson(data);
430
- console.log(`${tax} 共 ${headers.get('x-wp-total') ?? '?'} 个:`);
473
+ console.log(`${tax} 共 ${total ?? '?'} 个:`);
431
474
  for (const t of data) {
432
475
  console.log(` #${t.id} ${t.name} (${t.count}) slug=${t.slug}${t.parent ? ` parent=#${t.parent}` : ''}`);
433
476
  }
434
477
  }
435
478
 
436
479
  export async function getTaxonomy(client, tax, id) {
437
- const { data } = await client.request('GET', `${TAX[tax]}/${Number(id)}`);
480
+ const { data } = await client.request('GET', `${taxPath(tax)}/${Number(id)}`);
438
481
  printJson(data);
439
482
  }
440
483
 
@@ -449,42 +492,220 @@ function taxonomyBody(flags) {
449
492
 
450
493
  export async function createTaxonomy(client, tax, flags) {
451
494
  const body = taxonomyBody(flags);
452
- if (body.name === undefined) throw new Error(`创建 ${tax} 需要 --name`);
495
+ if (body.name === undefined) throw new UsageError(`创建 ${tax} 需要 --name`);
453
496
  if (flags['dry-run']) return dry(`创建 ${tax} ${JSON.stringify(body)}`);
454
- const { data } = await client.request('POST', TAX[tax], { body });
497
+ const { data } = await client.request('POST', taxPath(tax), { body });
455
498
  if (flags.json) return printJson(data);
456
499
  console.log(`✓ 已创建 ${tax} #${data.id} ${data.name}`);
457
500
  }
458
501
 
459
502
  export async function updateTaxonomy(client, tax, id, flags) {
460
503
  const body = taxonomyBody(flags);
461
- if (!Object.keys(body).length) throw new Error('没有可更新的字段(--name/--slug/--parent/--description)');
504
+ if (!Object.keys(body).length) throw new UsageError('没有可更新的字段(--name/--slug/--parent/--description)');
462
505
  if (flags['dry-run']) return dry(`更新 ${tax} #${id} ${JSON.stringify(body)}`);
463
- const { data } = await client.request('POST', `${TAX[tax]}/${Number(id)}`, { body });
506
+ const { data } = await client.request('POST', `${taxPath(tax)}/${Number(id)}`, { body });
464
507
  if (flags.json) return printJson(data);
465
508
  console.log(`✓ 已更新 ${tax} #${data.id} ${data.name}`);
466
509
  }
467
510
 
468
511
  export async function deleteTaxonomy(client, tax, id, flags) {
469
- if (!flags.force) throw new Error(`删除 ${tax} 需要 --force(不可撤销)`);
470
512
  if (flags['dry-run']) return dry(`删除 ${tax} #${id}`);
471
- const { data } = await client.request('DELETE', `${TAX[tax]}/${Number(id)}`, { query: { force: 'true' } });
513
+ if (!flags.force) throw new UsageError(`删除 ${tax} 需要 --force(不可撤销)`);
514
+ const { data } = await client.request('DELETE', `${taxPath(tax)}/${Number(id)}`, { query: { force: 'true' } });
472
515
  if (flags.json) return printJson(data);
473
516
  console.log(data.deleted ? `✓ 已删除 ${tax} #${id}` : `? ${tax} #${id} 未删除`);
474
517
  }
475
518
 
519
+ // ------------------------------------------------------------------ menus
520
+
521
+ const MENU_PLUGIN_HINT =
522
+ '菜单接口由插件提供(WordPress 核心 REST 没有 /menus、/menu-items);请确认站点已装并启用对应插件。';
523
+
524
+ /** 菜单端点请求:路由不存在时补充"需插件"的提示,便于排障。 */
525
+ async function menuRequest(client, method, path, opts) {
526
+ try {
527
+ return await client.request(method, path, opts);
528
+ } catch (err) {
529
+ if (err?.code === 'rest_no_route') {
530
+ throw new WpError(`${err.message} —— ${MENU_PLUGIN_HINT}`, {
531
+ status: err.status,
532
+ code: err.code,
533
+ data: err.data,
534
+ method: err.method,
535
+ path: err.path,
536
+ });
537
+ }
538
+ throw err;
539
+ }
540
+ }
541
+
542
+ export async function listMenus(client, flags) {
543
+ const { data, headers } = await menuRequest(client, 'GET', MENUS, {
544
+ query: { per_page: flags['per-page'] ?? 100, page: flags.page },
545
+ });
546
+ if (flags.json) return printJson(data);
547
+ console.log(`菜单共 ${headers.get('x-wp-total') ?? data.length} 个:`);
548
+ for (const m of data) {
549
+ console.log(` #${m.id} ${m.name} slug=${m.slug} locations=${(m.locations || []).join(',') || '—'}`);
550
+ }
551
+ }
552
+
553
+ export async function getMenu(client, id, flags) {
554
+ const { data } = await menuRequest(client, 'GET', `${MENUS}/${Number(id)}`);
555
+ if (flags.json) return printJson(data);
556
+ console.log(`#${data.id} ${data.name} slug=${data.slug}`);
557
+ console.log(` 位置:${(data.locations || []).join(', ') || '—'}`);
558
+ console.log(` 自动添加:${data.auto_add ? '是' : '否'}`);
559
+ if (data.description) console.log(` 描述:${data.description}`);
560
+ }
561
+
562
+ /** 菜单字段转请求体(locations/auto_add 用于给菜单绑定主题位置)。 */
563
+ function menuBody(flags) {
564
+ const body = {};
565
+ if (flags.name !== undefined) body.name = String(flags.name);
566
+ if (flags.slug !== undefined) body.slug = String(flags.slug);
567
+ if (flags.description !== undefined) body.description = String(flags.description);
568
+ if (flags.locations !== undefined) {
569
+ body.locations = String(flags.locations).split(',').map((s) => s.trim()).filter(Boolean);
570
+ }
571
+ if (flags['auto-add'] !== undefined) body.auto_add = Boolean(flags['auto-add']);
572
+ return body;
573
+ }
574
+
575
+ export async function createMenu(client, flags) {
576
+ const body = menuBody(flags);
577
+ if (body.name === undefined) throw new UsageError('创建菜单需要 --name');
578
+ if (flags['dry-run']) return dry(`创建菜单 ${JSON.stringify(body)}`);
579
+ const { data } = await menuRequest(client, 'POST', MENUS, { body });
580
+ if (flags.json) return printJson(data);
581
+ console.log(`✓ 已创建菜单 #${data.id} ${data.name}`);
582
+ }
583
+
584
+ export async function updateMenu(client, id, flags) {
585
+ const body = menuBody(flags);
586
+ if (!Object.keys(body).length) {
587
+ throw new UsageError('没有可更新的字段(--name/--slug/--description/--locations/--auto-add)');
588
+ }
589
+ if (flags['dry-run']) return dry(`更新菜单 #${id} ${JSON.stringify(body)}`);
590
+ const { data } = await menuRequest(client, 'POST', `${MENUS}/${Number(id)}`, { body });
591
+ if (flags.json) return printJson(data);
592
+ console.log(`✓ 已更新菜单 #${data.id} ${data.name} 位置:${(data.locations || []).join(',') || '—'}`);
593
+ }
594
+
595
+ export async function deleteMenu(client, id, flags) {
596
+ if (flags['dry-run']) return dry(`删除菜单 #${id}`);
597
+ if (!flags.force) throw new UsageError('删除菜单需要 --force(不可撤销)');
598
+ const { data } = await menuRequest(client, 'DELETE', `${MENUS}/${Number(id)}`, { query: { force: 'true' } });
599
+ if (flags.json) return printJson(data);
600
+ console.log(data.deleted ? `✓ 已删除菜单 #${id}` : `? 菜单 #${id} 未删除`);
601
+ }
602
+
603
+ /** 把 flags 中的菜单项字段转成 REST 请求体(注意数值/数组类型)。 */
604
+ function menuItemBody(flags) {
605
+ const body = {};
606
+ if (flags.title !== undefined) body.title = String(flags.title);
607
+ if (flags.url !== undefined) body.url = String(flags.url);
608
+ if (flags.menus !== undefined) body.menus = Number(flags.menus);
609
+ if (flags['menu-order'] !== undefined) body.menu_order = Number(flags['menu-order']);
610
+ if (flags.parent !== undefined) body.parent = Number(flags.parent);
611
+ if (flags.status !== undefined) body.status = String(flags.status);
612
+ if (flags.type !== undefined) body.type = String(flags.type);
613
+ if (flags.object !== undefined) body.object = String(flags.object);
614
+ if (flags['object-id'] !== undefined) body.object_id = Number(flags['object-id']);
615
+ if (flags.target !== undefined) body.target = String(flags.target);
616
+ if (flags.classes !== undefined) {
617
+ body.classes = String(flags.classes).split(',').map((s) => s.trim()).filter(Boolean);
618
+ }
619
+ if (flags.description !== undefined) body.description = String(flags.description);
620
+ if (flags['attr-title'] !== undefined) body.attr_title = String(flags['attr-title']);
621
+ if (flags.xfn !== undefined) body.xfn = String(flags.xfn).split(',').map((s) => s.trim()).filter(Boolean);
622
+ return body;
623
+ }
624
+
625
+ export async function listMenuItems(client, flags) {
626
+ const perPage = Number(flags['per-page'] ?? 100) || 100;
627
+ const page = Number(flags.page ?? 1) || 1;
628
+ const query = {
629
+ per_page: perPage,
630
+ page,
631
+ ...pick(flags, ['menus', 'search', 'status', 'orderby', 'order', 'menu-order']),
632
+ };
633
+ const { data, total, totalPages } = await listRequest(
634
+ client,
635
+ MENU_ITEMS,
636
+ query,
637
+ flags,
638
+ (method, path, opts) => menuRequest(client, method, path, opts),
639
+ );
640
+ if (flags.json) return printJson(data);
641
+ console.log(
642
+ `菜单项共 ${total ?? '?'} 项 / ${totalPages ?? '?'} 页;第 ${page} 页${totalPages ? `/${totalPages}` : ''},本页 ${data.length} 项:`,
643
+ );
644
+ for (const item of data) {
645
+ const title = stripTags(item.title?.rendered || '(无标题)');
646
+ console.log(
647
+ ` #${item.id} [${item.status}] order=${item.menu_order} parent=${item.parent} ${item.type}/${item.object} ${title} ${item.url}`,
648
+ );
649
+ }
650
+ if (totalPages && page < Number(totalPages)) {
651
+ console.log(` …下一页:wpops menu-items list --page ${page + 1} --per-page ${perPage}`);
652
+ }
653
+ }
654
+
655
+ export async function getMenuItem(client, id, flags) {
656
+ const { data } = await menuRequest(client, 'GET', `${MENU_ITEMS}/${Number(id)}`);
657
+ if (flags.json) return printJson(data);
658
+ console.log(`#${data.id} [${data.status}] ${stripTags(data.title?.rendered || '')}`);
659
+ console.log(` 顺序:${data.menu_order} 父级:#${data.parent} 类型:${data.type}/${data.object} object_id=${data.object_id}`);
660
+ console.log(` 链接:${data.url}`);
661
+ if (data.attr_title) console.log(` attr_title:${data.attr_title}`);
662
+ if (data.target) console.log(` target:${data.target}`);
663
+ if (data.classes?.filter(Boolean).length) console.log(` classes:${data.classes.filter(Boolean).join(',')}`);
664
+ if (data.description) console.log(` 描述:${data.description}`);
665
+ }
666
+
667
+ export async function createMenuItem(client, flags) {
668
+ const body = menuItemBody(flags);
669
+ if (body.title === undefined) throw new UsageError('创建菜单项需要 --title');
670
+ if (body.url === undefined && body.object === undefined) {
671
+ throw new UsageError('创建菜单项需要 --url(自定义链接)或 --object/--object-id(指向现有内容)');
672
+ }
673
+ if (flags['dry-run']) return dry(`创建菜单项 ${JSON.stringify(body)}`);
674
+ const { data } = await menuRequest(client, 'POST', MENU_ITEMS, { body });
675
+ if (flags.json) return printJson(data);
676
+ console.log(`✓ 已创建菜单项 #${data.id} [${data.status}] ${stripTags(data.title?.rendered || '')}`);
677
+ }
678
+
679
+ export async function updateMenuItem(client, id, flags) {
680
+ const body = menuItemBody(flags);
681
+ if (!Object.keys(body).length) {
682
+ throw new UsageError('没有可更新的字段(--title/--url/--menus/--menu-order/--parent/--status/--type/--object/--object-id/--target/--classes/--description/--attr-title)');
683
+ }
684
+ if (flags['dry-run']) return dry(`更新菜单项 #${id} ${JSON.stringify(body)}`);
685
+ const { data } = await menuRequest(client, 'POST', `${MENU_ITEMS}/${Number(id)}`, { body });
686
+ if (flags.json) return printJson(data);
687
+ console.log(`✓ 已更新菜单项 #${data.id} ${stripTags(data.title?.rendered || '')}`);
688
+ }
689
+
690
+ export async function deleteMenuItem(client, id, flags) {
691
+ if (flags['dry-run']) return dry(`删除菜单项 #${id}`);
692
+ if (!flags.force) throw new UsageError('删除菜单项需要 --force(不可撤销)');
693
+ const { data } = await menuRequest(client, 'DELETE', `${MENU_ITEMS}/${Number(id)}`, { query: { force: 'true' } });
694
+ if (flags.json) return printJson(data);
695
+ console.log(data.deleted ? `✓ 已删除菜单项 #${id}` : `? 菜单项 #${id} 未删除`);
696
+ }
697
+
476
698
  // ------------------------------------------------------------------ comments
477
699
 
478
700
  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
- });
701
+ const query = {
702
+ per_page: flags['per-page'] ?? 10,
703
+ page: flags.page,
704
+ ...pick(flags, ['post', 'status', 'search', 'author', 'orderby', 'order', 'type']),
705
+ };
706
+ const { data, total } = await listRequest(client, COMMENTS, query, flags);
486
707
  if (flags.json) return printJson(data);
487
- console.log(`评论共 ${headers.get('x-wp-total') ?? '?'} 条,本页 ${data.length}:`);
708
+ console.log(`评论共 ${total ?? '?'} 条,本页 ${data.length}:`);
488
709
  for (const c of data) {
489
710
  console.log(` #${c.id} [${c.status}] post#${c.post} ${stripTags(c.author_name || '')}: ${stripTags(c.content?.rendered || '')}`);
490
711
  }
@@ -495,12 +716,29 @@ export async function getComment(client, id) {
495
716
  printJson(data);
496
717
  }
497
718
 
719
+ export async function createComment(client, flags) {
720
+ const post = flags.post ?? flags['post-id'];
721
+ if (post === undefined) throw new UsageError('创建评论需要 --post <id>');
722
+ const body = { post: Number(post) };
723
+ if (flags['from-file'] !== undefined) body.content = readFileSync(String(flags['from-file']), 'utf8');
724
+ else if (flags.content !== undefined) body.content = String(flags.content);
725
+ if (body.content === undefined) throw new UsageError('创建评论需要 --content 或 --from-file');
726
+ if (flags['author-name'] !== undefined) body.author_name = String(flags['author-name']);
727
+ if (flags['author-email'] !== undefined) body.author_email = String(flags['author-email']);
728
+ if (flags.parent !== undefined) body.parent = Number(flags.parent);
729
+ if (flags.status !== undefined) body.status = String(flags.status);
730
+ if (flags['dry-run']) return dry(`创建评论 ${JSON.stringify(body)}`);
731
+ const { data } = await client.request('POST', COMMENTS, { body });
732
+ if (flags.json) return printJson(data);
733
+ console.log(`✓ 已创建评论 #${data.id} post#${data.post} [${data.status}]`);
734
+ }
735
+
498
736
  export async function updateComment(client, id, flags) {
499
737
  const body = {};
500
738
  if (flags.status !== undefined) body.status = String(flags.status);
501
739
  if (flags.content !== undefined) body.content = String(flags.content);
502
740
  if (!Object.keys(body).length) {
503
- throw new Error('没有可更新的字段(--status approve|hold|spam|trash / --content)');
741
+ throw new UsageError('没有可更新的字段(--status approve|hold|spam|trash / --content)');
504
742
  }
505
743
  if (flags['dry-run']) return dry(`更新评论 #${id} ${JSON.stringify(body)}`);
506
744
  const { data } = await client.request('POST', `${COMMENTS}/${Number(id)}`, { body });
@@ -520,16 +758,15 @@ export async function deleteComment(client, id, flags) {
520
758
  // ------------------------------------------------------------------ users
521
759
 
522
760
  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
- });
761
+ const query = {
762
+ per_page: flags['per-page'] ?? 10,
763
+ page: flags.page,
764
+ context: 'edit',
765
+ ...pick(flags, ['search', 'roles', 'orderby', 'order', 'who']),
766
+ };
767
+ const { data, total } = await listRequest(client, USERS, query, flags);
531
768
  if (flags.json) return printJson(data);
532
- console.log(`用户共 ${headers.get('x-wp-total') ?? '?'} 个,本页 ${data.length}:`);
769
+ console.log(`用户共 ${total ?? '?'} 个,本页 ${data.length}:`);
533
770
  for (const u of data) {
534
771
  console.log(` #${u.id} ${u.name} (@${u.slug}) ${(u.roles || []).join(',')} ${u.email || ''}`);
535
772
  }
@@ -541,8 +778,8 @@ export async function getUser(client, id) {
541
778
  }
542
779
 
543
780
  export async function createUser(client, flags) {
544
- if (flags.username === undefined) throw new Error('创建用户需要 --username');
545
- if (flags.email === undefined) throw new Error('创建用户需要 --email');
781
+ if (flags.username === undefined) throw new UsageError('创建用户需要 --username');
782
+ if (flags.email === undefined) throw new UsageError('创建用户需要 --email');
546
783
  const body = { username: String(flags.username), email: String(flags.email) };
547
784
  if (flags.password !== undefined) body.password = String(flags.password);
548
785
  if (flags.name !== undefined) body.name = String(flags.name);
@@ -559,7 +796,7 @@ export async function updateUser(client, id, flags) {
559
796
  if (flags[key] !== undefined) body[key] = String(flags[key]);
560
797
  }
561
798
  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/...)');
799
+ if (!Object.keys(body).length) throw new UsageError('没有可更新的字段(--name/--email/--password/--role/...)');
563
800
  if (flags['dry-run']) return dry(`更新用户 #${id} ${JSON.stringify({ ...body, password: body.password ? '***' : undefined })}`);
564
801
  const { data } = await client.request('POST', `${USERS}/${Number(id)}`, { body });
565
802
  if (flags.json) return printJson(data);
@@ -567,8 +804,8 @@ export async function updateUser(client, id, flags) {
567
804
  }
568
805
 
569
806
  export async function deleteUser(client, id, flags) {
570
- if (!flags.force) throw new Error('删除用户需要 --force(不可撤销)');
571
807
  if (flags['dry-run']) return dry(`删除用户 #${id}${flags.reassign ? `(内容转交 #${flags.reassign})` : ''}`);
808
+ if (!flags.force) throw new UsageError('删除用户需要 --force(不可撤销)');
572
809
  const { data } = await client.request('DELETE', `${USERS}/${Number(id)}`, {
573
810
  query: { force: 'true', reassign: flags.reassign },
574
811
  });
@@ -581,8 +818,11 @@ export async function deleteUser(client, id, flags) {
581
818
  export async function raw(client, method, path, flags) {
582
819
  const upper = String(method).toUpperCase();
583
820
  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'));
821
+ if (flags.data !== undefined) body = parseJson(flags.data);
822
+ else if (flags['data-file'] !== undefined) body = readJsonFile(flags['data-file']);
823
+ if (body !== undefined && ['GET', 'HEAD'].includes(upper)) {
824
+ throw new UsageError(`${upper} 请求不能带 --data(--data 仅用于写操作)`, { group: 'raw', action: upper });
825
+ }
586
826
 
587
827
  if (flags['dry-run'] && !['GET', 'HEAD', 'OPTIONS'].includes(upper)) {
588
828
  return dry(`${upper} ${path}${body !== undefined ? ` ${JSON.stringify(body)}` : ''}`);
@@ -643,7 +883,7 @@ function promptHidden(query) {
643
883
  export async function setup(flags) {
644
884
  const tty = Boolean(process.stdin.isTTY);
645
885
  const need = (what) => {
646
- if (!tty) throw new Error(`非交互模式请提供 ${what}`);
886
+ if (!tty) throw new UsageError(`非交互模式请提供 ${what}`);
647
887
  };
648
888
 
649
889
  let url = flags.url;
@@ -652,7 +892,7 @@ export async function setup(flags) {
652
892
  url = await promptLine('站点 URL(https://...):');
653
893
  }
654
894
  url = String(url).replace(/\/+$/, '');
655
- if (!/^https:\/\//i.test(url)) throw new Error('URL 必须以 https:// 开头');
895
+ if (!/^https:\/\//i.test(url)) throw new UsageError('URL 必须以 https:// 开头');
656
896
 
657
897
  let user = flags.user;
658
898
  if (!user) {
@@ -665,7 +905,7 @@ export async function setup(flags) {
665
905
  need('--password');
666
906
  password = await promptHidden('应用密码(输入不回显):');
667
907
  }
668
- if (!password) throw new Error('应用密码为空');
908
+ if (!password) throw new UsageError('应用密码为空');
669
909
 
670
910
  const defaultName = (() => {
671
911
  try {
@@ -685,7 +925,7 @@ export async function setup(flags) {
685
925
  mkdirSync(dir, { recursive: true });
686
926
  const file = join(dir, `${name}.env`);
687
927
  if (existsSync(file) && !flags.force) {
688
- throw new Error(`已存在 ${file};要覆盖请加 --force`);
928
+ throw new UsageError(`已存在 ${file};要覆盖请加 --force`);
689
929
  }
690
930
 
691
931
  const content = [
package/lib/env.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { UsageError } from './errors.js';
4
5
 
5
6
  const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
6
7
 
@@ -44,7 +45,7 @@ export function parseDotenv(text) {
44
45
 
45
46
  function assertSiteName(site) {
46
47
  if (site && !/^[A-Za-z0-9._-]+$/.test(site)) {
47
- throw new Error(`非法站点名 "${site}":只允许字母、数字、点、下划线、连字符`);
48
+ throw new UsageError(`非法站点名 "${site}":只允许字母、数字、点、下划线、连字符`);
48
49
  }
49
50
  }
50
51
 
package/lib/errors.js ADDED
@@ -0,0 +1,10 @@
1
+ // 用法/参数错误:表示“命令调用方式不对”(缺参数、未知动作、缺少必填开关等),
2
+ // 与运行期错误(HTTP/网络/配置)区分开,由 CLI 以退出码 2 处理并附带用法提示。
3
+ export class UsageError extends Error {
4
+ constructor(message, meta = {}) {
5
+ super(message);
6
+ this.name = 'UsageError';
7
+ this.group = meta.group;
8
+ this.action = meta.action;
9
+ }
10
+ }