wpops 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -115,15 +115,19 @@ node bin/wpops.js doctor
115
115
  > **退出码**:`0` 成功;`2` 用法/参数错误(stderr 打印错误 + 精简用法 + `--help` 提示);`1` 运行期错误(HTTP/网络/配置)。`--help` 始终走 stdout 且为 `0`。
116
116
 
117
117
  ```
118
- # 内容
118
+ # 内容(默认存草稿,发布是独立动作——见下方"发布安全")
119
119
  wpops posts list [--per-page 10] [--page 2] [--status draft] [--search 关键词]
120
120
  [--orderby date] [--order desc] [--categories 1,2] [--tags 3]
121
121
  [--author 1] [--after 2026-01-01] [--before 2026-12-31] [--json]
122
122
  wpops posts get <id>
123
- wpops posts create --title "标题" --content "正文" [--status draft] [--categories 1,2] [--tags 3] [--featured-media 44]
123
+ wpops posts create --title "标题" --content "正文" [--categories 1,2] [--tags 3] # 未给 --status 即草稿
124
124
  wpops posts create --title "标题" --from-file ./post.md --status draft
125
- wpops posts update <id> --title "新标题" [--status publish] [--featured-media 0]
126
- wpops posts delete <id> [--force] # 默认进回收站
125
+ wpops posts export <id> --file post.html # 导出正文,本地改排版
126
+ wpops posts update <id> --from-file post.html --dry-run # 先预览,再真正应用(下面这行)
127
+ wpops posts update <id> --from-file post.html
128
+ wpops posts publish <id> # 显式发布
129
+ wpops posts unpublish <id> # 下线为草稿
130
+ wpops posts delete <id> [--force] # 默认进回收站
127
131
  wpops pages ... # 同样的子命令
128
132
  wpops me
129
133
 
@@ -229,11 +233,20 @@ wpops raw POST /wp-json/wp/v2/comments --data '{"post":1,"content":"hi"}'
229
233
 
230
234
  # 安全开关
231
235
  wpops posts delete 12 --dry-run # 只预览,不执行(所有写命令都支持)
236
+ wpops posts update 12 --from-file post.html --dry-run
232
237
  wpops --all posts delete 12 --dry-run # 跨站预览
233
238
  wpops --all plugins list # 读操作无需确认
234
239
  wpops --all plugins activate akismet/akismet --yes # 写操作必须 --yes
235
240
  ```
236
241
 
242
+ ### 发布安全(两步走)
243
+
244
+ - **创建默认草稿**,不会直接上线;发布是独立动作 `wpops posts publish <id>`(页面/CPT 同理),下线用 `unpublish <id>`。
245
+ - 修改**已发布**内容的正文时,会先在 stderr 提示并给出修订回滚命令(加 `--yes` 可静默);`settings` / 菜单位置等 live 变更同样会提示。
246
+ - 改版式/长文的安全流程:`export` 导出正文 → 本地编辑 → 建**草稿副本**预览(回显后台编辑链接)→ `update --from-file` 应用到原页 → 不满意用 `revisions restore` 回滚。
247
+ - **页面构建器注意**:Elementor / SiteOrigin / Divi 等把布局存在插件 meta 里,直接改 REST 的 `post_content` 会**弄坏页面**;这类页面请在构建器 UI 里改,wpops 只用于体检/备份/回滚。
248
+ - WP 的**前端草稿预览**依赖登录态,应用密码在浏览器里用不了;可靠入口是回显的**后台编辑/预览链接**。
249
+
237
250
  ## 4. 多站点
238
251
 
239
252
  所有站点都放在 `sites/`,一个站点一个文件:
package/lib/commands.js CHANGED
@@ -3,7 +3,7 @@ import { basename, join } from 'node:path';
3
3
  import { createInterface } from 'node:readline';
4
4
  import { loadEnv, configHome } from './env.js';
5
5
  import { buildConfig, createClient, WpError } from './client.js';
6
- import { stripTags, printJson, dry, pick, listRequest, parseJson, readJsonFile } from './util.js';
6
+ import { stripTags, printJson, dry, warn, pick, listRequest, parseJson, readJsonFile } from './util.js';
7
7
  import { UsageError } from './errors.js';
8
8
 
9
9
  const CONTENT = {
@@ -31,10 +31,14 @@ const USERS = '/wp-json/wp/v2/users';
31
31
  const MENUS = '/wp-json/wp/v2/menus';
32
32
  const MENU_ITEMS = '/wp-json/wp/v2/menu-items';
33
33
 
34
- function printItem(data, flags) {
34
+ /** 内容写操作的结果输出:草稿/待审等非公开状态额外回显后台编辑(预览)链接。 */
35
+ function printItem(data, flags, client) {
35
36
  if (flags.json) return printJson(data);
36
37
  console.log(`✓ #${data.id} [${data.status}] ${stripTags(data.title?.rendered || '')}`);
37
- if (data.link) console.log(` ${data.link}`);
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`);
41
+ }
38
42
  }
39
43
 
40
44
  // ------------------------------------------------------------------ doctor
@@ -255,17 +259,61 @@ export async function createContent(client, type, flags) {
255
259
  if (body.title === undefined && body.content === undefined) {
256
260
  throw new UsageError('至少需要 --title 或 --content/--from-file');
257
261
  }
258
- if (flags['dry-run']) return dry(`创建 ${type} ${JSON.stringify(body)}`);
262
+ // 未显式指定状态时按草稿创建,不依赖服务端默认(避免"直接发布")。
263
+ if (body.status === undefined) body.status = 'draft';
264
+ if (flags['dry-run']) return dry(`将创建 ${type}(status=${body.status}) ${JSON.stringify(body)}`);
259
265
  const { data } = await client.request('POST', contentPath(type), { body });
260
- printItem(data, flags);
266
+ printItem(data, flags, client);
261
267
  }
262
268
 
263
269
  export async function updateContent(client, type, id, flags) {
264
270
  const body = contentBody(flags);
265
271
  if (!Object.keys(body).length) throw new UsageError('没有可更新的字段');
266
- if (flags['dry-run']) return dry(`更新 ${type} #${id} ${JSON.stringify(body)}`);
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)}`);
267
289
  const { data } = await client.request('POST', `${contentPath(type)}/${Number(id)}`, { body });
268
- printItem(data, flags);
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`);
269
317
  }
270
318
 
271
319
  export async function deleteContent(client, type, id, flags) {
@@ -586,6 +634,9 @@ export async function updateMenu(client, id, flags) {
586
634
  if (!Object.keys(body).length) {
587
635
  throw new UsageError('没有可更新的字段(--name/--slug/--description/--locations/--auto-add)');
588
636
  }
637
+ if (body.locations !== undefined) {
638
+ warn('正在修改菜单位置绑定,会立即影响站点导航', flags);
639
+ }
589
640
  if (flags['dry-run']) return dry(`更新菜单 #${id} ${JSON.stringify(body)}`);
590
641
  const { data } = await menuRequest(client, 'POST', `${MENUS}/${Number(id)}`, { body });
591
642
  if (flags.json) return printJson(data);
@@ -593,6 +644,7 @@ export async function updateMenu(client, id, flags) {
593
644
  }
594
645
 
595
646
  export async function deleteMenu(client, id, flags) {
647
+ warn('删除菜单会影响站点导航', flags);
596
648
  if (flags['dry-run']) return dry(`删除菜单 #${id}`);
597
649
  if (!flags.force) throw new UsageError('删除菜单需要 --force(不可撤销)');
598
650
  const { data } = await menuRequest(client, 'DELETE', `${MENUS}/${Number(id)}`, { query: { force: 'true' } });
package/lib/run.js CHANGED
@@ -43,6 +43,14 @@ export async function dispatch(client, cfg, group, action, rest, flags) {
43
43
  case 'update':
44
44
  case 'edit':
45
45
  return cmd.updateContent(client, type, needFirst('update'), flags);
46
+ case 'import':
47
+ return cmd.updateContent(client, type, needFirst('import'), flags);
48
+ case 'export':
49
+ return cmd.exportContent(client, type, needFirst('export'), flags);
50
+ case 'publish':
51
+ return cmd.setContentStatus(client, type, needFirst('publish'), 'publish', flags);
52
+ case 'unpublish':
53
+ return cmd.setContentStatus(client, type, needFirst('unpublish'), 'draft', flags);
46
54
  case 'delete':
47
55
  case 'rm':
48
56
  return cmd.deleteContent(client, type, needFirst('delete'), flags);
package/lib/settings.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // 站点设置:读取 / 更新核心 /wp/v2/settings 端点。
2
2
  // flag 名(- 分隔)映射到 REST 字段(_ 分隔),数值字段自动转 Number。
3
3
 
4
- import { printJson, dry, parseJson, readJsonFile } from './util.js';
4
+ import { printJson, dry, warn, parseJson, readJsonFile } from './util.js';
5
5
  import { UsageError } from './errors.js';
6
6
 
7
7
  const SETTINGS = '/wp-json/wp/v2/settings';
@@ -66,6 +66,7 @@ export async function updateSettings(client, flags) {
66
66
  throw new UsageError('没有可更新的字段(--title/--description/--timezone/--posts-per-page/... 或 --data JSON)');
67
67
  }
68
68
 
69
+ warn('正在修改站点设置,会立即对全站生效', flags);
69
70
  if (flags['dry-run']) return dry(`更新站点设置 ${JSON.stringify(body)}`);
70
71
 
71
72
  const { data } = await client.request('POST', SETTINGS, { body });
package/lib/spec.js CHANGED
@@ -2,7 +2,8 @@
2
2
  // help / `wpops commands --json` / --all 写操作护栏(isMutation)/ shell 补全 全部由这里派生,
3
3
  // 避免文档与代码各写一份、越走越偏。
4
4
 
5
- const CONTENT_MUT = ['create', 'new', 'update', 'edit', 'delete', 'rm'];
5
+ const CONTENT_MUT = ['create', 'new', 'update', 'edit', 'import', 'publish', 'unpublish', 'delete', 'rm'];
6
+ const TERM_MUT = ['create', 'new', 'update', 'edit', 'delete', 'rm'];
6
7
  const ITEM_MUT = ['create', 'new', 'update', 'edit', 'delete', 'rm'];
7
8
 
8
9
  /** 全局开关(所有组通用)。 */
@@ -71,33 +72,46 @@ export const GROUPS = [
71
72
  },
72
73
 
73
74
  {
74
- name: 'posts', summary: '文章:增删改查', aliases: ['post'],
75
- actions: { list: '列出', get: '查看', create: '新建', update: '更新', delete: '删除' },
75
+ name: 'posts', summary: '文章:增删改查/发布/导出', aliases: ['post'],
76
+ actions: {
77
+ list: '列出', get: '查看', create: '新建(默认草稿)', update: '更新',
78
+ import: '从文件导入正文', export: '导出正文到文件',
79
+ publish: '发布', unpublish: '下线为草稿', delete: '删除',
80
+ },
76
81
  mutation: CONTENT_MUT,
77
82
  usage: [
78
83
  'wpops posts list [--per-page 10] [--page 2] [--status draft] [--search 词] [--categories 1,2]',
79
84
  'wpops posts get <id>',
80
- 'wpops posts create --title "标题" --content "正文" [--status draft] [--categories 1,2] [--tags 3]',
81
- 'wpops posts create --title "标题" --from-file ./post.md',
82
- 'wpops posts update <id> --title "新标题" [--status publish] [--featured-media 44]',
83
- 'wpops posts delete <id> [--force] # 默认进回收站',
85
+ 'wpops posts create --title "标题" --content "正文" # 默认草稿,不会直接上线',
86
+ 'wpops posts export <id> --file post.html # 导出后本地改排版',
87
+ 'wpops posts update <id> --from-file post.html --dry-run # 先预览,再真正应用',
88
+ 'wpops posts publish <id> · wpops posts unpublish <id>',
89
+ 'wpops posts delete <id> [--force] # 默认进回收站',
84
90
  ],
85
- flags: ['--title', '--content', '--from-file', '--status', '--slug', '--excerpt',
91
+ flags: ['--title', '--content', '--from-file', '--file', '--status', '--slug', '--excerpt',
86
92
  '--featured-media', '--author', '--date', '--categories', '--tags',
87
93
  '--per-page', '--page', '--search', '--orderby', '--order'],
88
94
  },
89
95
  {
90
- name: 'pages', summary: '页面:增删改查', aliases: ['page'],
91
- actions: { list: '列出', get: '查看', create: '新建', update: '更新', delete: '删除' },
96
+ name: 'pages', summary: '页面:增删改查/发布/导出', aliases: ['page'],
97
+ actions: {
98
+ list: '列出', get: '查看', create: '新建(默认草稿)', update: '更新',
99
+ import: '从文件导入正文', export: '导出正文到文件',
100
+ publish: '发布', unpublish: '下线为草稿', delete: '删除',
101
+ },
92
102
  mutation: CONTENT_MUT,
93
- usage: ['wpops pages ... # 与 posts 同'],
103
+ usage: ['wpops pages ... # 与 posts 同'],
94
104
  flags: ['同 posts'],
95
105
  },
96
106
  {
97
107
  name: 'content', summary: '任意 show_in_rest 的 CPT(按 rest_base)', local: false,
98
- actions: { list: '列出', get: '查看', create: '新建', update: '更新', delete: '删除' },
108
+ actions: {
109
+ list: '列出', get: '查看', create: '新建(默认草稿)', update: '更新',
110
+ import: '从文件导入正文', export: '导出正文到文件',
111
+ publish: '发布', unpublish: '下线为草稿', delete: '删除',
112
+ },
99
113
  mutation: CONTENT_MUT,
100
- usage: ['wpops content list books', 'wpops content get books 12', 'wpops content create books --title "..."'],
114
+ usage: ['wpops content list books', 'wpops content export books 12 --file book.html', 'wpops content publish books 12'],
101
115
  flags: ['同 posts;第一个位置参数是 rest_base'],
102
116
  },
103
117
  {
@@ -121,21 +135,21 @@ export const GROUPS = [
121
135
  {
122
136
  name: 'categories', summary: '分类:增删改查', aliases: ['category'],
123
137
  actions: { list: '列出', get: '查看', create: '新建', update: '更新', delete: '删除' },
124
- mutation: CONTENT_MUT,
138
+ mutation: TERM_MUT,
125
139
  usage: ['wpops categories create --name "新分类" [--slug x] [--parent 3]', 'wpops categories delete <id> --force'],
126
140
  flags: ['--name', '--slug', '--parent', '--description'],
127
141
  },
128
142
  {
129
143
  name: 'tags', summary: '标签:增删改查', aliases: ['tag'],
130
144
  actions: { list: '列出', get: '查看', create: '新建', update: '更新', delete: '删除' },
131
- mutation: CONTENT_MUT,
145
+ mutation: TERM_MUT,
132
146
  usage: ['wpops tags ... # 与 categories 同'],
133
147
  flags: ['--name', '--slug', '--parent', '--description'],
134
148
  },
135
149
  {
136
150
  name: 'terms', summary: '任意 show_in_rest 的自定义分类法(按 rest_base)', local: false,
137
151
  actions: { list: '列出', get: '查看', create: '新建', update: '更新', delete: '删除' },
138
- mutation: CONTENT_MUT,
152
+ mutation: TERM_MUT,
139
153
  usage: ['wpops terms list genres', 'wpops terms create genres --name 科幻'],
140
154
  flags: ['同 categories;第一个位置参数是 rest_base'],
141
155
  },
package/lib/util.js CHANGED
@@ -21,6 +21,12 @@ function dry(message) {
21
21
  console.log(`[dry-run] ${message}`);
22
22
  }
23
23
 
24
+ /** 非阻断风险提示(stderr);--yes 或 --quiet 时静默。 */
25
+ function warn(message, flags = {}) {
26
+ if (flags.quiet || flags.yes) return;
27
+ console.error(`⚠ ${message}`);
28
+ }
29
+
24
30
  /** 把 flags 中的指定键转成 REST 查询参数(- 转 _)。 */
25
31
  function pick(flags, keys) {
26
32
  const query = {};
@@ -139,5 +145,5 @@ export async function listRequest(client, path, query, flags = {}, request) {
139
145
  return { data, total: headers.get('x-wp-total'), totalPages: headers.get('x-wp-totalpages') };
140
146
  }
141
147
 
142
- export { stripTags, dry, pick, printJson, project, toCsv, toTable };
148
+ export { stripTags, dry, warn, pick, printJson, project, toCsv, toTable };
143
149
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wpops",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "WordPress REST operations toolkit — manage posts, pages, media, menus, comments, users, custom types, revisions, blocks, templates, widgets, settings and plugins from the CLI. Multi-site, safety-first, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "qnnp",
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: wpops
3
3
  description: Use when managing a WordPress site through wpops, the zero-dependency REST CLI (npm package `wpops`, command `wpops`) — posts, pages, media, menus, comments, users, custom post types, search, settings, revisions, blocks, block-theme navigation/templates/global styles, widgets, application passwords, plugins, themes, Abilities, site health, or batch requests; configuring a site; or operating across multiple configured sites. Covers the required safe workflow (doctor first, --dry-run before writes, --all needs --yes) and the REST capability boundary. Prefer wpops over hand-written curl or raw HTTP for any WordPress task.
4
+ license: MIT
4
5
  ---
5
6
 
6
7
  # wpops —— WordPress REST 操作
@@ -24,11 +25,12 @@ description: Use when managing a WordPress site through wpops, the zero-dependen
24
25
  ## 安全流程(必须遵守)
25
26
  1. 首次操作某站先跑 `wpops doctor`(检查连接与权限;失败返回非 0)。
26
27
  2. 任何写操作先加 `--dry-run` 预览(`delete` 的预览不需要 `--force`;真正删除才加 `--force`)。
27
- 3. `--all` 的**写操作必须加 `--yes`**,否则会被拒绝。
28
- 4. 需要程序化解析输出时加 `--json`;要投影/表格用 `--fields a,b` / `--csv` / `--table`。
29
- 5. 应用密码绝不写进命令、日志或对话。
30
- 6. 破坏性操作(`delete --force`、分类/用户/菜单项/插件删除)先确认目标存在且正确。
31
- 7. 退出码:`0` 成功;`2` 用法/参数错误(stderr 会给用法与 `--help` 提示);`1` 运行期错误(HTTP/网络/配置)。脚本据此判断成败,不要只看有没有输出。
28
+ 3. **内容默认草稿**:创建不带 `--status` 即草稿,不会直接上线;发布用显式 `publish`(`unpublish` 下线)。修改已发布内容会先提示并给出 `wpops revisions restore` 回滚;`--yes` 可静默。改版式的安全流程:先建**草稿副本**预览(命令回显后台编辑链接),满意后再 `update` 原页。
29
+ 4. `--all` 的**写操作必须加 `--yes`**,否则会被拒绝。
30
+ 5. 需要程序化解析输出时加 `--json`;要投影/表格用 `--fields a,b` / `--csv` / `--table`。
31
+ 6. 应用密码绝不写进命令、日志或对话。
32
+ 7. 破坏性操作(`delete --force`、分类/用户/菜单项/插件删除)先确认目标存在且正确。页面构建器(Elementor 等)布局存在插件 meta,不要用 REST 改其 `post_content`。
33
+ 8. 退出码:`0` 成功;`2` 用法/参数错误(stderr 会给用法与 `--help` 提示);`1` 运行期错误(HTTP/网络/配置)。脚本据此判断成败,不要只看有没有输出。
32
34
 
33
35
  ## 现查现用(不要背细节)
34
36
  - `wpops <组> --help`、`wpops help <组> [动作]` — 该组动作与参数(**首选**)。
@@ -41,7 +43,7 @@ description: Use when managing a WordPress site through wpops, the zero-dependen
41
43
  内置:`doctor`(连接体检) · `me`(当前用户) · `setup`(配置站点) · `sites`(站点档案) · `install-skill` · `completion` · `commands`(命令清单)。
42
44
 
43
45
  内容与结构:
44
- - `posts` / `pages` — 文章/页面增删改查。
46
+ - `posts` / `pages` — 文章/页面增删改查;**默认草稿**、`publish`/`unpublish`、`export`/`import`(改版式)、`--dry-run` 预览。
45
47
  - `content <rest_base>` / `terms <rest_base>` — 任意 `show_in_rest` 的 CPT / 自定义分类。
46
48
  - `media` — 媒体列出/上传/URL 导入(`sideload`)/图像编辑(`edit-image` 裁剪旋转)/更新/删除。
47
49
  - `categories` / `tags` — 分类/标签。