mcp-wp-cli-terminus 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {mcp_wp_cli_terminus-0.2.0 → mcp_wp_cli_terminus-0.3.0}/PKG-INFO +26 -12
- {mcp_wp_cli_terminus-0.2.0 → mcp_wp_cli_terminus-0.3.0}/README.md +25 -11
- {mcp_wp_cli_terminus-0.2.0 → mcp_wp_cli_terminus-0.3.0}/pyproject.toml +1 -1
- {mcp_wp_cli_terminus-0.2.0 → mcp_wp_cli_terminus-0.3.0}/src/wp_cli_mcp/__init__.py +9 -7
- mcp_wp_cli_terminus-0.3.0/src/wp_cli_mcp/_shared.py +142 -0
- mcp_wp_cli_terminus-0.3.0/src/wp_cli_mcp/cli.py +51 -0
- mcp_wp_cli_terminus-0.3.0/src/wp_cli_mcp/clone.py +202 -0
- mcp_wp_cli_terminus-0.3.0/src/wp_cli_mcp/schemas.py +286 -0
- mcp_wp_cli_terminus-0.3.0/src/wp_cli_mcp/sync.py +223 -0
- mcp_wp_cli_terminus-0.3.0/src/wp_cli_mcp/tools.py +56 -0
- mcp_wp_cli_terminus-0.3.0/tests/_helpers.py +131 -0
- mcp_wp_cli_terminus-0.3.0/tests/test_clone.py +105 -0
- mcp_wp_cli_terminus-0.3.0/tests/test_command.py +135 -0
- mcp_wp_cli_terminus-0.3.0/tests/test_config.py +83 -0
- mcp_wp_cli_terminus-0.3.0/tests/test_configure.py +110 -0
- mcp_wp_cli_terminus-0.3.0/tests/test_sync.py +299 -0
- mcp_wp_cli_terminus-0.2.0/src/wp_cli_mcp/tools.py +0 -651
- mcp_wp_cli_terminus-0.2.0/tests/test_wp_cli_mcp.py +0 -725
- {mcp_wp_cli_terminus-0.2.0 → mcp_wp_cli_terminus-0.3.0}/.gitignore +0 -0
- {mcp_wp_cli_terminus-0.2.0 → mcp_wp_cli_terminus-0.3.0}/LICENSE +0 -0
- {mcp_wp_cli_terminus-0.2.0 → mcp_wp_cli_terminus-0.3.0}/src/wp_cli_mcp/__main__.py +0 -0
- {mcp_wp_cli_terminus-0.2.0 → mcp_wp_cli_terminus-0.3.0}/src/wp_cli_mcp/command.py +0 -0
- {mcp_wp_cli_terminus-0.2.0 → mcp_wp_cli_terminus-0.3.0}/src/wp_cli_mcp/config.py +0 -0
- {mcp_wp_cli_terminus-0.2.0 → mcp_wp_cli_terminus-0.3.0}/src/wp_cli_mcp/configure.py +0 -0
- {mcp_wp_cli_terminus-0.2.0 → mcp_wp_cli_terminus-0.3.0}/src/wp_cli_mcp/transport.py +0 -0
- {mcp_wp_cli_terminus-0.2.0 → mcp_wp_cli_terminus-0.3.0}/wp-cli.conf.example +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcp-wp-cli-terminus
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: MCP server for WP-CLI over local Docker, Pantheon Terminus, or SSH — run WP-CLI and byte-faithfully copy posts/meta between WordPress environments, with checksum verification.
|
|
5
5
|
Project-URL: Homepage, https://github.com/EarthmanWeb/mcp-wp-cli-terminus
|
|
6
6
|
Project-URL: Repository, https://github.com/EarthmanWeb/mcp-wp-cli-terminus
|
|
@@ -46,14 +46,21 @@ This server solves all of that: content is read, base64-encoded **in code**, del
|
|
|
46
46
|
|
|
47
47
|
| Tool | What it does |
|
|
48
48
|
|------|--------------|
|
|
49
|
+
| `wp_init_config` | Guided setup: detects your Docker container, WordPress path, and Terminus site, then writes `.serena/wp-cli.conf` (asking you for anything it can't detect). |
|
|
49
50
|
| `wp_cli` | Run any WP-CLI command against a configured site — `target: local` (Docker) or `target: production` (Terminus or SSH, chosen by config). Destructive commands are guarded on production. |
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
51
|
+
| `wp_sync_post` | **Sync** a post's `post_content` onto the **same post ID** in another environment (updates an existing post; e.g. a multidev cloned from the same DB), with an **md5 round-trip verification**. |
|
|
52
|
+
| `wp_sync_post_meta` | **Sync** a post's **complete** meta (serialized arrays, multiple values per key, ACF repeaters) onto the **same post ID** in another environment, with a canonical **checksum verification**. All keys (full mirror) or an allow-list. |
|
|
53
|
+
| `wp_clone_post` | **Clone** a post to another environment as a **NEW** post — the destination assigns its own ID (returned as `new_id`). Use when the post doesn't exist on the destination yet. Copies fields + all meta, verifies, and reports meta keys that hold ID references for manual remapping. |
|
|
54
|
+
|
|
55
|
+
### Sync vs. clone
|
|
56
|
+
|
|
57
|
+
- **Sync** (`wp_sync_post`, `wp_sync_post_meta`) updates an **existing** post that shares the **same ID** on both sides — the right tool when the environments were cloned from the same database (a Pantheon multidev, a staging copy). It fails if the destination ID doesn't exist.
|
|
58
|
+
- **Clone** (`wp_clone_post`) **creates a new** post on the destination from a source post; the destination assigns a fresh ID. Use it when the content is new to the destination (e.g. pushing a locally-authored post/alert up to a multidev). Meta values that look like ID references (`_thumbnail_id`, ACF relationship/image fields) are copied verbatim and **reported** — never silently remapped across databases.
|
|
52
59
|
|
|
53
60
|
### Correctness guarantees
|
|
54
61
|
|
|
55
62
|
- **Never routes content through the model's text.** Payloads are read into the server and base64-encoded in code.
|
|
56
|
-
- **Checksum-verified.** Every
|
|
63
|
+
- **Checksum-verified.** Every sync/clone re-reads the destination and compares it to the transferred source; `verified: false` + an error on any mismatch.
|
|
57
64
|
- **Transport-agnostic.** The same logic runs over local Docker, Pantheon Terminus, and WP-CLI `--ssh`.
|
|
58
65
|
- **Large payloads.** Docker/SSH deliver PHP over STDIN (`wp eval-file -`, exempt from the argv size limit); Terminus uses a size-guarded argv path and fails loud rather than emitting a raw `E2BIG`.
|
|
59
66
|
- **Meta fidelity.** Values are round-tripped so WordPress's own `maybe_serialize()` reproduces the exact stored `meta_value` — arrays stay arrays, and strings that merely look serialized stay strings.
|
|
@@ -141,18 +148,25 @@ TERMINUS_ENV=dev # default env (override per call)
|
|
|
141
148
|
// Run against production (Terminus or SSH per config)
|
|
142
149
|
{ "tool": "wp_cli", "args": "option get siteurl", "target": "production" }
|
|
143
150
|
|
|
144
|
-
//
|
|
145
|
-
{ "tool": "
|
|
151
|
+
// SYNC a front page's block markup onto the SAME post ID on production, verified
|
|
152
|
+
{ "tool": "wp_sync_post", "post_id": 42, "from": "local", "to": "production", "confirm": true }
|
|
146
153
|
|
|
147
|
-
//
|
|
148
|
-
{ "tool": "
|
|
154
|
+
// SYNC ALL meta for a post (full mirror) onto the same ID, verified
|
|
155
|
+
{ "tool": "wp_sync_post_meta", "post_id": 42, "from": "local", "to": "production", "confirm": true }
|
|
149
156
|
|
|
150
|
-
//
|
|
151
|
-
{ "tool": "
|
|
157
|
+
// SYNC only specific meta keys
|
|
158
|
+
{ "tool": "wp_sync_post_meta", "post_id": 42, "from": "local", "to": "production",
|
|
152
159
|
"keys": ["_thumbnail_id", "my_field"], "confirm": true }
|
|
160
|
+
|
|
161
|
+
// CLONE a locally-authored post to production as a NEW post (returns new_id)
|
|
162
|
+
{ "tool": "wp_clone_post", "post_id": 268529, "from": "local", "to": "production", "confirm": true }
|
|
163
|
+
|
|
164
|
+
// Clone but force the new post to draft
|
|
165
|
+
{ "tool": "wp_clone_post", "post_id": 268529, "from": "local", "to": "production",
|
|
166
|
+
"overrides": { "post_status": "draft" }, "confirm": true }
|
|
153
167
|
```
|
|
154
168
|
|
|
155
|
-
|
|
169
|
+
`wp_sync_*` return `verified: true/false` with `src_md5` / `dst_md5`, the delivery mode, and per-side transport. `wp_clone_post` returns `new_id`, `verified`, `content_verified`, `meta_verified`, and `id_reference_keys` (meta keys to review).
|
|
156
170
|
|
|
157
171
|
## Debug logging
|
|
158
172
|
|
|
@@ -173,7 +187,7 @@ Failures (non-zero WP-CLI exits) are appended to a log in your system temp dir
|
|
|
173
187
|
python -m unittest discover -s tests -v
|
|
174
188
|
```
|
|
175
189
|
|
|
176
|
-
|
|
190
|
+
70 stdlib-only unit tests (split by area under `tests/`) cover config parsing, transport selection (local/Terminus/SSH), the argv size guard, newline handling, PHP-key safety, id-reference detection, and the full sync/clone/verify orchestration via an injectable command-runner seam (no real WP-CLI invoked).
|
|
177
191
|
|
|
178
192
|
## Releasing
|
|
179
193
|
|
|
@@ -24,14 +24,21 @@ This server solves all of that: content is read, base64-encoded **in code**, del
|
|
|
24
24
|
|
|
25
25
|
| Tool | What it does |
|
|
26
26
|
|------|--------------|
|
|
27
|
+
| `wp_init_config` | Guided setup: detects your Docker container, WordPress path, and Terminus site, then writes `.serena/wp-cli.conf` (asking you for anything it can't detect). |
|
|
27
28
|
| `wp_cli` | Run any WP-CLI command against a configured site — `target: local` (Docker) or `target: production` (Terminus or SSH, chosen by config). Destructive commands are guarded on production. |
|
|
28
|
-
| `
|
|
29
|
-
| `
|
|
29
|
+
| `wp_sync_post` | **Sync** a post's `post_content` onto the **same post ID** in another environment (updates an existing post; e.g. a multidev cloned from the same DB), with an **md5 round-trip verification**. |
|
|
30
|
+
| `wp_sync_post_meta` | **Sync** a post's **complete** meta (serialized arrays, multiple values per key, ACF repeaters) onto the **same post ID** in another environment, with a canonical **checksum verification**. All keys (full mirror) or an allow-list. |
|
|
31
|
+
| `wp_clone_post` | **Clone** a post to another environment as a **NEW** post — the destination assigns its own ID (returned as `new_id`). Use when the post doesn't exist on the destination yet. Copies fields + all meta, verifies, and reports meta keys that hold ID references for manual remapping. |
|
|
32
|
+
|
|
33
|
+
### Sync vs. clone
|
|
34
|
+
|
|
35
|
+
- **Sync** (`wp_sync_post`, `wp_sync_post_meta`) updates an **existing** post that shares the **same ID** on both sides — the right tool when the environments were cloned from the same database (a Pantheon multidev, a staging copy). It fails if the destination ID doesn't exist.
|
|
36
|
+
- **Clone** (`wp_clone_post`) **creates a new** post on the destination from a source post; the destination assigns a fresh ID. Use it when the content is new to the destination (e.g. pushing a locally-authored post/alert up to a multidev). Meta values that look like ID references (`_thumbnail_id`, ACF relationship/image fields) are copied verbatim and **reported** — never silently remapped across databases.
|
|
30
37
|
|
|
31
38
|
### Correctness guarantees
|
|
32
39
|
|
|
33
40
|
- **Never routes content through the model's text.** Payloads are read into the server and base64-encoded in code.
|
|
34
|
-
- **Checksum-verified.** Every
|
|
41
|
+
- **Checksum-verified.** Every sync/clone re-reads the destination and compares it to the transferred source; `verified: false` + an error on any mismatch.
|
|
35
42
|
- **Transport-agnostic.** The same logic runs over local Docker, Pantheon Terminus, and WP-CLI `--ssh`.
|
|
36
43
|
- **Large payloads.** Docker/SSH deliver PHP over STDIN (`wp eval-file -`, exempt from the argv size limit); Terminus uses a size-guarded argv path and fails loud rather than emitting a raw `E2BIG`.
|
|
37
44
|
- **Meta fidelity.** Values are round-tripped so WordPress's own `maybe_serialize()` reproduces the exact stored `meta_value` — arrays stay arrays, and strings that merely look serialized stay strings.
|
|
@@ -119,18 +126,25 @@ TERMINUS_ENV=dev # default env (override per call)
|
|
|
119
126
|
// Run against production (Terminus or SSH per config)
|
|
120
127
|
{ "tool": "wp_cli", "args": "option get siteurl", "target": "production" }
|
|
121
128
|
|
|
122
|
-
//
|
|
123
|
-
{ "tool": "
|
|
129
|
+
// SYNC a front page's block markup onto the SAME post ID on production, verified
|
|
130
|
+
{ "tool": "wp_sync_post", "post_id": 42, "from": "local", "to": "production", "confirm": true }
|
|
124
131
|
|
|
125
|
-
//
|
|
126
|
-
{ "tool": "
|
|
132
|
+
// SYNC ALL meta for a post (full mirror) onto the same ID, verified
|
|
133
|
+
{ "tool": "wp_sync_post_meta", "post_id": 42, "from": "local", "to": "production", "confirm": true }
|
|
127
134
|
|
|
128
|
-
//
|
|
129
|
-
{ "tool": "
|
|
135
|
+
// SYNC only specific meta keys
|
|
136
|
+
{ "tool": "wp_sync_post_meta", "post_id": 42, "from": "local", "to": "production",
|
|
130
137
|
"keys": ["_thumbnail_id", "my_field"], "confirm": true }
|
|
138
|
+
|
|
139
|
+
// CLONE a locally-authored post to production as a NEW post (returns new_id)
|
|
140
|
+
{ "tool": "wp_clone_post", "post_id": 268529, "from": "local", "to": "production", "confirm": true }
|
|
141
|
+
|
|
142
|
+
// Clone but force the new post to draft
|
|
143
|
+
{ "tool": "wp_clone_post", "post_id": 268529, "from": "local", "to": "production",
|
|
144
|
+
"overrides": { "post_status": "draft" }, "confirm": true }
|
|
131
145
|
```
|
|
132
146
|
|
|
133
|
-
|
|
147
|
+
`wp_sync_*` return `verified: true/false` with `src_md5` / `dst_md5`, the delivery mode, and per-side transport. `wp_clone_post` returns `new_id`, `verified`, `content_verified`, `meta_verified`, and `id_reference_keys` (meta keys to review).
|
|
134
148
|
|
|
135
149
|
## Debug logging
|
|
136
150
|
|
|
@@ -151,7 +165,7 @@ Failures (non-zero WP-CLI exits) are appended to a log in your system temp dir
|
|
|
151
165
|
python -m unittest discover -s tests -v
|
|
152
166
|
```
|
|
153
167
|
|
|
154
|
-
|
|
168
|
+
70 stdlib-only unit tests (split by area under `tests/`) cover config parsing, transport selection (local/Terminus/SSH), the argv size guard, newline handling, PHP-key safety, id-reference detection, and the full sync/clone/verify orchestration via an injectable command-runner seam (no real WP-CLI invoked).
|
|
155
169
|
|
|
156
170
|
## Releasing
|
|
157
171
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mcp-wp-cli-terminus"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "MCP server for WP-CLI over local Docker, Pantheon Terminus, or SSH — run WP-CLI and byte-faithfully copy posts/meta between WordPress environments, with checksum verification."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""wp_cli_mcp — a transport-agnostic WP-CLI MCP server package.
|
|
3
3
|
|
|
4
|
-
Exposes a stdio JSON-RPC MCP server with
|
|
5
|
-
wp_cli,
|
|
4
|
+
Exposes a stdio JSON-RPC MCP server with five tools:
|
|
5
|
+
wp_init_config, wp_cli, wp_sync_post, wp_sync_post_meta, wp_clone_post.
|
|
6
6
|
|
|
7
|
-
Public API is re-exported here so callers (and tests) can `from
|
|
7
|
+
Public API is re-exported here so callers (and tests) can `from wp_cli_mcp import ...`
|
|
8
8
|
without reaching into submodules.
|
|
9
9
|
"""
|
|
10
10
|
|
|
@@ -34,8 +34,9 @@ from .tools import (
|
|
|
34
34
|
TOOL_DEFINITIONS,
|
|
35
35
|
TOOL_REGISTRY,
|
|
36
36
|
tool_wp_cli,
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
tool_wp_sync_post,
|
|
38
|
+
tool_wp_sync_post_meta,
|
|
39
|
+
tool_wp_clone_post,
|
|
39
40
|
)
|
|
40
41
|
from .configure import tool_wp_init_config
|
|
41
42
|
from .transport import (
|
|
@@ -54,7 +55,8 @@ __all__ = [
|
|
|
54
55
|
"MAX_ARG_STRLEN", "build_command", "is_destructive", "log_failure",
|
|
55
56
|
"run_php_eval", "run_wp", "set_runner", "transport_label", "uses_terminus",
|
|
56
57
|
"TOOL_DEFINITIONS", "TOOL_REGISTRY",
|
|
57
|
-
"tool_wp_init_config", "tool_wp_cli", "
|
|
58
|
+
"tool_wp_init_config", "tool_wp_cli", "tool_wp_sync_post", "tool_wp_sync_post_meta",
|
|
59
|
+
"tool_wp_clone_post",
|
|
58
60
|
"SERVER_NAME", "SERVER_VERSION", "PROTOCOL_VERSION",
|
|
59
61
|
"handle_initialize", "handle_tools_call", "handle_tools_list", "main",
|
|
60
62
|
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared helpers used by the post sync/clone tools.
|
|
3
|
+
|
|
4
|
+
Meta-map read/digest builders, destination-existence checks, post_content reads,
|
|
5
|
+
and small result helpers. Stdlib only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
10
|
+
import re
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from .command import run_php_eval, run_wp
|
|
14
|
+
|
|
15
|
+
# Meta keys travel as base64 data (never interpolated as raw PHP), but we still
|
|
16
|
+
# constrain them defensively: a WP meta_key is a short identifier. This allow-list
|
|
17
|
+
# accepts normal + protected keys (leading underscore, ACF field keys).
|
|
18
|
+
_META_KEY_RE = re.compile(r"^[A-Za-z0-9_:.\-]+$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _validate_from_to(from_, to):
|
|
22
|
+
for name, val in (("from", from_), ("to", to)):
|
|
23
|
+
if val not in ("local", "production"):
|
|
24
|
+
raise ValueError(f"'{name}' must be 'local' or 'production' (got {val!r}).")
|
|
25
|
+
if from_ == to:
|
|
26
|
+
raise ValueError("'from' and 'to' must differ — nothing to copy between the same env.")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read_post_content(block, site_name, target, post_id, env):
|
|
30
|
+
"""Return (ok, content_bytes, proc). `wp post get --field` appends exactly one
|
|
31
|
+
trailing newline to stdout that is NOT part of post_content — strip it so the
|
|
32
|
+
bytes (and their md5) match the value PHP strlen()/md5() see in the DB.
|
|
33
|
+
"""
|
|
34
|
+
proc, _ = run_wp(
|
|
35
|
+
block, site_name, target,
|
|
36
|
+
["post", "get", str(post_id), "--field=post_content"],
|
|
37
|
+
env, text=False,
|
|
38
|
+
)
|
|
39
|
+
if proc.returncode != 0:
|
|
40
|
+
return False, b"", proc
|
|
41
|
+
out = proc.stdout
|
|
42
|
+
if out.endswith(b"\r\n"):
|
|
43
|
+
out = out[:-2]
|
|
44
|
+
elif out.endswith(b"\n"):
|
|
45
|
+
out = out[:-1]
|
|
46
|
+
return True, out, proc
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _dest_post_exists(block, site_name, target, post_id, env):
|
|
50
|
+
proc, _ = run_wp(
|
|
51
|
+
block, site_name, target,
|
|
52
|
+
["post", "get", str(post_id), "--field=post_status"], env, text=True,
|
|
53
|
+
)
|
|
54
|
+
return proc.returncode == 0, proc
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _keys_php_literal(keys):
|
|
58
|
+
"""PHP array() of keys, each carried as base64_decode("...") so a key string
|
|
59
|
+
can never inject PHP (no interpolation, no quote breakout). Returns 'null' when
|
|
60
|
+
keys is None (meaning: all keys / full mirror)."""
|
|
61
|
+
if keys is None:
|
|
62
|
+
return "null"
|
|
63
|
+
parts = []
|
|
64
|
+
for k in keys:
|
|
65
|
+
kb = base64.b64encode(k.encode("utf-8")).decode("ascii")
|
|
66
|
+
parts.append('base64_decode("' + kb + '")')
|
|
67
|
+
return "array(" + ",".join(parts) + ")"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _meta_read_b64(block, site_name, target, post_id, env):
|
|
71
|
+
"""(ok, b64, proc): the FULL raw meta map as base64(serialize(get_post_meta(id))).
|
|
72
|
+
Values are the raw stored DB strings (get_post_meta with no key does NOT
|
|
73
|
+
unserialize), so the payload is byte-faithful."""
|
|
74
|
+
php = (
|
|
75
|
+
'$m=get_post_meta(' + str(int(post_id)) + ');'
|
|
76
|
+
'if(!is_array($m)){$m=array();}'
|
|
77
|
+
'ksort($m);'
|
|
78
|
+
'echo base64_encode(serialize($m));'
|
|
79
|
+
)
|
|
80
|
+
proc, _, _ = run_php_eval(block, site_name, target, php, env, text=True)
|
|
81
|
+
if proc.returncode != 0:
|
|
82
|
+
return False, "", proc
|
|
83
|
+
return True, (proc.stdout or "").strip(), proc
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _digest_php_from_b64(src_b64, keys):
|
|
87
|
+
"""PHP that digests a serialized meta map (base64) restricted to `keys`,
|
|
88
|
+
canonicalized by ksort — used to digest the TRANSFERRED payload identically to
|
|
89
|
+
how the destination is digested. Echoes '<md5>|<bytes>'."""
|
|
90
|
+
return (
|
|
91
|
+
'$m=unserialize(base64_decode("' + src_b64 + '"));'
|
|
92
|
+
'if(!is_array($m)){$m=array();}'
|
|
93
|
+
'$only=' + _keys_php_literal(keys) + ';'
|
|
94
|
+
'if($only!==null){$f=array();foreach($only as $k){$f[$k]=array_key_exists($k,$m)?$m[$k]:null;}$m=$f;}'
|
|
95
|
+
'ksort($m);'
|
|
96
|
+
'$s=serialize($m);'
|
|
97
|
+
'echo md5($s)."|".strlen($s);'
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _digest_php_from_db(post_id, keys):
|
|
102
|
+
"""PHP that digests the LIVE meta of a post restricted to `keys` (ksort'd).
|
|
103
|
+
Same canonical form as _digest_php_from_b64 so the two are comparable."""
|
|
104
|
+
return (
|
|
105
|
+
'$m=get_post_meta(' + str(int(post_id)) + ');'
|
|
106
|
+
'if(!is_array($m)){$m=array();}'
|
|
107
|
+
'$only=' + _keys_php_literal(keys) + ';'
|
|
108
|
+
'if($only!==null){$f=array();foreach($only as $k){$f[$k]=array_key_exists($k,$m)?$m[$k]:null;}$m=$f;}'
|
|
109
|
+
'ksort($m);'
|
|
110
|
+
'$s=serialize($m);'
|
|
111
|
+
'echo md5($s)."|".strlen($s);'
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _run_digest(block, site_name, target, php, env):
|
|
116
|
+
proc, _, _ = run_php_eval(block, site_name, target, php, env, text=True)
|
|
117
|
+
if proc.returncode != 0:
|
|
118
|
+
return False, "", 0, proc
|
|
119
|
+
out = (proc.stdout or "").strip()
|
|
120
|
+
if "|" not in out:
|
|
121
|
+
return False, "", 0, proc
|
|
122
|
+
md5_str, _, nbytes = out.partition("|")
|
|
123
|
+
try:
|
|
124
|
+
n = int(nbytes)
|
|
125
|
+
except ValueError:
|
|
126
|
+
n = 0
|
|
127
|
+
return True, md5_str, n, proc
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _dec(v):
|
|
131
|
+
"""Decode bytes stderr to str for JSON output."""
|
|
132
|
+
if isinstance(v, bytes):
|
|
133
|
+
return v.decode("utf-8", "replace")
|
|
134
|
+
return v
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _err(message, site, post_id, from_, to, **extra):
|
|
138
|
+
d = {"verified": False, "error": message, "site": site, "post_id": post_id, "from": from_, "to": to}
|
|
139
|
+
d.update({k: v for k, v in extra.items() if v is not None})
|
|
140
|
+
return d
|
|
141
|
+
|
|
142
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""wp_cli — run an arbitrary WP-CLI command (local / Terminus / SSH)."""
|
|
3
|
+
|
|
4
|
+
import shlex
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .config import guard_enabled, load_config, resolve_site
|
|
8
|
+
from .command import is_destructive, run_wp, transport_label
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def tool_wp_cli(args: str, site: Optional[str] = None, target: str = "local", env: Optional[str] = None, confirm: bool = False) -> dict:
|
|
12
|
+
if target not in ("local", "production"):
|
|
13
|
+
raise ValueError(f"Invalid target '{target}'. Use 'local' or 'production'.")
|
|
14
|
+
|
|
15
|
+
arg_tokens = shlex.split(args)
|
|
16
|
+
if not arg_tokens:
|
|
17
|
+
raise ValueError("'args' is empty — provide a WP-CLI command (without 'wp').")
|
|
18
|
+
if arg_tokens[0] == "wp":
|
|
19
|
+
arg_tokens = arg_tokens[1:]
|
|
20
|
+
if not arg_tokens:
|
|
21
|
+
raise ValueError("'args' contained only 'wp' — provide a command.")
|
|
22
|
+
|
|
23
|
+
conf = load_config()
|
|
24
|
+
block = resolve_site(conf, site)
|
|
25
|
+
resolved_name = site or conf["globals"].get("DEFAULT_SITE", "") or sorted(conf["sites"])[0]
|
|
26
|
+
|
|
27
|
+
if target == "production" and guard_enabled(conf) and is_destructive(arg_tokens):
|
|
28
|
+
if not confirm:
|
|
29
|
+
return {
|
|
30
|
+
"blocked": True,
|
|
31
|
+
"reason": (
|
|
32
|
+
"Destructive WP-CLI command blocked on production by PROD_GUARD. "
|
|
33
|
+
"Re-run with confirm=true to proceed."
|
|
34
|
+
),
|
|
35
|
+
"site": resolved_name,
|
|
36
|
+
"command": "wp " + " ".join(arg_tokens),
|
|
37
|
+
"target": target,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
proc, full_cmd = run_wp(block, resolved_name, target, arg_tokens, env, text=True)
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
"site": resolved_name,
|
|
44
|
+
"target": target,
|
|
45
|
+
"transport": transport_label(block, target),
|
|
46
|
+
"command": " ".join(shlex.quote(c) for c in full_cmd),
|
|
47
|
+
"exit_code": proc.returncode,
|
|
48
|
+
"stdout": proc.stdout,
|
|
49
|
+
"stderr": proc.stderr,
|
|
50
|
+
}
|
|
51
|
+
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""wp_clone_post — create a NEW post on the destination from a source post."""
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import re
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .config import guard_enabled, load_config, resolve_site
|
|
9
|
+
from .command import run_php_eval, transport_label
|
|
10
|
+
from ._shared import _digest_php_from_db, _run_digest, _validate_from_to, _dec, _err
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_ID_REF_KEY_RE = re.compile(
|
|
14
|
+
r"(^_thumbnail_id$|_id$|^_?field_|(^|_)(post|page|attachment|image|gallery|"
|
|
15
|
+
r"relationship|parent|term|user|author)(_id)?s?$)",
|
|
16
|
+
re.IGNORECASE,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Post fields carried when cloning (everything meaningful except the ID itself and
|
|
20
|
+
# GUID, which the destination must own).
|
|
21
|
+
_CLONE_FIELDS = [
|
|
22
|
+
"post_title", "post_content", "post_excerpt", "post_status", "post_type",
|
|
23
|
+
"post_name", "post_author", "post_date", "post_date_gmt", "post_parent",
|
|
24
|
+
"menu_order", "comment_status", "ping_status", "post_password", "post_mime_type",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _clone_read_php(post_id: int) -> str:
|
|
29
|
+
"""PHP that echoes '<base64(serialize([fields,meta]))>\\n<comma-separated META
|
|
30
|
+
keys>' for a post, or 'ERR:missing' if the post does not exist. The explicit
|
|
31
|
+
meta-key line lets us report ID-reference META keys precisely (no Python-side
|
|
32
|
+
guessing over the serialized blob). Byte-faithful: meta values are the raw
|
|
33
|
+
stored strings (no unserialize)."""
|
|
34
|
+
fields = ",".join('"' + f + '"' for f in _CLONE_FIELDS)
|
|
35
|
+
return (
|
|
36
|
+
'$p=get_post(' + str(int(post_id)) + ');'
|
|
37
|
+
'if(!$p){echo "ERR:missing";return;}'
|
|
38
|
+
'$f=array();foreach(array(' + fields + ') as $k){$f[$k]=$p->$k;}'
|
|
39
|
+
'$m=get_post_meta(' + str(int(post_id)) + ');if(!is_array($m)){$m=array();}ksort($m);'
|
|
40
|
+
'echo base64_encode(serialize(array("fields"=>$f,"meta"=>$m)))."\\n".implode(",",array_keys($m));'
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _flag_id_ref_keys(meta_keys):
|
|
45
|
+
return sorted({k for k in meta_keys if _ID_REF_KEY_RE.search(k)})
|
|
46
|
+
|
|
47
|
+
def tool_wp_clone_post(
|
|
48
|
+
post_id: int,
|
|
49
|
+
from_: Optional[str] = None,
|
|
50
|
+
to: Optional[str] = None,
|
|
51
|
+
site: Optional[str] = None,
|
|
52
|
+
from_env: Optional[str] = None,
|
|
53
|
+
to_env: Optional[str] = None,
|
|
54
|
+
overrides: Optional[dict] = None,
|
|
55
|
+
confirm: bool = False,
|
|
56
|
+
**kwargs,
|
|
57
|
+
) -> dict:
|
|
58
|
+
"""COPY a source post to another environment as a NEW post (dest assigns its ID).
|
|
59
|
+
|
|
60
|
+
Unlike wp_sync_post (which updates an existing same-ID post), this creates a
|
|
61
|
+
brand-new post on the destination from the source's fields + all meta, and
|
|
62
|
+
returns the new destination ID. Meta values that look like ID references are
|
|
63
|
+
copied verbatim and REPORTED (id_reference_keys) for manual remapping — no
|
|
64
|
+
silent remap. `overrides` may set/replace post fields on the new post (e.g.
|
|
65
|
+
{"post_status": "draft"}). Content/meta never pass through the caller's text.
|
|
66
|
+
"""
|
|
67
|
+
if from_ is None:
|
|
68
|
+
from_ = kwargs.get("from")
|
|
69
|
+
_validate_from_to(from_, to)
|
|
70
|
+
if not isinstance(post_id, int) or post_id <= 0:
|
|
71
|
+
raise ValueError(f"'post_id' must be a positive integer (got {post_id!r}).")
|
|
72
|
+
if overrides is not None and not isinstance(overrides, dict):
|
|
73
|
+
raise ValueError("'overrides' must be an object of post field -> value.")
|
|
74
|
+
|
|
75
|
+
conf = load_config()
|
|
76
|
+
block = resolve_site(conf, site)
|
|
77
|
+
resolved_name = site or conf["globals"].get("DEFAULT_SITE", "") or sorted(conf["sites"])[0]
|
|
78
|
+
|
|
79
|
+
if to == "production" and guard_enabled(conf) and not confirm:
|
|
80
|
+
return {
|
|
81
|
+
"blocked": True,
|
|
82
|
+
"reason": (
|
|
83
|
+
"wp_clone_post creates a new post on a PRODUCTION destination, blocked by "
|
|
84
|
+
"PROD_GUARD. Re-run with confirm=true to proceed."
|
|
85
|
+
),
|
|
86
|
+
"site": resolved_name, "post_id": post_id, "from": from_, "to": to,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# 1. Read source fields + raw meta as one base64 payload, plus the explicit list
|
|
90
|
+
# of META key names (second line) for a precise id-reference report.
|
|
91
|
+
try:
|
|
92
|
+
rp, _, _ = run_php_eval(block, resolved_name, from_, _clone_read_php(post_id), from_env, text=True)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
return _err(str(e), resolved_name, post_id, from_, to)
|
|
95
|
+
out = (rp.stdout or "").strip()
|
|
96
|
+
if rp.returncode != 0 or not out or out.startswith("ERR:"):
|
|
97
|
+
return _err(f"Failed to read source post {post_id} (missing or unreadable).",
|
|
98
|
+
resolved_name, post_id, from_, to, exit_code=rp.returncode,
|
|
99
|
+
stderr=_dec(rp.stderr))
|
|
100
|
+
src_b64, _, meta_keys_line = out.partition("\n")
|
|
101
|
+
src_b64 = src_b64.strip()
|
|
102
|
+
source_meta_keys = [k for k in meta_keys_line.strip().split(",") if k]
|
|
103
|
+
|
|
104
|
+
# 2. Create the post on the destination from the transferred payload, then write
|
|
105
|
+
# all meta into the new post. wp_insert_post assigns the new ID. Overrides are
|
|
106
|
+
# applied over the source fields. Returns "OK:<new_id>".
|
|
107
|
+
ov_php = "array()"
|
|
108
|
+
if overrides:
|
|
109
|
+
parts = []
|
|
110
|
+
for k, v in overrides.items():
|
|
111
|
+
kb = base64.b64encode(str(k).encode("utf-8")).decode("ascii")
|
|
112
|
+
vb = base64.b64encode(str(v).encode("utf-8")).decode("ascii")
|
|
113
|
+
parts.append('base64_decode("' + kb + '")=>base64_decode("' + vb + '")')
|
|
114
|
+
ov_php = "array(" + ",".join(parts) + ")"
|
|
115
|
+
create_php = (
|
|
116
|
+
'$d=unserialize(base64_decode("' + src_b64 + '"));'
|
|
117
|
+
'if(!is_array($d)||!isset($d["fields"])){echo "ERR:bad payload";return;}'
|
|
118
|
+
'$f=$d["fields"];$ov=' + ov_php + ';foreach($ov as $k=>$v){$f[$k]=$v;}'
|
|
119
|
+
# Never carry an ID/GUID; let WP assign them.
|
|
120
|
+
'unset($f["ID"]);unset($f["guid"]);'
|
|
121
|
+
'$new=wp_insert_post($f,true);'
|
|
122
|
+
'if(is_wp_error($new)){echo "ERR:".$new->get_error_message();return;}'
|
|
123
|
+
# Write meta verbatim (maybe_unserialize so WP re-serializes to the same bytes).
|
|
124
|
+
'$m=isset($d["meta"])&&is_array($d["meta"])?$d["meta"]:array();'
|
|
125
|
+
'foreach($m as $k=>$vals){delete_post_meta($new,$k);foreach($vals as $v){add_post_meta($new,$k,maybe_unserialize($v));}}'
|
|
126
|
+
'echo "OK:".$new;'
|
|
127
|
+
)
|
|
128
|
+
try:
|
|
129
|
+
cp, _, mode = run_php_eval(block, resolved_name, to, create_php, to_env, text=True)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
return _err(str(e), resolved_name, post_id, from_, to)
|
|
132
|
+
cout = (cp.stdout or "").strip()
|
|
133
|
+
if cp.returncode != 0 or not cout.startswith("OK:"):
|
|
134
|
+
return _err("Clone failed on destination (wp_insert_post or meta write).",
|
|
135
|
+
resolved_name, post_id, from_, to, exit_code=cp.returncode,
|
|
136
|
+
stdout=cp.stdout, stderr=_dec(cp.stderr), delivery=mode)
|
|
137
|
+
try:
|
|
138
|
+
new_id = int(cout.split("OK:", 1)[1].strip())
|
|
139
|
+
except (ValueError, IndexError):
|
|
140
|
+
return _err(f"Clone succeeded but could not parse new ID from: {cout!r}",
|
|
141
|
+
resolved_name, post_id, from_, to)
|
|
142
|
+
|
|
143
|
+
# 3. Verify: compare source vs new-post content md5, and source meta digest vs
|
|
144
|
+
# the new post's meta digest. Both computed in PHP.
|
|
145
|
+
# content md5 (source from payload vs destination live)
|
|
146
|
+
content_php = (
|
|
147
|
+
'$d=unserialize(base64_decode("' + src_b64 + '"));'
|
|
148
|
+
'$src=isset($d["fields"]["post_content"])?$d["fields"]["post_content"]:"";'
|
|
149
|
+
'$dst=get_post(' + str(new_id) + ')->post_content;'
|
|
150
|
+
'echo md5($src)."|".md5($dst);'
|
|
151
|
+
)
|
|
152
|
+
content_ok = False
|
|
153
|
+
cvp, _, _ = run_php_eval(block, resolved_name, to, content_php, to_env, text=True)
|
|
154
|
+
cvo = (cvp.stdout or "").strip()
|
|
155
|
+
if cvp.returncode == 0 and "|" in cvo:
|
|
156
|
+
a, _, b = cvo.partition("|")
|
|
157
|
+
content_ok = (a == b)
|
|
158
|
+
|
|
159
|
+
# meta digest: source payload meta vs new-post live meta
|
|
160
|
+
src_meta_digest = _digest_php_from_b64_meta(src_b64)
|
|
161
|
+
s_ok, src_dig, _sn, _sp = _run_digest(block, resolved_name, to, src_meta_digest, to_env)
|
|
162
|
+
d_ok, dst_dig, _dn, _dp = _run_digest(block, resolved_name, to, _digest_php_from_db(new_id, None), to_env)
|
|
163
|
+
meta_ok = (s_ok and d_ok and src_dig == dst_dig)
|
|
164
|
+
|
|
165
|
+
# id-reference META keys (from the source meta key list) for manual review
|
|
166
|
+
id_ref_keys = _flag_id_ref_keys(source_meta_keys)
|
|
167
|
+
|
|
168
|
+
verified = content_ok and meta_ok
|
|
169
|
+
result = {
|
|
170
|
+
"verified": verified,
|
|
171
|
+
"cloned": True,
|
|
172
|
+
"new_id": new_id,
|
|
173
|
+
"source_post_id": post_id,
|
|
174
|
+
"site": resolved_name, "from": from_, "to": to,
|
|
175
|
+
"content_verified": content_ok,
|
|
176
|
+
"meta_verified": meta_ok,
|
|
177
|
+
"id_reference_keys": id_ref_keys,
|
|
178
|
+
"delivery": mode,
|
|
179
|
+
"from_transport": transport_label(block, from_),
|
|
180
|
+
"to_transport": transport_label(block, to),
|
|
181
|
+
}
|
|
182
|
+
if id_ref_keys:
|
|
183
|
+
result["note"] = (
|
|
184
|
+
"The listed meta keys hold ID references that point at the SOURCE database and "
|
|
185
|
+
"likely need manual remapping on the destination (their IDs differ across envs)."
|
|
186
|
+
)
|
|
187
|
+
if not verified:
|
|
188
|
+
result["error"] = (
|
|
189
|
+
"Clone written but verification failed "
|
|
190
|
+
f"(content_verified={content_ok}, meta_verified={meta_ok}). Investigate the new "
|
|
191
|
+
f"post {new_id} before trusting it."
|
|
192
|
+
)
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _digest_php_from_b64_meta(src_b64: str) -> str:
|
|
197
|
+
"""Digest the meta map inside a clone payload (base64(serialize({fields,meta})))."""
|
|
198
|
+
return (
|
|
199
|
+
'$d=unserialize(base64_decode("' + src_b64 + '"));'
|
|
200
|
+
'$m=isset($d["meta"])&&is_array($d["meta"])?$d["meta"]:array();'
|
|
201
|
+
'ksort($m);$s=serialize($m);echo md5($s)."|".strlen($s);'
|
|
202
|
+
)
|