@diviops/mcp-server 0.2.23 → 0.2.29
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 +19 -9
- package/dist/compatibility.d.ts +1 -1
- package/dist/compatibility.js +1 -1
- package/dist/index.js +487 -11
- package/dist/wp-cli-fs-validator.d.ts +6 -2
- package/dist/wp-cli-fs-validator.js +64 -2
- package/dist/wp-cli.d.ts +19 -3
- package/dist/wp-cli.js +95 -56
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -96,9 +96,9 @@ The server connects via standard WordPress REST API and works with any environme
|
|
|
96
96
|
|
|
97
97
|
> **WP-CLI note:** `WP_PATH` keeps the existing Local by Flywheel behavior by running `wp` directly on the host filesystem. For Docker-based environments (DDEV, wp-env, DevKinsta, WordPress Studio), set `WP_CLI_CMD` to the wrapper command instead. When `WP_CLI_CMD` is set, the server executes the wrapper from `WP_PATH` if provided, otherwise from its current working directory. The MCP server still validates the requested WP-CLI subcommand against its allowlist before executing either path.
|
|
98
98
|
|
|
99
|
-
## Available Tools (
|
|
99
|
+
## Available Tools (63)
|
|
100
100
|
|
|
101
|
-
### Read (
|
|
101
|
+
### Read (30)
|
|
102
102
|
| Tool | Description |
|
|
103
103
|
|------|-------------|
|
|
104
104
|
| `diviops_test_connection` | Test WordPress connection and Divi version |
|
|
@@ -115,7 +115,7 @@ The server connects via standard WordPress REST API and works with any environme
|
|
|
115
115
|
| `diviops_find_icon` | Search 1,989 icons by keyword (FA + Divi) |
|
|
116
116
|
| `diviops_list_templates` | List available MCP prompt templates |
|
|
117
117
|
| `diviops_get_template` | Get a specific template's block markup |
|
|
118
|
-
| `diviops_preset_audit` | Audit presets with referenced/unreferenced analysis. Walks both page content and in-registry `groupPresets` chains; exposes `block_ref_count`, `group_ref_count`, `referenced_by_presets` |
|
|
118
|
+
| `diviops_preset_audit` | Audit presets with referenced/unreferenced analysis. Walks both page content and in-registry `groupPresets` chains; exposes `block_ref_count`, `group_ref_count`, `referenced_by_presets`. Also reports `orphan_default_pointers` — per-bucket `default` pointers referencing UUIDs missing from `items[]` (legacy damage from past unsafe deletes; clear via `diviops_preset_set_default` in bucket-addressed mode: `type` + `module` + `unset=true`) |
|
|
119
119
|
| `diviops_preset_scan_orphans` | List page-referenced preset UUIDs missing from the D5 registry (separates dangling orphans from D4-legacy refs) |
|
|
120
120
|
| `diviops_list_library` | List saved Divi Library items |
|
|
121
121
|
| `diviops_get_library_item` | Get a library item's block markup |
|
|
@@ -125,10 +125,14 @@ The server connects via standard WordPress REST API and works with any environme
|
|
|
125
125
|
| `diviops_get_tb_layout` | Get a Theme Builder layout's block markup (header/body/footer) |
|
|
126
126
|
| `diviops_list_variables` | List design token variables (filter by type or prefix) |
|
|
127
127
|
| `diviops_variables_scan_orphans` | Find `gvid-`/`gcid-` refs with no backing Variable Manager entry (orphans render as invalid CSS) + unused variables (defined, never referenced). Scans pages, Theme Builder layouts (header/body/footer), Divi Library items, canvas pages, and the preset registry |
|
|
128
|
+
| `diviops_variables_used_on_page` | Detect which `gvid-` (numeric/font) IDs a single page emits — the exact set Divi 5.4.0+ uses to scope selective `:root{--gvid-*}` CSS variable emission. Walks the same content stack the frontend assembles (post_content + active TB header/body/footer + appended canvases + presets). `gcid-` colors are out of scope (separate emission path). Use for per-page orphan validation, preflight before bulk variable rename, or to debug why a numeric/font variable doesn't render on a specific page. Read-only |
|
|
128
129
|
| `diviops_list_canvases` | List all canvas pages |
|
|
129
130
|
| `diviops_get_canvas` | Get canvas content |
|
|
131
|
+
| `diviops_scf_status` | Show SCF (Secure Custom Fields) sync status — pending JSON-vs-DB drift across field groups, post types, taxonomies, options pages. Wraps `wp scf json status` |
|
|
132
|
+
| `diviops_scf_list_field_groups` | List all SCF/ACF field groups (post_name = ACF key, post_title, post_status, post_modified). Queries the `acf-field-group` post type via `wp post list` (works on SCF 6.8.4+ and older ACF) |
|
|
133
|
+
| `diviops_scf_get_field_group` | Fetch a single SCF/ACF field-group post by ACF key (`group_abc123` → post_name) or numeric WP post ID. For the parsed/structured field tree, use `diviops_scf_export --field-groups=<key> --stdout` |
|
|
130
134
|
|
|
131
|
-
### Write (
|
|
135
|
+
### Write (31)
|
|
132
136
|
| Tool | Description |
|
|
133
137
|
|------|-------------|
|
|
134
138
|
| `diviops_create_page` | Create a new page with optional Divi content |
|
|
@@ -148,16 +152,20 @@ The server connects via standard WordPress REST API and works with any environme
|
|
|
148
152
|
| `diviops_preset_create` | Write a new preset to the D5 registry (module or group type, supports `divi/column` etc.). Optional `make_default: true` sets it as the bucket's default; optional `priority` controls stack-merge order |
|
|
149
153
|
| `diviops_preset_reassign` | Rewrite `modulePreset` references across pages (dry-run by default; optional `strip_inline` removes redundant inline attrs) |
|
|
150
154
|
| `diviops_preset_update` | Update a specific preset (name, attrs, priority) |
|
|
151
|
-
| `diviops_preset_delete` | Delete a preset by ID |
|
|
152
|
-
| `diviops_preset_set_default` | Set or clear the per-module/group default preset (
|
|
155
|
+
| `diviops_preset_delete` | Delete a preset by ID. Refuses with HTTP 409 `preset_is_default` when the target is the registered default for its bucket — clear the pointer first via `diviops_preset_set_default` with `unset=true`, or pass `force=true` to delete and clear the pointer in one write |
|
|
156
|
+
| `diviops_preset_set_default` | Set or clear the per-module/group default preset. Two modes: by `preset_id` (UUID-addressed; auto-resolves bucket) or by `type` + `module` + `unset=true` (bucket-addressed clear, used to repair orphan default pointers when the UUID is gone from `items[]`). Defaults apply to NEW instances only — use `diviops_preset_reassign` for retroactive swaps |
|
|
153
157
|
| `diviops_save_to_library` | Save block markup to Divi Library |
|
|
154
158
|
| `diviops_update_tb_layout` | Update a Theme Builder layout's block markup |
|
|
155
159
|
| `diviops_create_tb_template` | Create Theme Builder template with header/footer and conditions |
|
|
156
160
|
| `diviops_create_variable` | Create a design token variable. For `type=numbers` fluid tokens, pass `min`+`max` shorthand (anchors default to 320px/1920px) or explicit `targets` like `{"320px":"20px","1920px":"60px"}` — server generates arithmetically-correct `clamp()` instead of hand-written math that silently under-reaches the stated max. All-px inputs emit px (root-agnostic). Rem inputs OR rem output require explicit opt-in: pass `output_unit="rem"` (accepts the 1rem=16px default) or `root_font_size_px:N` (declares your site's actual root font-size, e.g. `10` for `html { font-size: 62.5% }`, `20` for `html { font-size: 20px }`) |
|
|
161
|
+
| `diviops_create_fluid_system` | Batch-emit a fluid typography + spacing + radius variable set in one call. Mirrors Divi 5.4.0's Variable Generator Modal at the algorithm level (clamp() math is identical to `diviops_create_variable`'s fluid mode) but layers profile-selectable anchors over it: `divi-default` (360→1350) matches ET's defaults, `wide` (320→1920) matches the diviops convention, `custom` takes explicit anchors. Each category is independent and optional. Typography uses modular-scale chains (named ratios `major-third`/`perfect-fifth`/`golden`/etc., or raw numbers) — h1 = largest, hN = base. Spacing/radius support `linear` or `geometric` step distributions. `dry_run: true` returns the full plan without persisting; `overwrite: false` (default) skips existing IDs. Single atomic write to the registry — mid-batch failures roll back cleanly |
|
|
157
162
|
| `diviops_delete_variable` | Delete a variable by ID. Returns HTTP 409 when live references exist unless `force=true` (use `diviops_variables_scan_orphans` to find reference locations). Returns HTTP 403 for Divi's customizer-bound defaults (`gcid-primary-color`, `gcid-secondary-color`, `gcid-heading-color`, `gcid-body-color`, `gcid-link-color` — managed via WP Customizer) |
|
|
158
163
|
| `diviops_create_canvas` | Create a canvas page |
|
|
159
164
|
| `diviops_update_canvas` | Update canvas content |
|
|
160
165
|
| `diviops_delete_canvas` | Delete a canvas page |
|
|
166
|
+
| `diviops_scf_export` | Export SCF schema (field groups, post types, taxonomies, options pages) as JSON to a directory under the safe-root, or to stdout. Wraps `wp scf json export` |
|
|
167
|
+
| `diviops_scf_import` | Import SCF schema from a JSON file (mutates DB; idempotent — existing items are updated). Wraps `wp scf json import <file>` |
|
|
168
|
+
| `diviops_scf_sync` | Apply pending JSON-on-disk SCF changes to the DB. Defaults to `dry_run: true` for safety. Wraps `wp scf json sync` |
|
|
161
169
|
|
|
162
170
|
### Utility (2)
|
|
163
171
|
| Tool | Description |
|
|
@@ -180,7 +188,7 @@ Read-only commands plus non-destructive writes needed for core MCP functionality
|
|
|
180
188
|
| Post meta | `post meta get`, `post meta list`, `post meta set`, `post meta update` |
|
|
181
189
|
| Post types | `post-type list`, `post-type get` |
|
|
182
190
|
| Taxonomies | `taxonomy list`, `term list`, `term create`, `term update` |
|
|
183
|
-
| ACF / SCF | `acf export`, `acf import`, `acf field-group list`, `acf field-group get` |
|
|
191
|
+
| ACF / SCF | `acf export`, `acf import`, `acf field-group list`, `acf field-group get`, `scf json {status,sync,import,export}` (also aliased as `acf json …` per SCF 6.8.4+) |
|
|
184
192
|
| Users | `user list` |
|
|
185
193
|
| Cache | `cache flush`, `transient delete`, `rewrite flush` |
|
|
186
194
|
| Export | `export` (WXR data export to file) |
|
|
@@ -235,12 +243,13 @@ The sentinel grants exactly the extended set above — it does NOT unlock anythi
|
|
|
235
243
|
|
|
236
244
|
### Filesystem flag validation
|
|
237
245
|
|
|
238
|
-
The
|
|
246
|
+
The DEFAULT-tier filesystem commands (`wp export`, `acf export <path>`, `acf import <path>`, `scf json export --dir=<path>`, `scf json import <file>`, plus the `acf json …` aliases) are second-pass validated against a safe root so wrong-path arguments can't write WXR / schema JSON to the web root or read configs from arbitrary locations.
|
|
239
247
|
|
|
240
248
|
- **Safe root**: `<WP_PATH>/.diviops-tmp/` by default (auto-created on first use in host mode). Override with `DIVIOPS_WP_CLI_SAFE_FS_ROOT=/absolute/path`. All path arguments must canonicalize under this directory; symlinks are resolved via `realpath` so a planted symlink inside the safe root pointing outside it is caught.
|
|
241
249
|
- **`wp export` must pass `--dir=<path-under-safe-root>`** (or `--stdout`). Without `--dir`, wp-cli writes to the current working directory; on prod that's typically the web root.
|
|
242
250
|
- **`--filename_format=` must be a filename template**, not a path — separators (`/`, `\`) are rejected so a crafted template can't escape `--dir`'s scope.
|
|
243
251
|
- **`acf export/import`'s positional path** must resolve under the safe root.
|
|
252
|
+
- **`scf json export`'s `--dir=` flag** must resolve under the safe root (or pass `--stdout` for in-memory transfer). **`scf json import`'s positional `<file>` path** must resolve under the safe root.
|
|
244
253
|
- **Wrapper mode (`WP_CLI_CMD`)**: the host-derived safe root doesn't correspond to the wrapper's filesystem (e.g., container paths like `/www/app`), so `DIVIOPS_WP_CLI_SAFE_FS_ROOT` is **required** and must be set to the container-namespace path. FS-sensitive commands are rejected with a clear error if it's missing.
|
|
245
254
|
- **Escape hatch**: `DIVIOPS_WP_CLI_UNSAFE_FS=1` disables validation entirely. Appropriate for trusted single-user local-dev setups that don't want the guard.
|
|
246
255
|
|
|
@@ -248,7 +257,7 @@ The three DEFAULT-tier filesystem commands (`wp export`, `acf export <path>`, `a
|
|
|
248
257
|
|
|
249
258
|
## Safety Patterns
|
|
250
259
|
|
|
251
|
-
High-risk or bulk destructive tools follow one of two conventions to guard against unintended mutation. Both are stateless (no session tokens between calls), but they guard differently: Pattern A is a **stateless gate** — the first call mutates when the safety check passes, refuses with an explanatory error when it fires. Pattern B is **preview-before-commit** — the first call never mutates; an explicit apply step is required. Tools without a gate (e.g., `
|
|
260
|
+
High-risk or bulk destructive tools follow one of two conventions to guard against unintended mutation. Both are stateless (no session tokens between calls), but they guard differently: Pattern A is a **stateless gate** — the first call mutates when the safety check passes, refuses with an explanatory error when it fires. Pattern B is **preview-before-commit** — the first call never mutates; an explicit apply step is required. Tools without a gate (e.g., `diviops_update_page_content`) execute their mutation directly — whether to adopt a pattern is a per-tool design decision, not a retrofit requirement.
|
|
252
261
|
|
|
253
262
|
### Pattern A — `force: false/true` (refuse-with-override)
|
|
254
263
|
|
|
@@ -264,6 +273,7 @@ Tool refuses the operation with an explanatory error when a safety check fails;
|
|
|
264
273
|
| Tool | Guard | Override |
|
|
265
274
|
|------|-------|----------|
|
|
266
275
|
| `diviops_delete_variable` | HTTP 409 when live references exist | `force=true` |
|
|
276
|
+
| `diviops_preset_delete` | HTTP 409 `preset_is_default` when target is the registered default for its bucket | `force=true` (deletes + clears the `default` pointer in the same write) |
|
|
267
277
|
|
|
268
278
|
### Pattern B — `mode: "dry-run"/"apply"` (preview-then-commit)
|
|
269
279
|
|
package/dist/compatibility.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Version compatibility between MCP server and WP plugin.
|
|
3
3
|
*/
|
|
4
4
|
/** Minimum WP plugin version this server requires. */
|
|
5
|
-
export declare const MIN_PLUGIN_VERSION = "1.0.0-beta.
|
|
5
|
+
export declare const MIN_PLUGIN_VERSION = "1.0.0-beta.44";
|
|
6
6
|
/**
|
|
7
7
|
* Compare two semver-like version strings (supports pre-release tags).
|
|
8
8
|
*
|
package/dist/compatibility.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Version compatibility between MCP server and WP plugin.
|
|
3
3
|
*/
|
|
4
4
|
/** Minimum WP plugin version this server requires. */
|
|
5
|
-
export const MIN_PLUGIN_VERSION = '1.0.0-beta.
|
|
5
|
+
export const MIN_PLUGIN_VERSION = '1.0.0-beta.44';
|
|
6
6
|
/**
|
|
7
7
|
* Compare two semver-like version strings (supports pre-release tags).
|
|
8
8
|
*
|
package/dist/index.js
CHANGED
|
@@ -521,7 +521,7 @@ server.registerTool("diviops_get_section", {
|
|
|
521
521
|
};
|
|
522
522
|
});
|
|
523
523
|
server.registerTool("diviops_update_module", {
|
|
524
|
-
description: 'Update specific attributes of a module. Target by auto_index (e.g. "text:5"), admin label, or text content. Uses dot notation for attribute paths. Example: {"content.decoration.headingFont.h2.font.desktop.value.color": "#ff0000"}. Priority: auto_index > label > match_text. Use occurrence with label when duplicates exist.',
|
|
524
|
+
description: 'Update specific attributes of a module. Target by auto_index (e.g. "text:5"), admin label, or text content. Uses dot notation for attribute paths. Example: {"content.decoration.headingFont.h2.font.desktop.value.color": "#ff0000"}. For paths whose key segments contain literal dots — notably Composable Settings preset slots like groupPreset["title.decoration.spacing"] — escape the inner dots with `\\.` to keep the segment intact: {"groupPreset.title\\\\.decoration\\\\.spacing.presetId": ["uuid"]}. Priority: auto_index > label > match_text. Use occurrence with label when duplicates exist.',
|
|
525
525
|
inputSchema: {
|
|
526
526
|
page_id: z.number().describe("WordPress post/page ID"),
|
|
527
527
|
label: z
|
|
@@ -746,7 +746,7 @@ server.registerTool("diviops_create_page", {
|
|
|
746
746
|
});
|
|
747
747
|
// ── Preset Tools ────────────────────────────────────────────────────
|
|
748
748
|
server.registerTool("diviops_preset_audit", {
|
|
749
|
-
description: "Audit all Divi presets (module + group). Each entry reports `block_ref_count` (page-content refs via modulePreset / groupPreset block markup), `group_ref_count` (in-registry chain refs from other presets — module presets via top-level `groupPresets.<slot>.presetId`, group presets via `attrs.groupPreset.<slot>.presetId`), and `referenced` (true if either > 0). Group presets that are chain-referenced also expose `referenced_by_presets` (UUIDs of the presets that wire them in — typically module presets, but type-agnostic). Use this before deleting — orphan-cleanup based only on page refs would silently wipe load-bearing chain-wired group presets (font, border, box-shadow, spacing, button).",
|
|
749
|
+
description: "Audit all Divi presets (module + group). Each entry reports `block_ref_count` (page-content refs via modulePreset / groupPreset block markup), `group_ref_count` (in-registry chain refs from other presets — module presets via top-level `groupPresets.<slot>.presetId`, group presets via `attrs.groupPreset.<slot>.presetId`), and `referenced` (true if either > 0). Group presets that are chain-referenced also expose `referenced_by_presets` (UUIDs of the presets that wire them in — typically module presets, but type-agnostic). Use this before deleting — orphan-cleanup based only on page refs would silently wipe load-bearing chain-wired group presets (font, border, box-shadow, spacing, button). Also reports `orphan_default_pointers`: per-bucket `default` pointers that reference a UUID no longer present in `items[]` (caused by past unsafe deletes). Render-safe but blocks Divi's lazy recreate-on-VB-use path; clear via diviops_preset_set_default with unset=true on the affected module/group.",
|
|
750
750
|
}, async () => {
|
|
751
751
|
const result = await wp.request("/preset-audit");
|
|
752
752
|
return {
|
|
@@ -835,14 +835,21 @@ server.registerTool("diviops_preset_update", {
|
|
|
835
835
|
};
|
|
836
836
|
});
|
|
837
837
|
server.registerTool("diviops_preset_delete", {
|
|
838
|
-
description: "Delete a specific preset by ID. Use diviops_preset_audit first to verify the preset is unreferenced before deleting.",
|
|
838
|
+
description: "Delete a specific preset by ID. Use diviops_preset_audit first to verify the preset is unreferenced before deleting. Refuses with 409 preset_is_default if the target is the registered default for its module/group bucket — clear the pointer first via diviops_preset_set_default with unset=true, or pass force=true to delete and clear the pointer in one write.",
|
|
839
839
|
inputSchema: {
|
|
840
840
|
preset_id: z.string().describe("Preset ID to delete"),
|
|
841
|
+
force: z
|
|
842
|
+
.boolean()
|
|
843
|
+
.optional()
|
|
844
|
+
.describe("When true, deletes the preset even if it is the registered default and clears the default pointer in the same write. Default false (refuse-by-default)."),
|
|
841
845
|
},
|
|
842
|
-
}, async ({ preset_id }) => {
|
|
846
|
+
}, async ({ preset_id, force }) => {
|
|
847
|
+
const body = { preset_id };
|
|
848
|
+
if (force !== undefined)
|
|
849
|
+
body.force = force;
|
|
843
850
|
const result = await wp.request("/preset-delete", {
|
|
844
851
|
method: "POST",
|
|
845
|
-
body
|
|
852
|
+
body,
|
|
846
853
|
});
|
|
847
854
|
return {
|
|
848
855
|
content: [
|
|
@@ -969,18 +976,33 @@ server.registerTool("diviops_preset_scan_orphans", {
|
|
|
969
976
|
};
|
|
970
977
|
});
|
|
971
978
|
server.registerTool("diviops_preset_set_default", {
|
|
972
|
-
description: "Set or clear the per-module/group default preset.
|
|
979
|
+
description: "Set or clear the per-module/group default preset. Two addressing modes: (1) preset_id mode — walks both buckets to locate the preset by UUID, then points the containing module/group's `default` slot at it (or clears it with unset=true). (2) Bucket-addressed clear — pass type + module + unset=true to clear an orphan default pointer when the preset_id no longer exists in items[] (the preset_id walk path can't locate orphans — that's the very state being repaired; surfaced via diviops_preset_audit's `orphan_default_pointers`). Defaults apply to NEW module instances only — existing modules keep their current preset bindings (use diviops_preset_reassign for retroactive swaps). Use diviops_preset_audit's `is_default` and `orphan_default_pointers` fields to verify state before/after.",
|
|
973
980
|
inputSchema: {
|
|
974
981
|
preset_id: z
|
|
975
982
|
.string()
|
|
976
|
-
.
|
|
983
|
+
.optional()
|
|
984
|
+
.describe("Preset UUID. Bucket (module vs. group) and target module/group are auto-resolved from the registry — no need to specify them. Required unless using bucket-addressed clear (type + module + unset=true) to repair an orphan default pointer."),
|
|
985
|
+
type: z
|
|
986
|
+
.enum(["module", "group"])
|
|
987
|
+
.optional()
|
|
988
|
+
.describe("Bucket-addressed clear: bucket type. Required together with `module` and `unset=true` to clear an orphan default pointer (UUID gone from items[] but `default` still references it)."),
|
|
989
|
+
module: z
|
|
990
|
+
.string()
|
|
991
|
+
.optional()
|
|
992
|
+
.describe('Bucket-addressed clear: module slug or group key (e.g. "divi/blurb", "divi/font"). Required together with `type` and `unset=true`.'),
|
|
977
993
|
unset: z
|
|
978
994
|
.boolean()
|
|
979
995
|
.optional()
|
|
980
|
-
.describe("If true, clear the default pointer
|
|
996
|
+
.describe("If true, clear the default pointer. With preset_id, clears the bucket containing that preset. With type+module, clears that bucket directly (use this form for orphan-pointer repair). Defaults to false (set the preset as the default — preset_id required)."),
|
|
981
997
|
},
|
|
982
|
-
}, async ({ preset_id, unset }) => {
|
|
983
|
-
const body = {
|
|
998
|
+
}, async ({ preset_id, type, module, unset }) => {
|
|
999
|
+
const body = {};
|
|
1000
|
+
if (preset_id !== undefined)
|
|
1001
|
+
body.preset_id = preset_id;
|
|
1002
|
+
if (type !== undefined)
|
|
1003
|
+
body.type = type;
|
|
1004
|
+
if (module !== undefined)
|
|
1005
|
+
body.module = module;
|
|
984
1006
|
if (unset)
|
|
985
1007
|
body.unset = true;
|
|
986
1008
|
const result = await wp.request("/preset-set-default", {
|
|
@@ -1300,7 +1322,7 @@ server.registerTool("diviops_delete_canvas", {
|
|
|
1300
1322
|
});
|
|
1301
1323
|
// ── WP-CLI ──────────────────────────────────────────────────────────
|
|
1302
1324
|
server.registerTool("diviops_wp_cli", {
|
|
1303
|
-
description: "Run a WP-CLI command on the WordPress site. Requires WP_PATH env var (LOCAL_SITE_ID auto-detected from Local by Flywheel), or WP_CLI_CMD for containerized wrappers. Commands validated against a safety allowlist. Default tier covers read ops across options/posts/post-types/taxonomies/users/info/core/db, non-destructive writes (post/term create+update, post meta read/write, cache/rewrite/transient flush), ACF schema ops (export/import/list/get
|
|
1325
|
+
description: "Run a WP-CLI command on the WordPress site. Requires WP_PATH env var (LOCAL_SITE_ID auto-detected from Local by Flywheel), or WP_CLI_CMD for containerized wrappers. Commands validated against a safety allowlist. Default tier covers read ops across options/posts/post-types/taxonomies/users/info/core/db, non-destructive writes (post/term create+update, post meta read/write, cache/rewrite/transient flush), ACF/SCF schema ops (`acf export/import/field-group list/get` plus SCF 6.8.4+ `scf json {status,sync,import,export}` and the `acf json …` aliases), and WXR export. Extended tier (requires DIVIOPS_WP_CLI_ALLOW env var) adds destructive or bulk-modifying ops: option update, post/post meta/term delete, search-replace, import, plugin activate/deactivate, eval-file. Filesystem-touching commands (`wp export`, `acf export/import`, `scf|acf json export/import`) are additionally constrained: path arguments must resolve under a safe root (defaults to `<WP_PATH>/.diviops-tmp/`, overridable via DIVIOPS_WP_CLI_SAFE_FS_ROOT, disable via DIVIOPS_WP_CLI_UNSAFE_FS=1); `wp export` and `scf json export` require an explicit `--dir=<path>` (or `--stdout`). In WP_CLI_CMD wrapper mode, DIVIOPS_WP_CLI_SAFE_FS_ROOT is required for FS-sensitive commands. Prefer the typed `diviops_scf_*` wrappers for SCF round-trips — they're easier to invoke and accept the same safe-root scoping. Use --format=json for structured output. Full allowlist + tier rationale + filesystem semantics in the MCP server README.",
|
|
1304
1326
|
inputSchema: {
|
|
1305
1327
|
command: z
|
|
1306
1328
|
.string()
|
|
@@ -1323,6 +1345,275 @@ server.registerTool("diviops_wp_cli", {
|
|
|
1323
1345
|
: `Error: ${result.error}\n${result.output}`;
|
|
1324
1346
|
return { content: [{ type: "text", text: output }] };
|
|
1325
1347
|
});
|
|
1348
|
+
// ── SCF (Secure Custom Fields / ACF) wrappers ───────────────────────
|
|
1349
|
+
//
|
|
1350
|
+
// Typed wrappers over SCF 6.8.4+'s `wp scf json {status,sync,import,export}`
|
|
1351
|
+
// CLI family (also reachable as `wp acf json …`). The plugin file at
|
|
1352
|
+
// wp-content/plugins/secure-custom-fields/src/CLI/JsonCommand.php is the
|
|
1353
|
+
// upstream source of truth for flag shapes — keep these wrappers aligned.
|
|
1354
|
+
function ensureWpCli() {
|
|
1355
|
+
if (!wpCli) {
|
|
1356
|
+
return {
|
|
1357
|
+
ok: false,
|
|
1358
|
+
text: "WP-CLI not configured. Set WP_PATH (Local by Flywheel auto-detect) " +
|
|
1359
|
+
"or WP_CLI_CMD (containerized wrappers) to enable SCF round-trip tools.",
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
return { ok: true };
|
|
1363
|
+
}
|
|
1364
|
+
function pushScfFlag(args, name, value) {
|
|
1365
|
+
if (!value)
|
|
1366
|
+
return;
|
|
1367
|
+
// Each `--name=value` becomes a single argv entry — execFile handles spaces
|
|
1368
|
+
// and quotes inside the value transparently. No string concatenation, no
|
|
1369
|
+
// parseCommand round-trip, so values like "Bob's Group" or filenames with
|
|
1370
|
+
// spaces flow through verbatim.
|
|
1371
|
+
args.push(`--${name}=${value}`);
|
|
1372
|
+
}
|
|
1373
|
+
server.registerTool("diviops_scf_status", {
|
|
1374
|
+
description: "Show SCF (Secure Custom Fields) sync status — how many field groups, post types, taxonomies, and options pages have JSON-on-disk newer than the database (or absent from DB). Read-only. Wraps `wp scf json status`. Requires SCF 6.8.4+ and WP_PATH or WP_CLI_CMD.",
|
|
1375
|
+
inputSchema: {
|
|
1376
|
+
type: z
|
|
1377
|
+
.enum(["field-group", "post-type", "taxonomy", "options-page"])
|
|
1378
|
+
.optional()
|
|
1379
|
+
.describe("Limit to a single item type. Defaults to all types. options-page requires ACF PRO."),
|
|
1380
|
+
detailed: z
|
|
1381
|
+
.boolean()
|
|
1382
|
+
.optional()
|
|
1383
|
+
.describe("List the individual pending items (key/title/type/action) instead of just counts."),
|
|
1384
|
+
},
|
|
1385
|
+
}, async ({ type, detailed }) => {
|
|
1386
|
+
const gate = ensureWpCli();
|
|
1387
|
+
if (!gate.ok) {
|
|
1388
|
+
return { content: [{ type: "text", text: gate.text }] };
|
|
1389
|
+
}
|
|
1390
|
+
const args = ["scf", "json", "status", "--format=json"];
|
|
1391
|
+
pushScfFlag(args, "type", type);
|
|
1392
|
+
if (detailed)
|
|
1393
|
+
args.push("--detailed");
|
|
1394
|
+
const result = await wpCli.runArgs(args);
|
|
1395
|
+
const output = result.success
|
|
1396
|
+
? result.output
|
|
1397
|
+
: `Error: ${result.error}\n${result.output}`;
|
|
1398
|
+
return { content: [{ type: "text", text: output }] };
|
|
1399
|
+
});
|
|
1400
|
+
server.registerTool("diviops_scf_export", {
|
|
1401
|
+
description: "Export SCF field groups, post types, taxonomies, and options pages as JSON — to a directory under the safe-root (`<WP_PATH>/.diviops-tmp/` by default, override via DIVIOPS_WP_CLI_SAFE_FS_ROOT) or to stdout. Wraps `wp scf json export`. Either `dir` or `stdout: true` is required. Filters can be combined; without filters, all items are exported. Note: SCF writes a fixed filename `acf-export-YYYY-MM-DD.json` inside `dir` — two exports on the same day silently overwrite. Copy/rename if you're archiving baselines.",
|
|
1402
|
+
inputSchema: {
|
|
1403
|
+
dir: z
|
|
1404
|
+
.string()
|
|
1405
|
+
.optional()
|
|
1406
|
+
.describe("Absolute output directory under the WP-CLI safe-root. Mutually exclusive with `stdout`. SCF writes a single `acf-export-YYYY-MM-DD.json` file inside this dir."),
|
|
1407
|
+
stdout: z
|
|
1408
|
+
.boolean()
|
|
1409
|
+
.optional()
|
|
1410
|
+
.describe("Print JSON to stdout instead of writing a file. Mutually exclusive with `dir`."),
|
|
1411
|
+
field_groups: z
|
|
1412
|
+
.string()
|
|
1413
|
+
.optional()
|
|
1414
|
+
.describe("Comma-separated field-group ACF keys (`group_abc123`) or admin titles (`My Field Group`). NOT WP post slugs — SCF matches against the def's `key` field or its `title` (case-insensitive). Use `diviops_scf_list_field_groups` to discover keys (post_name column)."),
|
|
1415
|
+
post_types: z
|
|
1416
|
+
.string()
|
|
1417
|
+
.optional()
|
|
1418
|
+
.describe("Comma-separated SCF post-type def keys (`post_type_xxx`) or admin titles (`Programm`). IMPORTANT: this is the SCF def's identifier, NOT the registered post-type slug (`event`, `book`). The registered slug is what `wp post list` and REST URLs use, but SCF's filter matches against the def's `key` field or its `title`. To discover def keys, run `diviops_scf_export --stdout` (no filter) and inspect the top-level entries with `parent='post-type'`."),
|
|
1419
|
+
taxonomies: z
|
|
1420
|
+
.string()
|
|
1421
|
+
.optional()
|
|
1422
|
+
.describe("Comma-separated SCF taxonomy def keys (`taxonomy_xxx`) or admin titles. Same caveat as `post_types`: NOT the registered taxonomy slug — the SCF def's `key` or `title`. Discover via `diviops_scf_export --stdout`."),
|
|
1423
|
+
options_pages: z
|
|
1424
|
+
.string()
|
|
1425
|
+
.optional()
|
|
1426
|
+
.describe("Comma-separated options-page def keys or admin titles. Requires ACF PRO."),
|
|
1427
|
+
},
|
|
1428
|
+
}, async ({ dir, stdout, field_groups, post_types, taxonomies, options_pages }) => {
|
|
1429
|
+
const gate = ensureWpCli();
|
|
1430
|
+
if (!gate.ok) {
|
|
1431
|
+
return { content: [{ type: "text", text: gate.text }] };
|
|
1432
|
+
}
|
|
1433
|
+
if (!dir && !stdout) {
|
|
1434
|
+
return {
|
|
1435
|
+
content: [
|
|
1436
|
+
{
|
|
1437
|
+
type: "text",
|
|
1438
|
+
text: "Error: pass either `dir` (absolute path under DIVIOPS_WP_CLI_SAFE_FS_ROOT) or `stdout: true`.",
|
|
1439
|
+
},
|
|
1440
|
+
],
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
if (dir && stdout) {
|
|
1444
|
+
return {
|
|
1445
|
+
content: [
|
|
1446
|
+
{
|
|
1447
|
+
type: "text",
|
|
1448
|
+
text: "Error: `dir` and `stdout` are mutually exclusive — pick one.",
|
|
1449
|
+
},
|
|
1450
|
+
],
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
const args = ["scf", "json", "export"];
|
|
1454
|
+
if (stdout)
|
|
1455
|
+
args.push("--stdout");
|
|
1456
|
+
pushScfFlag(args, "dir", dir);
|
|
1457
|
+
pushScfFlag(args, "field-groups", field_groups);
|
|
1458
|
+
pushScfFlag(args, "post-types", post_types);
|
|
1459
|
+
pushScfFlag(args, "taxonomies", taxonomies);
|
|
1460
|
+
pushScfFlag(args, "options-pages", options_pages);
|
|
1461
|
+
const result = await wpCli.runArgs(args);
|
|
1462
|
+
const output = result.success
|
|
1463
|
+
? result.output
|
|
1464
|
+
: `Error: ${result.error}\n${result.output}`;
|
|
1465
|
+
return { content: [{ type: "text", text: output }] };
|
|
1466
|
+
});
|
|
1467
|
+
server.registerTool("diviops_scf_import", {
|
|
1468
|
+
description: "Import SCF field groups, post types, taxonomies, options pages from a JSON file. Mutates the database. File path must resolve under the safe-root (`<WP_PATH>/.diviops-tmp/` by default, override via DIVIOPS_WP_CLI_SAFE_FS_ROOT). Idempotent — existing items with matching keys are updated. Wraps `wp scf json import <file>`.",
|
|
1469
|
+
inputSchema: {
|
|
1470
|
+
file: z
|
|
1471
|
+
.string()
|
|
1472
|
+
.describe("Absolute path to the .json file to import. Must resolve under DIVIOPS_WP_CLI_SAFE_FS_ROOT."),
|
|
1473
|
+
},
|
|
1474
|
+
}, async ({ file }) => {
|
|
1475
|
+
const gate = ensureWpCli();
|
|
1476
|
+
if (!gate.ok) {
|
|
1477
|
+
return { content: [{ type: "text", text: gate.text }] };
|
|
1478
|
+
}
|
|
1479
|
+
const result = await wpCli.runArgs(["scf", "json", "import", file]);
|
|
1480
|
+
const output = result.success
|
|
1481
|
+
? result.output
|
|
1482
|
+
: `Error: ${result.error}\n${result.output}`;
|
|
1483
|
+
return { content: [{ type: "text", text: output }] };
|
|
1484
|
+
});
|
|
1485
|
+
server.registerTool("diviops_scf_sync", {
|
|
1486
|
+
description: "Apply pending JSON-on-disk SCF changes to the database. Reads JSON files from the theme/plugin acf-json directory and creates/updates DB entries. Defaults to `dry_run: true` for safety — caller must opt in to mutation. Wraps `wp scf json sync`.",
|
|
1487
|
+
inputSchema: {
|
|
1488
|
+
type: z
|
|
1489
|
+
.enum(["field-group", "post-type", "taxonomy", "options-page"])
|
|
1490
|
+
.optional()
|
|
1491
|
+
.describe("Limit sync to a single item type."),
|
|
1492
|
+
key: z
|
|
1493
|
+
.string()
|
|
1494
|
+
.optional()
|
|
1495
|
+
.describe("Sync only the item with this ACF key (e.g. `group_abc123`)."),
|
|
1496
|
+
dry_run: z
|
|
1497
|
+
.boolean()
|
|
1498
|
+
.optional()
|
|
1499
|
+
.default(true)
|
|
1500
|
+
.describe("Preview pending changes without mutating the database. Defaults to true. Pass `false` to commit."),
|
|
1501
|
+
},
|
|
1502
|
+
}, async ({ type, key, dry_run }) => {
|
|
1503
|
+
const gate = ensureWpCli();
|
|
1504
|
+
if (!gate.ok) {
|
|
1505
|
+
return { content: [{ type: "text", text: gate.text }] };
|
|
1506
|
+
}
|
|
1507
|
+
const args = ["scf", "json", "sync"];
|
|
1508
|
+
pushScfFlag(args, "type", type);
|
|
1509
|
+
pushScfFlag(args, "key", key);
|
|
1510
|
+
if (dry_run !== false)
|
|
1511
|
+
args.push("--dry-run");
|
|
1512
|
+
const result = await wpCli.runArgs(args);
|
|
1513
|
+
const output = result.success
|
|
1514
|
+
? result.output
|
|
1515
|
+
: `Error: ${result.error}\n${result.output}`;
|
|
1516
|
+
return { content: [{ type: "text", text: output }] };
|
|
1517
|
+
});
|
|
1518
|
+
server.registerTool("diviops_scf_list_field_groups", {
|
|
1519
|
+
description: "List all SCF/ACF field groups in the database (post_name = ACF key, post_title, post_status, post_modified). Read-only. Queries the underlying `acf-field-group` post type via `wp post list` — works on both SCF 6.8.4+ (which dropped the legacy `wp acf field-group …` family in favor of the `wp scf json` namespace) and older ACF installs.",
|
|
1520
|
+
}, async () => {
|
|
1521
|
+
const gate = ensureWpCli();
|
|
1522
|
+
if (!gate.ok) {
|
|
1523
|
+
return { content: [{ type: "text", text: gate.text }] };
|
|
1524
|
+
}
|
|
1525
|
+
const result = await wpCli.runArgs([
|
|
1526
|
+
"post",
|
|
1527
|
+
"list",
|
|
1528
|
+
"--post_type=acf-field-group",
|
|
1529
|
+
"--post_status=any",
|
|
1530
|
+
"--fields=ID,post_name,post_title,post_status,post_modified",
|
|
1531
|
+
"--format=json",
|
|
1532
|
+
]);
|
|
1533
|
+
const output = result.success
|
|
1534
|
+
? result.output
|
|
1535
|
+
: `Error: ${result.error}\n${result.output}`;
|
|
1536
|
+
return { content: [{ type: "text", text: output }] };
|
|
1537
|
+
});
|
|
1538
|
+
server.registerTool("diviops_scf_get_field_group", {
|
|
1539
|
+
description: "Fetch a single SCF/ACF field group from the `acf-field-group` post type — by ACF key (`group_abc123`, looked up via `post_name`) or by numeric WP post ID. Returns the WP post fields (post_name, post_title, post_content with serialized fields blob, post_status, post_modified). For the parsed/structured field tree including nested fields, use `diviops_scf_export --field-groups=<key> --stdout` instead. Read-only. SCF 6.8.4 dropped the legacy `wp acf field-group get` command, so this wrapper queries the post type directly via `wp post`.",
|
|
1540
|
+
inputSchema: {
|
|
1541
|
+
key: z
|
|
1542
|
+
.string()
|
|
1543
|
+
.describe("ACF field-group key (`group_abc123`, matched against post_name) or numeric WP post ID."),
|
|
1544
|
+
},
|
|
1545
|
+
}, async ({ key }) => {
|
|
1546
|
+
const gate = ensureWpCli();
|
|
1547
|
+
if (!gate.ok) {
|
|
1548
|
+
return { content: [{ type: "text", text: gate.text }] };
|
|
1549
|
+
}
|
|
1550
|
+
// If the input looks like a numeric ID, hand it to `wp post get` directly.
|
|
1551
|
+
// Otherwise treat it as an ACF key and resolve via post_name first.
|
|
1552
|
+
const isNumericId = /^\d+$/.test(key);
|
|
1553
|
+
if (isNumericId) {
|
|
1554
|
+
const result = await wpCli.runArgs([
|
|
1555
|
+
"post",
|
|
1556
|
+
"get",
|
|
1557
|
+
key,
|
|
1558
|
+
"--format=json",
|
|
1559
|
+
]);
|
|
1560
|
+
const output = result.success
|
|
1561
|
+
? result.output
|
|
1562
|
+
: `Error: ${result.error}\n${result.output}`;
|
|
1563
|
+
return { content: [{ type: "text", text: output }] };
|
|
1564
|
+
}
|
|
1565
|
+
// Resolve ACF key → post ID via `wp post list --name=<key>`. Single-row
|
|
1566
|
+
// lookup; returns [] if the key isn't found.
|
|
1567
|
+
const lookup = await wpCli.runArgs([
|
|
1568
|
+
"post",
|
|
1569
|
+
"list",
|
|
1570
|
+
"--post_type=acf-field-group",
|
|
1571
|
+
"--post_status=any",
|
|
1572
|
+
`--name=${key}`,
|
|
1573
|
+
"--fields=ID",
|
|
1574
|
+
"--format=json",
|
|
1575
|
+
]);
|
|
1576
|
+
if (!lookup.success) {
|
|
1577
|
+
return {
|
|
1578
|
+
content: [
|
|
1579
|
+
{
|
|
1580
|
+
type: "text",
|
|
1581
|
+
text: `Error looking up field-group key "${key}": ${lookup.error}\n${lookup.output}`,
|
|
1582
|
+
},
|
|
1583
|
+
],
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
let postId = null;
|
|
1587
|
+
try {
|
|
1588
|
+
const rows = JSON.parse(lookup.output);
|
|
1589
|
+
if (Array.isArray(rows) && rows.length > 0) {
|
|
1590
|
+
postId = String(rows[0].ID);
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
catch {
|
|
1594
|
+
// Fall through — postId stays null, return a clear "not found" error.
|
|
1595
|
+
}
|
|
1596
|
+
if (!postId) {
|
|
1597
|
+
return {
|
|
1598
|
+
content: [
|
|
1599
|
+
{
|
|
1600
|
+
type: "text",
|
|
1601
|
+
text: `No field-group found for key "${key}". Use diviops_scf_list_field_groups to see available keys (post_name field).`,
|
|
1602
|
+
},
|
|
1603
|
+
],
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
const result = await wpCli.runArgs([
|
|
1607
|
+
"post",
|
|
1608
|
+
"get",
|
|
1609
|
+
postId,
|
|
1610
|
+
"--format=json",
|
|
1611
|
+
]);
|
|
1612
|
+
const output = result.success
|
|
1613
|
+
? result.output
|
|
1614
|
+
: `Error: ${result.error}\n${result.output}`;
|
|
1615
|
+
return { content: [{ type: "text", text: output }] };
|
|
1616
|
+
});
|
|
1326
1617
|
// ── Connection ──────────────────────────────────────────────────────
|
|
1327
1618
|
server.registerTool("diviops_test_connection", {
|
|
1328
1619
|
description: "Test the connection to the WordPress site and verify the Divi MCP plugin is active.",
|
|
@@ -1590,6 +1881,174 @@ server.registerTool("diviops_create_variable", {
|
|
|
1590
1881
|
],
|
|
1591
1882
|
};
|
|
1592
1883
|
});
|
|
1884
|
+
server.registerTool("diviops_create_fluid_system", {
|
|
1885
|
+
description: "Batch-emit a fluid typography + spacing + radius variable set in one call — mirrors Divi 5.4.0's Variable Generator Modal at the algorithm level (clamp() math is identical to diviops_create_variable's fluid mode) but layers profile-selectable anchors over it. Each category is independent and optional. Use for: (1) bootstrapping a design system in one call instead of 20+ individual diviops_create_variable invocations; (2) mirroring ET's variable layout so your tokens coexist with VB-generated ones in the Variable Manager; (3) deterministic preflight via dry_run before committing the registry change. By default, refuses to overwrite existing IDs (returns them in `skipped`) — pass overwrite=true to update in place. Persists in a single atomic write to the variable registry; mid-batch failures roll back cleanly.",
|
|
1886
|
+
inputSchema: {
|
|
1887
|
+
profile: z
|
|
1888
|
+
.enum(["divi-default", "wide", "custom"])
|
|
1889
|
+
.optional()
|
|
1890
|
+
.default("divi-default")
|
|
1891
|
+
.describe('Anchor preset for the underlying clamp() math. "divi-default" (360→1350) matches Divi 5.4.0\'s Variable Generator Modal defaults; "wide" (320→1920) covers a wider device span (the diviops convention); "custom" requires custom_anchors. Affects ALL three categories uniformly.'),
|
|
1892
|
+
custom_anchors: z
|
|
1893
|
+
.object({
|
|
1894
|
+
min_viewport_px: z.number().positive(),
|
|
1895
|
+
max_viewport_px: z.number().positive(),
|
|
1896
|
+
})
|
|
1897
|
+
.refine((a) => a.max_viewport_px > a.min_viewport_px, {
|
|
1898
|
+
message: "custom_anchors.max_viewport_px must be > min_viewport_px",
|
|
1899
|
+
})
|
|
1900
|
+
.optional()
|
|
1901
|
+
.describe('Required when profile="custom". Defines the (min_viewport_px, max_viewport_px) pair the clamp() formulas anchor to. max must be > min. (The profile/custom_anchors pairing is also enforced server-side, returning 400 invalid_profile if profile="custom" is sent without custom_anchors.)'),
|
|
1902
|
+
typography: z
|
|
1903
|
+
.object({
|
|
1904
|
+
base_px: z
|
|
1905
|
+
.number()
|
|
1906
|
+
.positive()
|
|
1907
|
+
.describe("Base body size in px. Step N's value = base_px × ratio^(steps-1). h1 = largest (top of chain), hN = base."),
|
|
1908
|
+
ratio: z
|
|
1909
|
+
.union([
|
|
1910
|
+
z.number().positive(),
|
|
1911
|
+
z.enum([
|
|
1912
|
+
"minor-second",
|
|
1913
|
+
"major-second",
|
|
1914
|
+
"minor-third",
|
|
1915
|
+
"major-third",
|
|
1916
|
+
"perfect-fourth",
|
|
1917
|
+
"augmented-fourth",
|
|
1918
|
+
"perfect-fifth",
|
|
1919
|
+
"golden",
|
|
1920
|
+
]),
|
|
1921
|
+
])
|
|
1922
|
+
.describe("Modular-scale ratio. Pass a named scale ('major-third'=1.25, 'perfect-fifth'=1.5, 'golden'=1.618, etc.) or a raw number. Step N is base × ratio^(steps-N), so h1 (step 1) is the largest size when steps>1."),
|
|
1923
|
+
steps: z
|
|
1924
|
+
.number()
|
|
1925
|
+
.int()
|
|
1926
|
+
.min(1)
|
|
1927
|
+
.max(20)
|
|
1928
|
+
.describe("Number of typography steps to emit (e.g. 6 = h1..h6). Cap is 20 to prevent runaway scale chains."),
|
|
1929
|
+
max_ratio: z
|
|
1930
|
+
.union([
|
|
1931
|
+
z.number().positive(),
|
|
1932
|
+
z.enum([
|
|
1933
|
+
"minor-second",
|
|
1934
|
+
"major-second",
|
|
1935
|
+
"minor-third",
|
|
1936
|
+
"major-third",
|
|
1937
|
+
"perfect-fourth",
|
|
1938
|
+
"augmented-fourth",
|
|
1939
|
+
"perfect-fifth",
|
|
1940
|
+
"golden",
|
|
1941
|
+
]),
|
|
1942
|
+
])
|
|
1943
|
+
.optional()
|
|
1944
|
+
.describe("Optional ratio at max viewport. Defaults to ratio (same chain at both anchors). Pass a larger value (e.g. ratio=1.2 + max_ratio=1.333) for a more dramatic scale on large screens."),
|
|
1945
|
+
fluid_growth: z
|
|
1946
|
+
.number()
|
|
1947
|
+
.positive()
|
|
1948
|
+
.optional()
|
|
1949
|
+
.describe("Multiplicative growth factor at max viewport. Default 1.0 = discrete (each step emits a fixed value, no clamp growth). Common values: 1.2-1.5 for moderate fluid scaling. Step N's clamp goes from `base × ratio^(steps-N)` at min_viewport to `base × max_ratio^(steps-N) × fluid_growth` at max_viewport."),
|
|
1950
|
+
name_prefix: z
|
|
1951
|
+
.string()
|
|
1952
|
+
.optional()
|
|
1953
|
+
.describe("ID prefix per step. Default 'h' → IDs become gvid-{namespace}-size-h1..hN. Pass 'display' for hero sizes ('gvid-{namespace}-size-display1..')."),
|
|
1954
|
+
})
|
|
1955
|
+
.optional(),
|
|
1956
|
+
spacing: z
|
|
1957
|
+
.object({
|
|
1958
|
+
min_px: z.number().min(0),
|
|
1959
|
+
max_px: z.number().positive(),
|
|
1960
|
+
steps: z.number().int().min(1).max(30),
|
|
1961
|
+
scale: z
|
|
1962
|
+
.enum(["linear", "geometric"])
|
|
1963
|
+
.optional()
|
|
1964
|
+
.default("linear")
|
|
1965
|
+
.describe("Distribution between min_px and max_px. 'linear' = equal arithmetic spacing (best for spacing scales). 'geometric' = equal multiplicative spacing (best for typography-like scales). geometric requires min_px > 0."),
|
|
1966
|
+
fluid_growth: z
|
|
1967
|
+
.number()
|
|
1968
|
+
.positive()
|
|
1969
|
+
.optional()
|
|
1970
|
+
.describe("Multiplicative growth factor at max viewport. Default 1.0 = discrete (each spacing token is constant across viewports — typical design-system behavior). > 1.0 = fluid (each token scales from `value` at min_viewport to `value × fluid_growth` at max_viewport)."),
|
|
1971
|
+
name_prefix: z
|
|
1972
|
+
.string()
|
|
1973
|
+
.optional()
|
|
1974
|
+
.describe("ID prefix. Default 'space' → gvid-{namespace}-space-1..N."),
|
|
1975
|
+
})
|
|
1976
|
+
.optional(),
|
|
1977
|
+
radius: z
|
|
1978
|
+
.object({
|
|
1979
|
+
min_px: z.number().min(0),
|
|
1980
|
+
max_px: z.number().positive(),
|
|
1981
|
+
steps: z.number().int().min(1).max(30),
|
|
1982
|
+
scale: z.enum(["linear", "geometric"]).optional().default("linear"),
|
|
1983
|
+
fluid_growth: z
|
|
1984
|
+
.number()
|
|
1985
|
+
.positive()
|
|
1986
|
+
.optional()
|
|
1987
|
+
.describe("Multiplicative growth factor at max viewport. Default 1.0 = discrete. Most radius tokens stay discrete; pass > 1.0 only when you want corners to grow with viewport."),
|
|
1988
|
+
name_prefix: z
|
|
1989
|
+
.string()
|
|
1990
|
+
.optional()
|
|
1991
|
+
.describe("ID prefix. Default 'rounded' → gvid-{namespace}-rounded-1..N."),
|
|
1992
|
+
})
|
|
1993
|
+
.optional(),
|
|
1994
|
+
namespace: z
|
|
1995
|
+
.string()
|
|
1996
|
+
.regex(/^[a-z0-9_-]+$/i, {
|
|
1997
|
+
message: "namespace must match [a-z0-9_-]+ (case-insensitive; lowercased server-side). Inputs outside this charset are rejected explicitly rather than silently rewritten — passing 'o a' or 'oa!' would alias onto the default 'oa' namespace and risk overwriting unrelated tokens.",
|
|
1998
|
+
})
|
|
1999
|
+
.optional()
|
|
2000
|
+
.default("oa")
|
|
2001
|
+
.describe("Namespace inserted into every generated ID (gvid-{namespace}-*). Default 'oa' matches existing diviops convention. Validated against [a-z0-9_-]+ on both client and server (rejects rather than sanitizes — see message for rationale)."),
|
|
2002
|
+
output_unit: z
|
|
2003
|
+
.enum(["rem", "px"])
|
|
2004
|
+
.optional()
|
|
2005
|
+
.describe('Unit for emitted clamp() formulas. Defaults to "px" (root-agnostic, safe). Pass "rem" to opt into rem emission (bakes the 1rem=16px assumption unless root_font_size_px is also passed).'),
|
|
2006
|
+
root_font_size_px: z
|
|
2007
|
+
.number()
|
|
2008
|
+
.positive()
|
|
2009
|
+
.optional()
|
|
2010
|
+
.describe("Site's actual root font-size in px. Pass for non-16px-root sites (e.g. 10 for `html { font-size: 62.5% }`). Passing this alone implies output_unit='rem'."),
|
|
2011
|
+
dry_run: z
|
|
2012
|
+
.boolean()
|
|
2013
|
+
.optional()
|
|
2014
|
+
.default(false)
|
|
2015
|
+
.describe("Preview the full plan without persisting. Returns identical `created`/`skipped` shape so callers can audit IDs and clamp() values before committing."),
|
|
2016
|
+
overwrite: z
|
|
2017
|
+
.boolean()
|
|
2018
|
+
.optional()
|
|
2019
|
+
.default(false)
|
|
2020
|
+
.describe("When false (default), existing IDs land in `skipped` with the existing value. When true, each existing ID is updated in place (label + value rewritten, order preserved)."),
|
|
2021
|
+
},
|
|
2022
|
+
}, async ({ profile, custom_anchors, typography, spacing, radius, namespace, output_unit, root_font_size_px, dry_run, overwrite, }) => {
|
|
2023
|
+
const body = { profile };
|
|
2024
|
+
if (custom_anchors !== undefined)
|
|
2025
|
+
body.custom_anchors = custom_anchors;
|
|
2026
|
+
if (typography !== undefined)
|
|
2027
|
+
body.typography = typography;
|
|
2028
|
+
if (spacing !== undefined)
|
|
2029
|
+
body.spacing = spacing;
|
|
2030
|
+
if (radius !== undefined)
|
|
2031
|
+
body.radius = radius;
|
|
2032
|
+
if (namespace !== undefined)
|
|
2033
|
+
body.namespace = namespace;
|
|
2034
|
+
if (output_unit !== undefined)
|
|
2035
|
+
body.output_unit = output_unit;
|
|
2036
|
+
if (root_font_size_px !== undefined)
|
|
2037
|
+
body.root_font_size_px = root_font_size_px;
|
|
2038
|
+
if (dry_run !== undefined)
|
|
2039
|
+
body.dry_run = dry_run;
|
|
2040
|
+
if (overwrite !== undefined)
|
|
2041
|
+
body.overwrite = overwrite;
|
|
2042
|
+
const result = await wp.request("/variables-create-fluid-system", {
|
|
2043
|
+
method: "POST",
|
|
2044
|
+
body,
|
|
2045
|
+
});
|
|
2046
|
+
return {
|
|
2047
|
+
content: [
|
|
2048
|
+
{ type: "text", text: JSON.stringify(result) },
|
|
2049
|
+
],
|
|
2050
|
+
};
|
|
2051
|
+
});
|
|
1593
2052
|
server.registerTool("diviops_delete_variable", {
|
|
1594
2053
|
description: "Delete a design token variable by ID. Auto-detects storage from ID prefix (gcid-* = colors, gvid-* = numbers/strings/etc). Returns HTTP 409 when live references exist unless force=true — run diviops_variables_scan_orphans to see where the references live. Returns HTTP 403 for Divi's customizer-bound defaults (gcid-primary-color, gcid-secondary-color, gcid-heading-color, gcid-body-color, gcid-link-color); those are managed via WP Customizer theme options and can't be deleted via this tool.",
|
|
1595
2054
|
inputSchema: {
|
|
@@ -1623,6 +2082,23 @@ server.registerTool("diviops_variables_scan_orphans", {
|
|
|
1623
2082
|
],
|
|
1624
2083
|
};
|
|
1625
2084
|
});
|
|
2085
|
+
server.registerTool("diviops_variables_used_on_page", {
|
|
2086
|
+
description: "Detect which numeric/font variable IDs a single page actually emits — the exact set Divi 5.4.0+ uses to scope selective `:root{--gvid-*}` CSS variable emission. Walks the same content stack the frontend assembles: post_content + active Theme Builder header/body/footer template content + appended canvas content (interaction targets etc.), plus presets referenced by that content. NOTE: this is `gvid-*` only — color variables (`gcid-*`) are emitted via a separate path (`GlobalData` color block) that is NOT scoped per-page in 5.4.0; this tool returns gvid IDs only. Use for per-page orphan validation (complements global diviops_variables_scan_orphans), preflight before bulk variable rename (know which pages are affected), or to debug why a numeric/font variable doesn't render on a specific page. Read-only. Returns variable_ids (sorted, deduped), count, and the tb_template_ids resolved for that post.",
|
|
2087
|
+
inputSchema: {
|
|
2088
|
+
post_id: z
|
|
2089
|
+
.number()
|
|
2090
|
+
.int()
|
|
2091
|
+
.positive()
|
|
2092
|
+
.describe("WordPress post/page ID. The page does not need to be Divi-built — TB templates and canvases attached to non-Divi posts are still scanned."),
|
|
2093
|
+
},
|
|
2094
|
+
}, async ({ post_id }) => {
|
|
2095
|
+
const result = await wp.request(`/variables-used-on-page/${post_id}`);
|
|
2096
|
+
return {
|
|
2097
|
+
content: [
|
|
2098
|
+
{ type: "text", text: JSON.stringify(result) },
|
|
2099
|
+
],
|
|
2100
|
+
};
|
|
2101
|
+
});
|
|
1626
2102
|
server.registerTool("diviops_flush_static_cache", {
|
|
1627
2103
|
description: "Flush Divi's compiled static CSS cache under wp-content/et-cache/. wp cache flush does NOT touch these files — the frontend can keep serving stale CSS after a preset/variable/module mutation until the cache is cleared. Delegates to Divi's native ET_Core_PageResource::remove_static_resources when available (response backend: \"divi_native\"), which additionally clears Theme Builder CSS scattered across other post dirs, archive/taxonomy/home/notfound CSS, the object cache, module features cache, post features cache, Google Fonts cache, dynamic assets cache, and post meta caches. Falls back to a targeted filesystem walk of numeric-named et-cache subdirs when the Divi class is absent (backend: \"fs_fallback\"). Provide exactly one selector — no site-wide default to prevent accidental full flush. Idempotent: missing cache root returns 200 with empty list.",
|
|
1628
2104
|
inputSchema: {
|
|
@@ -53,8 +53,12 @@ export declare function fsValidationDisabled(): boolean;
|
|
|
53
53
|
export declare function ensureSafeFsRoot(safeRoot: string): void;
|
|
54
54
|
/**
|
|
55
55
|
* Identify which FS-sensitive command a parsed arg vector represents.
|
|
56
|
-
* Returns the canonical prefix ("export", "acf export", "
|
|
57
|
-
* null if the args don't match a FS-sensitive command.
|
|
56
|
+
* Returns the canonical prefix ("export", "acf export", "scf json export"…)
|
|
57
|
+
* or null if the args don't match a FS-sensitive command.
|
|
58
|
+
*
|
|
59
|
+
* Checks 3-, 2-, then 1-word prefixes — longer matches win so that
|
|
60
|
+
* `scf json export` is identified as the SCF command (not bare `export`)
|
|
61
|
+
* even though both share the trailing word.
|
|
58
62
|
*/
|
|
59
63
|
export declare function matchFsSensitiveCommand(args: string[]): string | null;
|
|
60
64
|
/**
|
|
@@ -31,11 +31,19 @@ const SAFE_FS_ROOT_OVERRIDE_ENV = 'DIVIOPS_WP_CLI_SAFE_FS_ROOT';
|
|
|
31
31
|
* DEFAULT-tier commands whose arguments can write/read to arbitrary paths.
|
|
32
32
|
* EXTENDED-tier FS commands (`import`, `eval-file`) are opt-in and not
|
|
33
33
|
* validated here — opting in implies accepting the path-scope risk.
|
|
34
|
+
*
|
|
35
|
+
* SCF 6.8.4 introduced `wp scf json export|import` (and `wp acf json …`
|
|
36
|
+
* aliases). `export` takes `--dir=<directory>` (flag, like `wp export`);
|
|
37
|
+
* `import` takes a positional `<file>` path (like the legacy `acf import`).
|
|
34
38
|
*/
|
|
35
39
|
const FS_SENSITIVE_COMMANDS = [
|
|
36
40
|
'export',
|
|
37
41
|
'acf export',
|
|
38
42
|
'acf import',
|
|
43
|
+
'scf json export',
|
|
44
|
+
'scf json import',
|
|
45
|
+
'acf json export',
|
|
46
|
+
'acf json import',
|
|
39
47
|
];
|
|
40
48
|
/**
|
|
41
49
|
* Resolve the effective safe filesystem root for this wp-cli instance.
|
|
@@ -70,12 +78,19 @@ export function ensureSafeFsRoot(safeRoot) {
|
|
|
70
78
|
}
|
|
71
79
|
/**
|
|
72
80
|
* Identify which FS-sensitive command a parsed arg vector represents.
|
|
73
|
-
* Returns the canonical prefix ("export", "acf export", "
|
|
74
|
-
* null if the args don't match a FS-sensitive command.
|
|
81
|
+
* Returns the canonical prefix ("export", "acf export", "scf json export"…)
|
|
82
|
+
* or null if the args don't match a FS-sensitive command.
|
|
83
|
+
*
|
|
84
|
+
* Checks 3-, 2-, then 1-word prefixes — longer matches win so that
|
|
85
|
+
* `scf json export` is identified as the SCF command (not bare `export`)
|
|
86
|
+
* even though both share the trailing word.
|
|
75
87
|
*/
|
|
76
88
|
export function matchFsSensitiveCommand(args) {
|
|
77
89
|
if (args.length === 0)
|
|
78
90
|
return null;
|
|
91
|
+
const threeWord = args.slice(0, 3).join(' ');
|
|
92
|
+
if (FS_SENSITIVE_COMMANDS.includes(threeWord))
|
|
93
|
+
return threeWord;
|
|
79
94
|
const twoWord = args.slice(0, 2).join(' ');
|
|
80
95
|
if (FS_SENSITIVE_COMMANDS.includes(twoWord))
|
|
81
96
|
return twoWord;
|
|
@@ -333,5 +348,52 @@ export function validateFilesystemFlags(args, safeRoot, opts = {}) {
|
|
|
333
348
|
}
|
|
334
349
|
return { allowed: true };
|
|
335
350
|
}
|
|
351
|
+
if (cmd === 'scf json export' || cmd === 'acf json export') {
|
|
352
|
+
// SCF 6.8.4's `wp scf|acf json export` uses `--dir=<dir>` (or `--stdout`)
|
|
353
|
+
// for output destination — same flag shape as `wp export`. Reuse the same
|
|
354
|
+
// extractor; `--filename_format` is `wp export`-only and irrelevant here.
|
|
355
|
+
const { dir, stdout } = extractExportFlags(args);
|
|
356
|
+
if (stdout) {
|
|
357
|
+
// Writes JSON to stdout; no FS write to validate.
|
|
358
|
+
return { allowed: true };
|
|
359
|
+
}
|
|
360
|
+
if (!dir) {
|
|
361
|
+
// Let wp-cli surface its own "must specify --dir or --stdout" error
|
|
362
|
+
// rather than returning a redundant allowlist rejection.
|
|
363
|
+
return { allowed: true };
|
|
364
|
+
}
|
|
365
|
+
const rel = rejectIfRelative(dir, `${cmd} --dir`, safeRootCanonical);
|
|
366
|
+
if (rel)
|
|
367
|
+
return rel;
|
|
368
|
+
if (!isPathUnderSafeRoot(dir, safeRootCanonical)) {
|
|
369
|
+
return {
|
|
370
|
+
allowed: false,
|
|
371
|
+
reason: `${cmd} --dir="${dir}" resolves outside the safe filesystem root ` +
|
|
372
|
+
`"${safeRootCanonical}". Use a path under the safe root, or set ` +
|
|
373
|
+
`${UNSAFE_FS_ENV}=1 to disable validation.`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
return { allowed: true };
|
|
377
|
+
}
|
|
378
|
+
if (cmd === 'scf json import' || cmd === 'acf json import') {
|
|
379
|
+
// `wp scf|acf json import <file>` — positional file path at args[3]
|
|
380
|
+
// (after the 3-word command prefix). Same safe-root rules as `acf import`.
|
|
381
|
+
const userPath = extractPositionalAfterPrefix(args, 3);
|
|
382
|
+
if (!userPath) {
|
|
383
|
+
return { allowed: true };
|
|
384
|
+
}
|
|
385
|
+
const rel = rejectIfRelative(userPath, cmd, safeRootCanonical);
|
|
386
|
+
if (rel)
|
|
387
|
+
return rel;
|
|
388
|
+
if (!isPathUnderSafeRoot(userPath, safeRootCanonical)) {
|
|
389
|
+
return {
|
|
390
|
+
allowed: false,
|
|
391
|
+
reason: `${cmd} "${userPath}" resolves outside the safe filesystem root ` +
|
|
392
|
+
`"${safeRootCanonical}". Use a path under the safe root, or set ` +
|
|
393
|
+
`${UNSAFE_FS_ENV}=1 to disable validation.`,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
return { allowed: true };
|
|
397
|
+
}
|
|
336
398
|
return { allowed: true };
|
|
337
399
|
}
|
package/dist/wp-cli.d.ts
CHANGED
|
@@ -15,15 +15,31 @@ interface WpCliConfig {
|
|
|
15
15
|
}
|
|
16
16
|
export declare function createWpCli(config: WpCliConfig): {
|
|
17
17
|
/**
|
|
18
|
-
* Execute a WP-CLI command.
|
|
19
|
-
*
|
|
20
|
-
* Uses execFile (no shell) to prevent command injection.
|
|
18
|
+
* Execute a WP-CLI command from a string. Parsed via parseCommand
|
|
19
|
+
* (single/double-quote toggling, no escape support). Validated against
|
|
20
|
+
* the allowlist. Uses execFile (no shell) to prevent command injection.
|
|
21
|
+
*
|
|
22
|
+
* Prefer `runArgs` from typed wrappers — it skips parseCommand entirely
|
|
23
|
+
* so user-supplied values containing apostrophes/quotes flow through
|
|
24
|
+
* verbatim instead of being mis-split.
|
|
21
25
|
*/
|
|
22
26
|
run(command: string): Promise<{
|
|
23
27
|
success: boolean;
|
|
24
28
|
output: string;
|
|
25
29
|
error?: string;
|
|
26
30
|
}>;
|
|
31
|
+
/**
|
|
32
|
+
* Execute a WP-CLI command from a pre-built argv array. Skips
|
|
33
|
+
* parseCommand so values containing whitespace, apostrophes, or
|
|
34
|
+
* quotes pass through unmodified. Same allowlist + FS-safe-root
|
|
35
|
+
* validation as `run`. Use this from typed wrappers that already
|
|
36
|
+
* have the args structured (no string concatenation needed).
|
|
37
|
+
*/
|
|
38
|
+
runArgs(args: string[]): Promise<{
|
|
39
|
+
success: boolean;
|
|
40
|
+
output: string;
|
|
41
|
+
error?: string;
|
|
42
|
+
}>;
|
|
27
43
|
/** Return the list of allowed commands and available extensions. */
|
|
28
44
|
getAllowedCommands(): {
|
|
29
45
|
allowed: string[];
|
package/dist/wp-cli.js
CHANGED
|
@@ -40,6 +40,20 @@ const DEFAULT_COMMANDS = [
|
|
|
40
40
|
'acf import',
|
|
41
41
|
'acf field-group list',
|
|
42
42
|
'acf field-group get',
|
|
43
|
+
// SCF 6.8.4+ adds `wp scf json {status,sync,import,export}` (also aliased as
|
|
44
|
+
// `wp acf json …`). All four are dev-time schema ops — `status`/`sync` are
|
|
45
|
+
// diff/apply against on-disk JSON, `import` re-creates DB entries from JSON,
|
|
46
|
+
// `export` writes DB schema to JSON. Same tier semantics as the legacy `acf`
|
|
47
|
+
// entries above; FS-touching subcommands (export, import) are second-pass
|
|
48
|
+
// validated below.
|
|
49
|
+
'scf json status',
|
|
50
|
+
'scf json sync',
|
|
51
|
+
'scf json import',
|
|
52
|
+
'scf json export',
|
|
53
|
+
'acf json status',
|
|
54
|
+
'acf json sync',
|
|
55
|
+
'acf json import',
|
|
56
|
+
'acf json export',
|
|
43
57
|
// Users (read-only)
|
|
44
58
|
'user list',
|
|
45
59
|
// Cache (non-destructive maintenance)
|
|
@@ -319,66 +333,91 @@ export function createWpCli(config) {
|
|
|
319
333
|
const runOptions = customWpCliCmd
|
|
320
334
|
? { ...execOptions, env, cwd: config.wpPath }
|
|
321
335
|
: { ...execOptions, env };
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
336
|
+
// Internal argv-based runner — used by both `run(string)` (after
|
|
337
|
+
// parseCommand) and `runArgs(string[])` (skip parseCommand). Typed
|
|
338
|
+
// wrappers should call runArgs to bypass parseCommand's quote-toggling
|
|
339
|
+
// weakness: when a value contains an apostrophe (e.g. label "Bob's
|
|
340
|
+
// Group", file path /tmp/it's-fine.json), parseCommand mis-splits the
|
|
341
|
+
// argv because it treats the embedded `'` as a quote toggle. Passing
|
|
342
|
+
// pre-built argv eliminates the parsing step entirely so user-provided
|
|
343
|
+
// strings flow through verbatim — execFile (no shell) handles them
|
|
344
|
+
// correctly. Raised in PR #473 review (Copilot/Gemini both flagged).
|
|
345
|
+
const runArgv = async (args) => {
|
|
346
|
+
const check = isCommandAllowed(args);
|
|
347
|
+
if (!check.allowed) {
|
|
348
|
+
return { success: false, output: '', error: check.reason };
|
|
349
|
+
}
|
|
350
|
+
// Second-pass FS validation for commands whose flags/args can read/write
|
|
351
|
+
// arbitrary paths. Scoped to DEFAULT-tier FS commands only — EXTENDED
|
|
352
|
+
// (`import`, `eval-file`) are opt-in via DIVIOPS_WP_CLI_ALLOW, so opting
|
|
353
|
+
// in signals the caller accepts path-scope risk. Skip entirely when the
|
|
354
|
+
// user explicitly disables via DIVIOPS_WP_CLI_UNSAFE_FS=1.
|
|
355
|
+
//
|
|
356
|
+
// Wrapper mode (WP_CLI_CMD set) is gated separately inside
|
|
357
|
+
// validateFilesystemFlags — host-derived safe roots don't correspond to
|
|
358
|
+
// the wrapper's filesystem namespace, so the validator requires an
|
|
359
|
+
// explicit DIVIOPS_WP_CLI_SAFE_FS_ROOT there. We also skip the host-side
|
|
360
|
+
// mkdir in wrapper mode since the safe root is either container-scoped
|
|
361
|
+
// (user-managed) or unset (validator rejects).
|
|
362
|
+
if (!fsValidationDisabled() && matchFsSensitiveCommand(args)) {
|
|
363
|
+
const safeRoot = resolveSafeFsRoot(config.wpPath);
|
|
364
|
+
if (!customWpCliCmd) {
|
|
365
|
+
ensureSafeFsRoot(safeRoot);
|
|
333
366
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
367
|
+
const fsCheck = validateFilesystemFlags(args, safeRoot, {
|
|
368
|
+
isWrapper: !!customWpCliCmd,
|
|
369
|
+
});
|
|
370
|
+
if (!fsCheck.allowed) {
|
|
371
|
+
return { success: false, output: '', error: fsCheck.reason };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const fullArgs = customWpCliCmd
|
|
375
|
+
? [...prefixArgs, ...args, '--no-color']
|
|
376
|
+
: [...args, `--path=${config.wpPath}`, '--no-color'];
|
|
377
|
+
return new Promise((resolve) => {
|
|
378
|
+
execFile(executable, fullArgs, runOptions, (error, stdout, stderr) => {
|
|
379
|
+
// Filter PHP deprecation warnings from output
|
|
380
|
+
const output = (stdout + '\n' + stderr)
|
|
381
|
+
.split('\n')
|
|
382
|
+
.filter((line) => !line.includes('Deprecated:') && !line.includes('PHP Deprecated'))
|
|
383
|
+
.join('\n')
|
|
384
|
+
.trim();
|
|
385
|
+
if (error) {
|
|
386
|
+
const detail = error.killed
|
|
387
|
+
? 'Command timed out'
|
|
388
|
+
: error.signal
|
|
389
|
+
? `Killed by signal ${error.signal}`
|
|
390
|
+
: `Exit code ${error.code ?? 'unknown'}`;
|
|
391
|
+
resolve({ success: false, output, error: detail });
|
|
350
392
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
});
|
|
354
|
-
if (!fsCheck.allowed) {
|
|
355
|
-
return { success: false, output: '', error: fsCheck.reason };
|
|
393
|
+
else {
|
|
394
|
+
resolve({ success: true, output });
|
|
356
395
|
}
|
|
357
|
-
}
|
|
358
|
-
const fullArgs = customWpCliCmd
|
|
359
|
-
? [...prefixArgs, ...args, '--no-color']
|
|
360
|
-
: [...args, `--path=${config.wpPath}`, '--no-color'];
|
|
361
|
-
return new Promise((resolve) => {
|
|
362
|
-
execFile(executable, fullArgs, runOptions, (error, stdout, stderr) => {
|
|
363
|
-
// Filter PHP deprecation warnings from output
|
|
364
|
-
const output = (stdout + '\n' + stderr)
|
|
365
|
-
.split('\n')
|
|
366
|
-
.filter((line) => !line.includes('Deprecated:') && !line.includes('PHP Deprecated'))
|
|
367
|
-
.join('\n')
|
|
368
|
-
.trim();
|
|
369
|
-
if (error) {
|
|
370
|
-
const detail = error.killed
|
|
371
|
-
? 'Command timed out'
|
|
372
|
-
: error.signal
|
|
373
|
-
? `Killed by signal ${error.signal}`
|
|
374
|
-
: `Exit code ${error.code ?? 'unknown'}`;
|
|
375
|
-
resolve({ success: false, output, error: detail });
|
|
376
|
-
}
|
|
377
|
-
else {
|
|
378
|
-
resolve({ success: true, output });
|
|
379
|
-
}
|
|
380
|
-
});
|
|
381
396
|
});
|
|
397
|
+
});
|
|
398
|
+
};
|
|
399
|
+
return {
|
|
400
|
+
/**
|
|
401
|
+
* Execute a WP-CLI command from a string. Parsed via parseCommand
|
|
402
|
+
* (single/double-quote toggling, no escape support). Validated against
|
|
403
|
+
* the allowlist. Uses execFile (no shell) to prevent command injection.
|
|
404
|
+
*
|
|
405
|
+
* Prefer `runArgs` from typed wrappers — it skips parseCommand entirely
|
|
406
|
+
* so user-supplied values containing apostrophes/quotes flow through
|
|
407
|
+
* verbatim instead of being mis-split.
|
|
408
|
+
*/
|
|
409
|
+
async run(command) {
|
|
410
|
+
return runArgv(parseCommand(command));
|
|
411
|
+
},
|
|
412
|
+
/**
|
|
413
|
+
* Execute a WP-CLI command from a pre-built argv array. Skips
|
|
414
|
+
* parseCommand so values containing whitespace, apostrophes, or
|
|
415
|
+
* quotes pass through unmodified. Same allowlist + FS-safe-root
|
|
416
|
+
* validation as `run`. Use this from typed wrappers that already
|
|
417
|
+
* have the args structured (no string concatenation needed).
|
|
418
|
+
*/
|
|
419
|
+
async runArgs(args) {
|
|
420
|
+
return runArgv(args);
|
|
382
421
|
},
|
|
383
422
|
/** Return the list of allowed commands and available extensions. */
|
|
384
423
|
getAllowedCommands() {
|