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 ADDED
@@ -0,0 +1,19 @@
1
+ # wpops 站点配置模板。
2
+ # 复制成 sites/<名字>.env,填入该站真实值。sites/*.env 已被 .gitignore 忽略,不会提交。
3
+ # 生成应用密码:WordPress 后台 → 用户 → 个人资料 → 应用程序密码 → 勾选 "Read/Write"
4
+
5
+ # 站点地址(必须 https,结尾不要带斜杠)
6
+ WP_URL=https://your-site.example.com
7
+
8
+ # 登录用户名
9
+ WP_USER=admin
10
+
11
+ # 应用密码(可带空格,原样粘贴)
12
+ WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
13
+
14
+ # 可选:请求与连接超时(毫秒),默认 30000
15
+ # WP_TIMEOUT_MS=30000
16
+
17
+ # 可选:失败重试次数与退避基数(毫秒),默认 2 次 / 500ms
18
+ # WP_RETRIES=2
19
+ # WP_RETRY_DELAY_MS=500
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 qnnp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # wpops
2
+
3
+ > WordPress REST operations toolkit — a zero-dependency CLI (plus agent skill) to manage posts, pages, media, comments, users and plugins across one or many sites.
4
+
5
+ WordPress REST 操作工具。只依赖 Node 内置能力(无第三方包),用应用密码通过
6
+ `/wp-json/` 管理站点内容与插件。
7
+
8
+ ## 为什么是它
9
+
10
+ - **不用装插件、不用 SSH**,纯走 WordPress 核心 REST API。
11
+ - 凭据只落在本机 `sites/*.env`,不进 shell 历史、不写进命令。
12
+ - 输出可读,也可以 `--json` 交给别的脚本。
13
+
14
+ ## 安装 / 分发
15
+
16
+ 零依赖、纯 Node(≥20)。
17
+
18
+ **最快:一条命令装好(含 skill)**
19
+
20
+ ```powershell
21
+ npm i -g wpops # 装 CLI;postinstall 会自动把 skill 装到 ~/.agents/skills/wpops
22
+ wpops setup # 交互式配置站点:问 URL / 用户名 / 应用密码,写 sites/<名字>.env 并自动体检
23
+ ```
24
+
25
+ 第二条是配置站点(密码必须你亲自输入,不回显)。之后即可 `wpops posts list` 等。
26
+ 不想安装、只想用一次:`npx wpops doctor`。
27
+
28
+ **从 Git / 开发者**
29
+
30
+ ```powershell
31
+ git clone https://github.com/qnnp-me/wpops.git
32
+ cd wpops
33
+ ./install.ps1 # Windows;macOS/Linux 用 sh install.sh(装 CLI + skill)
34
+ ```
35
+
36
+ 不 clone 也行:`pnpm add -g github:qnnp-me/wpops`,再加
37
+ `npx skills add qnnp-me/wpops --skill wpops -a opencode -g`。
38
+
39
+ **发布(维护者)**
40
+
41
+ ```powershell
42
+ npm publish
43
+ ```
44
+
45
+ > 凭据不进仓库 / 包:`sites/*.env` 与 `.env` 已忽略,且 `package.json` 的 `files`
46
+ > 白名单不含 `sites/`。每台机器各自 `wpops setup`,工具与 skill 都不携带密钥。
47
+ > `WPOPS_SKIP_SKILL=1 npm i -g wpops` 可跳过自动装 skill;`wpops install-skill` 可手动补装。
48
+
49
+ ## 1. 配置
50
+
51
+ 给每个站点建一个档案。**推荐用向导**:`wpops setup`(问 URL/用户名/应用密码,自动写入并体检)。
52
+ 也可以手动:把 `.env.example` 复制成 `sites/<名字>.env`,填入真实值:
53
+
54
+ ```
55
+ WP_URL=https://your-site.example.com
56
+ WP_USER=你的用户名
57
+ WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
58
+ ```
59
+
60
+ 应用密码生成:WP 后台 → 用户 → 个人资料 → 应用程序密码 → 新建。
61
+ 建议**单独建一个自动化专用管理员账号**,别用你自己的主账号。
62
+
63
+ > `sites/*.env` 已在 `.gitignore` 中,不会被提交。
64
+
65
+ **配置位置**:默认读**包目录**下的 `.env` / `sites/`(git clone 就在仓库里)。
66
+ 若用 `npm i -g wpops` 全局安装,建议设环境变量 `WPOPS_HOME` 指向固定目录,
67
+ 把配置放那里,升级/重装不会丢:
68
+
69
+ ```powershell
70
+ # 例如(Windows)
71
+ setx WPOPS_HOME "%USERPROFILE%\.config\wpops"
72
+ # 然后把 sites\<名字>.env 放到该目录下
73
+ ```
74
+
75
+ ## 2. 先体检
76
+
77
+ ```
78
+ node bin/wpops.js doctor
79
+ ```
80
+
81
+ 它会逐项检查:配置、REST 根、当前用户与角色、文章/媒体/插件/主题权限、版本。
82
+ 有 ✗ 就看末尾的提示(401 多半是主机没透传 `Authorization` 头)。
83
+
84
+ ## 3. 命令
85
+
86
+ ```
87
+ # 内容
88
+ wpops posts list [--per-page 10] [--page 2] [--status draft] [--search 关键词]
89
+ [--orderby date] [--order desc] [--categories 1,2] [--tags 3]
90
+ [--author 1] [--after 2026-01-01] [--before 2026-12-31] [--json]
91
+ wpops posts get <id>
92
+ wpops posts create --title "标题" --content "正文" [--status draft] [--categories 1,2] [--tags 3] [--featured-media 44]
93
+ wpops posts create --title "标题" --from-file ./post.md --status draft
94
+ wpops posts update <id> --title "新标题" [--status publish] [--featured-media 0]
95
+ wpops posts delete <id> [--force] # 默认进回收站
96
+ wpops pages ... # 同样的子命令
97
+ wpops me
98
+
99
+ # 媒体
100
+ wpops media list [--per-page 20] [--media-type image]
101
+ wpops media get <id>
102
+ wpops media upload ./pic.jpg [--title "标题"] [--alt "替代文本"]
103
+ wpops media update <id> [--alt "替代文本"] [--caption "说明"] [--post 123]
104
+ wpops media delete <id>
105
+
106
+ # 分类 / 标签
107
+ wpops categories list [--per-page 100] [--search 关键词]
108
+ wpops categories create --name "新分类" [--slug x] [--parent 3] [--description "..."]
109
+ wpops categories update <id> --name "改名"
110
+ wpops categories delete <id> --force # 分类删除不可撤销,必须 --force
111
+ wpops tags ... # 同样的子命令
112
+
113
+ # 评论 / 用户
114
+ wpops comments list [--status hold|approved|spam|trash] [--post 123]
115
+ wpops comments update <id> --status approved
116
+ wpops comments delete <id> [--force]
117
+ wpops users list [--search 关键词] [--role administrator]
118
+ wpops users create --username bob --email bob@example.com --role editor
119
+ wpops users update <id> --role editor
120
+ wpops users delete <id> --force [--reassign 1] # 有内容的用户需指定内容转交人
121
+ wpops users me
122
+
123
+ # 插件 / 主题(见下方能力说明)
124
+ wpops plugins list [--status active] [--search 关键词]
125
+ wpops plugins install <wordpress.org 别名> [--activate]
126
+ wpops plugins activate <plugin> # plugin 形如 akismet/akismet
127
+ wpops plugins deactivate <plugin>
128
+ wpops themes list
129
+ wpops themes activate <stylesheet>
130
+
131
+ # 兜底:任意端点
132
+ wpops raw GET /wp-json/wp/v2/categories
133
+ wpops raw POST /wp-json/wp/v2/comments --data '{"post":1,"content":"hi"}'
134
+
135
+ # 安全开关
136
+ wpops posts delete 12 --dry-run # 只预览,不执行(所有写命令都支持)
137
+ wpops --all posts delete 12 --dry-run # 跨站预览
138
+ wpops --all plugins list # 读操作无需确认
139
+ wpops --all plugins activate akismet/akismet --yes # 写操作必须 --yes
140
+ ```
141
+
142
+ ## 4. 多站点
143
+
144
+ 所有站点都放在 `sites/`,一个站点一个文件:
145
+
146
+ ```
147
+ App\wpops\
148
+ └── sites\
149
+ ├── qnnp.env ← 站点:qnnp.me
150
+ ├── blog-a.env
151
+ └── blog-b.env
152
+ ```
153
+
154
+ 每个文件内容和 `.env.example` 一样,填该站自己的凭据(用各自的**应用密码**)。
155
+
156
+ ```powershell
157
+ wpops sites # 列出所有已配置站点
158
+ wpops doctor # 只有一个站点时,默认用它
159
+ wpops -s blog-a doctor # 指定站点(s 是 --site 的简写)
160
+ wpops -s blog-a posts list
161
+ wpops --all posts list # 对全部站点批量执行
162
+ wpops --all plugins list # 巡检所有站的插件
163
+ ```
164
+
165
+ - 只有一个站点时,不带 `-s` 的命令默认用它;有多个时请用 `-s <名字>` 或 `--all`。
166
+ - `--all` 遍历 `sites/` 下所有站点;某个站失败会单独报错,不影响其它站。
167
+ - 命名站点的配置**只**来自它自己的文件,不会被 shell 环境变量串味。
168
+ - `sites/*.env` 已被 `.gitignore` 忽略。
169
+
170
+ > 站点数量到几十个、又想做「批量更新/备份/安全」时,REST 就不够用了,
171
+ > 应该上 MainWP 这类集中管理平台。wpops 负责内容,MainWP 负责维护。
172
+
173
+ ## 5. 能力边界(重要,别被误导)
174
+
175
+ | 事项 | 核心 REST 能做到吗 |
176
+ |---|---|
177
+ | 文章 / 页面 / 媒体 / 分类 / 标签 / 评论 / 用户的增删改查 | ✅ 完整 |
178
+ | 插件:列出、**安装**(按 wp.org 别名)、启用/停用、删除 | ✅ |
179
+ | 插件:**更新已装插件代码** | ❌ 核心 REST 没有该端点 |
180
+ | 主题:列出、切换 | ✅ |
181
+ | 主题:安装 / 更新 | ❌ |
182
+ | WordPress 核心更新 | ❌ |
183
+ | 备份 / 数据库优化 / 缓存清理 | ❌(需服务器侧) |
184
+
185
+ **结论:REST 负责"内容运营",不负责"更新与维护"。** 想更新插件/核心,只能:
186
+ - WP-CLI + SSH(`wp plugin update --all`),或
187
+ - 后台手动点,或
188
+ - 装 MCP Adapter 类插件把 Abilities 暴露成 API。
189
+
190
+ ## 6. 安全
191
+
192
+ - `sites/*.env` 存放应用密码,等同于该账号登录凭据;专用账号 + 可随时在后台吊销。
193
+ - 应用密码要求 HTTPS。
194
+ - **写操作前先 `--dry-run` 预览**;破坏性操作(`delete --force`、分类/用户删除)另需 `--force`。
195
+ - **`--all` 的写操作必须加 `--yes`**,否则直接拒绝,避免手滑波及全线。
196
+ - 网络抖动/429/5xx 会自动重试(默认 2 次,幂等方法才重试网络错误)。
197
+ - 这是本地脚本,不对外开端口。
198
+
199
+ ## 7. 与 WordPress skills 的关系
200
+
201
+ 本项目是**执行器**(真正发请求);已安装的官方 skills
202
+ (`wp-rest-api`、`wp-wpcli-and-ops`、`wp-performance`、`wordpress-router`)
203
+ 是**知识/规范**,指导"该怎么安全地做"。
204
+
205
+ ## 8. 全局命令(可选)
206
+
207
+ 想让 `wpops` 在任意目录可用,用 pnpm 挂到全局:
208
+
209
+ ```powershell
210
+ cd C:\Users\qnnp\App\wpops
211
+ pnpm add -g .
212
+ ```
213
+
214
+ 会生成 `wpops` 命令(在 pnpm 全局 bin,已在 PATH 上)。
215
+
216
+ - 与源码是链接关系,**改代码立即生效**,不用重新安装。
217
+ - 若移动了 `wpops` 目录,重新执行一次 `pnpm add -g .`。
218
+ - 卸载:`pnpm remove -g wpops`。
package/bin/wpops.js ADDED
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env node
2
+ import { loadEnv, listSites } from '../lib/env.js';
3
+ import { buildConfig, createClient, WpError } from '../lib/client.js';
4
+ import { parseArgs, isMutation } from '../lib/args.js';
5
+ import { installSkill } from '../lib/skill.js';
6
+ import * as cmd from '../lib/commands.js';
7
+
8
+ function printJson(value) {
9
+ console.log(JSON.stringify(value, null, 2));
10
+ }
11
+
12
+ function printHelp() {
13
+ console.log(`wpops —— WordPress REST 操作工具
14
+
15
+ 用法: wpops [--site 名字 | --all] <组> <动作> [参数] [--flags]
16
+
17
+ setup 交互式配置一个站点(写入 sites/<名字>.env 并体检)
18
+ doctor 连接与权限体检(先跑这个)
19
+ me 当前用户与能力
20
+ sites 列出已配置的站点
21
+ install-skill 把内置 skill 装到 agent 发现目录
22
+
23
+ posts list|get|create|update|delete ...
24
+ pages list|get|create|update|delete ...
25
+ media list|get|upload|update|delete ...
26
+ plugins list|install|activate|deactivate ...
27
+ themes list|activate ...
28
+ categories list|get|create|update|delete ...
29
+ tags list|get|create|update|delete ...
30
+ comments list|get|update|delete ...
31
+ users list|get|create|update|delete|me ...
32
+ raw <METHOD> <path> [--data JSON] 任意端点兜底
33
+
34
+ 多站点:
35
+ --site <名字> / -s <名字> 对 sites/<名字>.env 执行
36
+ --all / -a 对所有已配置站点批量执行(写操作需 --yes)
37
+
38
+ 安全:
39
+ --dry-run 只预览不执行(写操作)
40
+ --yes / -y --all 写操作的确认开关
41
+
42
+ 常用参数:
43
+ --per-page N · --page N 分页(REST 上限 100)
44
+ --status <s> 状态(posts/comments 等)
45
+ --search <s> · --orderby <f> · --order <asc|desc>
46
+ --categories 1,2 · --tags 3,4 分类/标签 ID(过滤或设置)
47
+ --author <id> · --after <日期> · --before <日期>
48
+ --title/--content/--from-file 标题/正文
49
+ --slug <s> · --excerpt <s> 别名 / 摘要
50
+ --featured-media <id> 特色图
51
+ --alt <文本> · --caption <文本> 媒体替代文本 / 说明
52
+ --name/--parent/--description 分类/标签字段
53
+ --username/--email/--role 用户字段
54
+ --force 彻底删除 / 危险操作确认
55
+ --data <JSON> raw 的请求体
56
+ --json 输出原始 JSON
57
+
58
+ 示例:
59
+ wpops doctor
60
+ wpops -s blog-a posts list --per-page 5 --orderby date --order desc
61
+ wpops posts list --categories 3,7
62
+ wpops posts update 12 --from-file ./post.md --status draft
63
+ wpops media update 44 --alt "配图"
64
+ wpops categories list
65
+ wpops comments list --status hold
66
+ wpops --all posts list
67
+ wpops --all plugins list
68
+ wpops --all posts delete 5 --dry-run
69
+ wpops plugins install wp-super-cache --activate`);
70
+ }
71
+
72
+ function printSites() {
73
+ const sites = listSites();
74
+ if (!sites.length) {
75
+ console.log('还没有配置任何站点。');
76
+ console.log('把 .env.example 复制成 sites/<名字>.env,填入该站凭据。');
77
+ return;
78
+ }
79
+ console.log(`已配置 ${sites.length} 个站点:`);
80
+ for (const s of sites) {
81
+ let url = '(未填 WP_URL)';
82
+ try {
83
+ const env = loadEnv(s.name);
84
+ if (env.WP_URL) url = env.WP_URL;
85
+ } catch {
86
+ url = '(读取失败)';
87
+ }
88
+ console.log(` ${s.label.padEnd(16)} ${url}`);
89
+ }
90
+ }
91
+
92
+ async function runOne(site, group, action, rest, flags, showHeader) {
93
+ const cfg = buildConfig(loadEnv(site));
94
+ const client = createClient(cfg);
95
+
96
+ if (showHeader) {
97
+ console.log(`\n=== ${site || '(default)'} — ${cfg.url || '(未配置 WP_URL)'} ===`);
98
+ }
99
+
100
+ const needFirst = (what) => {
101
+ const value = rest[0];
102
+ if (!value) throw new Error(`${what} 需要一个参数`);
103
+ return value;
104
+ };
105
+
106
+ const content = async (type) => {
107
+ switch (action) {
108
+ case undefined:
109
+ case 'list':
110
+ return cmd.listContent(client, type, flags);
111
+ case 'get':
112
+ return cmd.getContent(client, type, needFirst('get'), flags);
113
+ case 'create':
114
+ case 'new':
115
+ return cmd.createContent(client, type, flags);
116
+ case 'update':
117
+ case 'edit':
118
+ return cmd.updateContent(client, type, needFirst('update'), flags);
119
+ case 'delete':
120
+ case 'rm':
121
+ return cmd.deleteContent(client, type, needFirst('delete'), flags);
122
+ default:
123
+ throw new Error(`${type} 未知子命令:${action}`);
124
+ }
125
+ };
126
+
127
+ const taxonomy = async (tax) => {
128
+ switch (action) {
129
+ case undefined:
130
+ case 'list':
131
+ return cmd.listTaxonomy(client, tax, flags);
132
+ case 'get':
133
+ return cmd.getTaxonomy(client, tax, needFirst('get'));
134
+ case 'create':
135
+ case 'new':
136
+ return cmd.createTaxonomy(client, tax, flags);
137
+ case 'update':
138
+ case 'edit':
139
+ return cmd.updateTaxonomy(client, tax, needFirst('update'), flags);
140
+ case 'delete':
141
+ case 'rm':
142
+ return cmd.deleteTaxonomy(client, tax, needFirst('delete'), flags);
143
+ default:
144
+ throw new Error(`${tax} 未知子命令:${action}`);
145
+ }
146
+ };
147
+
148
+ switch (group) {
149
+ case 'doctor':
150
+ return cmd.doctor(client, cfg, flags);
151
+ case 'me':
152
+ return cmd.me(client, flags);
153
+ case 'posts':
154
+ case 'post':
155
+ return content('posts');
156
+ case 'pages':
157
+ case 'page':
158
+ return content('pages');
159
+ case 'media':
160
+ switch (action) {
161
+ case undefined:
162
+ case 'list':
163
+ return cmd.listMedia(client, flags);
164
+ case 'get':
165
+ return cmd.getMedia(client, needFirst('get'), flags);
166
+ case 'upload':
167
+ return cmd.uploadMedia(client, needFirst('upload 需要文件路径'), flags);
168
+ case 'update':
169
+ case 'edit':
170
+ return cmd.updateMedia(client, needFirst('update'), flags);
171
+ case 'delete':
172
+ case 'rm':
173
+ return cmd.deleteMedia(client, needFirst('delete'), flags);
174
+ default:
175
+ throw new Error(`media 未知子命令:${action}`);
176
+ }
177
+ case 'plugins':
178
+ case 'plugin':
179
+ switch (action) {
180
+ case undefined:
181
+ case 'list':
182
+ return cmd.listPlugins(client, flags);
183
+ case 'install':
184
+ if (!rest[0]) {
185
+ throw new Error('plugins install 需要 wordpress.org 插件别名,例如 wpops plugins install akismet');
186
+ }
187
+ return cmd.installPlugin(client, rest[0], flags);
188
+ case 'activate':
189
+ return cmd.setPluginStatus(client, needFirst('activate'), true, flags);
190
+ case 'deactivate':
191
+ return cmd.setPluginStatus(client, needFirst('deactivate'), false, flags);
192
+ default:
193
+ throw new Error(`plugins 未知子命令:${action}`);
194
+ }
195
+ case 'themes':
196
+ case 'theme':
197
+ switch (action) {
198
+ case undefined:
199
+ case 'list':
200
+ return cmd.listThemes(client, flags);
201
+ case 'activate':
202
+ case 'switch':
203
+ return cmd.activateTheme(client, needFirst('activate'), flags);
204
+ default:
205
+ throw new Error(`themes 未知子命令:${action}`);
206
+ }
207
+ case 'categories':
208
+ case 'category':
209
+ return taxonomy('categories');
210
+ case 'tags':
211
+ case 'tag':
212
+ return taxonomy('tags');
213
+ case 'comments':
214
+ case 'comment':
215
+ switch (action) {
216
+ case undefined:
217
+ case 'list':
218
+ return cmd.listComments(client, flags);
219
+ case 'get':
220
+ return cmd.getComment(client, needFirst('get'));
221
+ case 'update':
222
+ case 'edit':
223
+ return cmd.updateComment(client, needFirst('update'), flags);
224
+ case 'delete':
225
+ case 'rm':
226
+ return cmd.deleteComment(client, needFirst('delete'), flags);
227
+ default:
228
+ throw new Error(`comments 未知子命令:${action}`);
229
+ }
230
+ case 'users':
231
+ case 'user':
232
+ switch (action) {
233
+ case undefined:
234
+ case 'list':
235
+ return cmd.listUsers(client, flags);
236
+ case 'me':
237
+ return cmd.me(client, flags);
238
+ case 'get':
239
+ return cmd.getUser(client, needFirst('get'));
240
+ case 'create':
241
+ case 'new':
242
+ return cmd.createUser(client, flags);
243
+ case 'update':
244
+ case 'edit':
245
+ return cmd.updateUser(client, needFirst('update'), flags);
246
+ case 'delete':
247
+ case 'rm':
248
+ return cmd.deleteUser(client, needFirst('delete'), flags);
249
+ default:
250
+ throw new Error(`users 未知子命令:${action}`);
251
+ }
252
+ case 'raw':
253
+ if (!action || !rest[0]) throw new Error('raw 用法:wpops raw <METHOD> <path> [--data JSON]');
254
+ return cmd.raw(client, action, rest[0], flags);
255
+ default:
256
+ throw new Error(`未知命令:${group}(运行 wpops help 查看用法)`);
257
+ }
258
+ }
259
+
260
+ async function main() {
261
+ const { positionals, flags } = parseArgs(process.argv.slice(2));
262
+ const [group, action, ...rest] = positionals;
263
+
264
+ if (!group || group === 'help' || flags.help) {
265
+ printHelp();
266
+ return;
267
+ }
268
+ if (group === 'sites') {
269
+ printSites();
270
+ return;
271
+ }
272
+ if (group === 'setup') {
273
+ await cmd.setup(flags);
274
+ return;
275
+ }
276
+ if (group === 'install-skill') {
277
+ const dirs = installSkill();
278
+ if (!dirs.length) {
279
+ console.log('未安装 skill(可能已存在同名目录被跳过,或用 WPOPS_SKIP_SKILL 禁用)。');
280
+ } else {
281
+ for (const dir of dirs) console.log(`✓ skill 已安装到 ${dir}`);
282
+ }
283
+ return;
284
+ }
285
+
286
+ const site = flags.site || process.env.WPOPS_SITE || null;
287
+ const mutating = isMutation(group, action);
288
+
289
+ if (flags.all) {
290
+ const targets = listSites();
291
+ if (!targets.length) throw new Error('还没有配置任何站点');
292
+ if (mutating && !flags.yes && !flags['dry-run']) {
293
+ throw new Error('--all 会作用于所有站点。写操作请加 --yes 确认,或先用 --dry-run 预览。');
294
+ }
295
+ let failures = 0;
296
+ for (const target of targets) {
297
+ try {
298
+ await runOne(target.name, group, action, rest, flags, true);
299
+ } catch (err) {
300
+ failures++;
301
+ console.error(`✗ [${target.label}] ${err.message}`);
302
+ }
303
+ }
304
+ if (failures) {
305
+ console.error(`\n完成:${failures}/${targets.length} 个站点出错。`);
306
+ process.exitCode = 1;
307
+ }
308
+ return;
309
+ }
310
+
311
+ await runOne(site, group, action, rest, flags, false);
312
+ }
313
+
314
+ main().catch((err) => {
315
+ if (err instanceof WpError) {
316
+ console.error(`✗ ${err.message}`);
317
+ if (err.code) console.error(` 代码:${err.code}`);
318
+ } else {
319
+ console.error(`✗ ${err.message}`);
320
+ }
321
+ process.exit(1);
322
+ });
package/lib/args.js ADDED
@@ -0,0 +1,85 @@
1
+ // 已知的布尔开关:遇到它们不吞下一个参数,避免 `--json -s a` 把 -s 当值。
2
+ const BOOLEAN_FLAGS = new Set(['json', 'force', 'activate', 'dry-run', 'yes', 'all', 'help']);
3
+
4
+ /** 解析命令行。支持 --flag value、--flag=value、布尔 --flag、以及 -s/-a/-h/-y。 */
5
+ export function parseArgs(argv) {
6
+ const positionals = [];
7
+ const flags = {};
8
+ for (let i = 0; i < argv.length; i++) {
9
+ const arg = argv[i];
10
+ if (arg === '-s') {
11
+ flags.site = argv[++i];
12
+ continue;
13
+ }
14
+ if (arg === '-a') {
15
+ flags.all = true;
16
+ continue;
17
+ }
18
+ if (arg === '-h') {
19
+ flags.help = true;
20
+ continue;
21
+ }
22
+ if (arg === '-y') {
23
+ flags.yes = true;
24
+ continue;
25
+ }
26
+ if (arg.startsWith('--')) {
27
+ const eq = arg.indexOf('=');
28
+ if (eq !== -1) {
29
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
30
+ continue;
31
+ }
32
+ const key = arg.slice(2);
33
+ if (BOOLEAN_FLAGS.has(key)) {
34
+ flags[key] = true;
35
+ continue;
36
+ }
37
+ // 需要取值的 flag:即使下一个以 - 开头也当作值(支持 --content "- 列表")
38
+ const next = argv[i + 1];
39
+ if (next !== undefined) {
40
+ flags[key] = next;
41
+ i++;
42
+ } else {
43
+ flags[key] = true;
44
+ }
45
+ continue;
46
+ }
47
+ positionals.push(arg);
48
+ }
49
+ return { positionals, flags };
50
+ }
51
+
52
+ const CONTENT_MUTATIONS = new Set(['create', 'new', 'update', 'edit', 'delete', 'rm']);
53
+
54
+ /** 判断命令是否会修改站点数据(用于 --all 护栏与 --dry-run 提示)。 */
55
+ export function isMutation(group, action) {
56
+ const act = action || '';
57
+ switch (group) {
58
+ case 'posts':
59
+ case 'post':
60
+ case 'pages':
61
+ case 'page':
62
+ case 'categories':
63
+ case 'category':
64
+ case 'tags':
65
+ case 'tag':
66
+ return CONTENT_MUTATIONS.has(act);
67
+ case 'media':
68
+ return ['upload', 'update', 'edit', 'delete', 'rm'].includes(act);
69
+ case 'plugins':
70
+ case 'plugin':
71
+ return ['install', 'activate', 'deactivate'].includes(act);
72
+ case 'themes':
73
+ case 'theme':
74
+ return ['activate', 'switch'].includes(act);
75
+ case 'comments':
76
+ case 'comment':
77
+ return ['update', 'edit', 'delete', 'rm'].includes(act);
78
+ case 'users':
79
+ return ['create', 'update', 'edit', 'delete', 'rm'].includes(act);
80
+ case 'raw':
81
+ return !['GET', 'HEAD', 'OPTIONS'].includes(act.toUpperCase());
82
+ default:
83
+ return false;
84
+ }
85
+ }