mcp-wp-cli-terminus 0.1.0__py3-none-any.whl
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.1.0.dist-info/METADATA +169 -0
- mcp_wp_cli_terminus-0.1.0.dist-info/RECORD +11 -0
- mcp_wp_cli_terminus-0.1.0.dist-info/WHEEL +4 -0
- mcp_wp_cli_terminus-0.1.0.dist-info/entry_points.txt +2 -0
- mcp_wp_cli_terminus-0.1.0.dist-info/licenses/LICENSE +21 -0
- wp_cli_mcp/__init__.py +59 -0
- wp_cli_mcp/__main__.py +7 -0
- wp_cli_mcp/command.py +278 -0
- wp_cli_mcp/config.py +178 -0
- wp_cli_mcp/tools.py +596 -0
- wp_cli_mcp/transport.py +131 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-wp-cli-terminus
|
|
3
|
+
Version: 0.1.0
|
|
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
|
+
Project-URL: Homepage, https://github.com/EarthmanWeb/mcp-wp-cli-terminus
|
|
6
|
+
Project-URL: Repository, https://github.com/EarthmanWeb/mcp-wp-cli-terminus
|
|
7
|
+
Project-URL: Issues, https://github.com/EarthmanWeb/mcp-wp-cli-terminus/issues
|
|
8
|
+
Author: EarthmanWeb
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai,anthropic,claude,claude-code,cli,devops,llm,mcp,migration,model-context-protocol,pantheon,terminus,wordpress,wp,wp-cli
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
|
|
18
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# mcp-wp-cli-terminus
|
|
24
|
+
|
|
25
|
+
**An [MCP](https://modelcontextprotocol.io) (Model Context Protocol) server that lets Claude and other AI agents run [WP-CLI](https://wp-cli.org) against WordPress — over local Docker, [Pantheon Terminus](https://docs.pantheon.io/terminus), or SSH — and byte-faithfully copy posts and post meta between environments with checksum verification.**
|
|
26
|
+
|
|
27
|
+
Built for developers using **Claude Code / Claude Desktop** (or any MCP client) to operate **WordPress** sites — including **Pantheon** multidevs reached through **Terminus** — without hand-assembling fragile `wp eval` commands.
|
|
28
|
+
|
|
29
|
+
<sub>`wp-cli` · `wordpress` · `terminus` · `pantheon` · `mcp` · `model-context-protocol` · `claude` · `claude-code` · `anthropic` · `wordpress-migration` · `devops`</sub>
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Why
|
|
34
|
+
|
|
35
|
+
Moving a WordPress post's body or custom fields between environments (e.g. pushing a block-based front page from local to a Pantheon multidev) is deceptively hard to do correctly:
|
|
36
|
+
|
|
37
|
+
- `wp post update --post_content` **truncates at newlines** ([wp-cli#2712](https://github.com/wp-cli/wp-cli/issues/2712)).
|
|
38
|
+
- Piping content over STDIN **hangs on `terminus remote:wp`** ([terminus#1615](https://github.com/pantheon-systems/terminus/issues/1615)).
|
|
39
|
+
- Hand-pasting base64 into an agent prompt is **lossy** — a single flipped byte silently corrupts production.
|
|
40
|
+
- Large payloads passed as one shell argument hit the Linux **`MAX_ARG_STRLEN` (131072 bytes)** limit and fail with `E2BIG`.
|
|
41
|
+
- Post **meta** with serialized arrays / multiple values per key is easy to corrupt by re-serializing.
|
|
42
|
+
|
|
43
|
+
This server solves all of that: content is read, base64-encoded **in code**, delivered over a transport-safe path, and then **re-read and checksum-compared** to the source. A mismatch is reported, never silently trusted.
|
|
44
|
+
|
|
45
|
+
## Tools
|
|
46
|
+
|
|
47
|
+
| Tool | What it does |
|
|
48
|
+
|------|--------------|
|
|
49
|
+
| `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
|
+
| `wp_copy_post` | Byte-faithfully copy a post's `post_content` from one environment to another, with an **md5 round-trip verification**. |
|
|
51
|
+
| `wp_copy_post_meta` | Byte-faithfully copy a post's **complete** meta (all custom fields — serialized arrays, multiple values per key, ACF repeaters) between environments, with a canonical **checksum verification**. Copy all keys (full mirror) or an allow-list. |
|
|
52
|
+
|
|
53
|
+
### Correctness guarantees
|
|
54
|
+
|
|
55
|
+
- **Never routes content through the model's text.** Payloads are read into the server and base64-encoded in code.
|
|
56
|
+
- **Checksum-verified.** Every copy re-reads the destination and compares it to the transferred source; `verified: false` + an error on any mismatch.
|
|
57
|
+
- **Transport-agnostic.** The same logic runs over local Docker, Pantheon Terminus, and WP-CLI `--ssh`.
|
|
58
|
+
- **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
|
+
- **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.
|
|
60
|
+
- **Production guard.** Writes to a production destination require `confirm: true` when `PROD_GUARD` is enabled.
|
|
61
|
+
|
|
62
|
+
## Install & run
|
|
63
|
+
|
|
64
|
+
The server is **pure Python (stdlib only, zero dependencies)**.
|
|
65
|
+
|
|
66
|
+
### With `uvx` (recommended — no install)
|
|
67
|
+
|
|
68
|
+
```jsonc
|
|
69
|
+
// Claude Desktop / Claude Code MCP config
|
|
70
|
+
{
|
|
71
|
+
"mcpServers": {
|
|
72
|
+
"wp-cli": {
|
|
73
|
+
"command": "uvx",
|
|
74
|
+
"args": ["mcp-wp-cli-terminus"]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### With `pip`
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install mcp-wp-cli-terminus
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```jsonc
|
|
87
|
+
{
|
|
88
|
+
"mcpServers": {
|
|
89
|
+
"wp-cli": { "command": "mcp-wp-cli-terminus" }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### From source
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
git clone https://github.com/EarthmanWeb/mcp-wp-cli-terminus
|
|
98
|
+
cd mcp-wp-cli-terminus
|
|
99
|
+
python -m wp_cli_mcp # PYTHONPATH=src, or `pip install -e .`
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Configure
|
|
103
|
+
|
|
104
|
+
The server reads `<project-root>/.serena/wp-cli.conf` at runtime (set `CLAUDE_PROJECT_DIR` to point at your project). Copy [`wp-cli.conf.example`](wp-cli.conf.example) and edit:
|
|
105
|
+
|
|
106
|
+
```ini
|
|
107
|
+
DEFAULT_SITE=example-site
|
|
108
|
+
PROD_GUARD=true
|
|
109
|
+
|
|
110
|
+
[site:example-site]
|
|
111
|
+
LOCAL_CONTAINER=my-container # docker container running WP-CLI
|
|
112
|
+
LOCAL_PATH=/var/www/html # WordPress path inside the container
|
|
113
|
+
TERMINUS_SITE=example # Pantheon site — production routes over Terminus
|
|
114
|
+
TERMINUS_ENV=dev # default env (override per call)
|
|
115
|
+
# — or, for a non-Pantheon remote, omit TERMINUS_* and set:
|
|
116
|
+
# REMOTE_SSH=deploy@example.com:22/var/www/html
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- **Production transport is chosen by config:** `TERMINUS_SITE` → `terminus remote:wp`; otherwise `REMOTE_SSH` → WP-CLI `--ssh`.
|
|
120
|
+
- **Multi-site:** add more `[site:NAME]` sections and pass `site` per call.
|
|
121
|
+
|
|
122
|
+
**Never commit `.serena/wp-cli.conf`** — it may contain hostnames/SSH strings. The shipped [`.gitignore`](.gitignore) excludes it.
|
|
123
|
+
|
|
124
|
+
## Usage examples
|
|
125
|
+
|
|
126
|
+
```jsonc
|
|
127
|
+
// Run a WP-CLI command locally
|
|
128
|
+
{ "tool": "wp_cli", "args": "plugin list --status=active --format=json" }
|
|
129
|
+
|
|
130
|
+
// Run against production (Terminus or SSH per config)
|
|
131
|
+
{ "tool": "wp_cli", "args": "option get siteurl", "target": "production" }
|
|
132
|
+
|
|
133
|
+
// Copy a front page's block markup from local to production, verified
|
|
134
|
+
{ "tool": "wp_copy_post", "post_id": 42, "from": "local", "to": "production", "confirm": true }
|
|
135
|
+
|
|
136
|
+
// Copy ALL meta for a post (full mirror), verified
|
|
137
|
+
{ "tool": "wp_copy_post_meta", "post_id": 42, "from": "local", "to": "production", "confirm": true }
|
|
138
|
+
|
|
139
|
+
// Copy only specific meta keys
|
|
140
|
+
{ "tool": "wp_copy_post_meta", "post_id": 42, "from": "local", "to": "production",
|
|
141
|
+
"keys": ["_thumbnail_id", "my_field"], "confirm": true }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Each copy returns `verified: true/false` with `src_md5` / `dst_md5`, the delivery mode, and per-side transport.
|
|
145
|
+
|
|
146
|
+
## Debug logging
|
|
147
|
+
|
|
148
|
+
Failures (non-zero WP-CLI exits) are appended to a log in your system temp dir — **failures only**, successes are never logged:
|
|
149
|
+
|
|
150
|
+
- Location: `${TMPDIR}/wp-cli-mcp/failures.log` (override with `WP_CLI_MCP_LOG_DIR`).
|
|
151
|
+
- Disable entirely with `WP_CLI_MCP_LOG=0`.
|
|
152
|
+
- SSH connection strings are redacted in the log.
|
|
153
|
+
|
|
154
|
+
## Requirements
|
|
155
|
+
|
|
156
|
+
- Python 3.8+
|
|
157
|
+
- [WP-CLI](https://wp-cli.org) reachable via one of: a local Docker container (`docker exec`), Pantheon [Terminus](https://docs.pantheon.io/terminus) on the host, or a host WP-CLI with `--ssh`.
|
|
158
|
+
|
|
159
|
+
## Tests
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
python -m unittest discover -s tests -v
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
54 stdlib-only unit tests cover config parsing, transport selection (local/Terminus/SSH), the argv size guard, newline handling, PHP-key safety, and the full copy/verify orchestration via an injectable command-runner seam (no real WP-CLI invoked).
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
wp_cli_mcp/__init__.py,sha256=F1rzIFikYTp2NscHkbY06k7uOsoETjQX2YKW7-E9Hqg,1562
|
|
2
|
+
wp_cli_mcp/__main__.py,sha256=IJtKrKqAneO0nmmy1ilRxBEVBawVB7myVRVtR_DJvZg,172
|
|
3
|
+
wp_cli_mcp/command.py,sha256=AEub3m01wOFzbZt-LS3guBPmlsHz05siDMAd-jgF0tI,12441
|
|
4
|
+
wp_cli_mcp/config.py,sha256=7CSsab3gW2QmV7TzamyR2eGsPx_h6EyvWdPCRZhq4Zo,6513
|
|
5
|
+
wp_cli_mcp/tools.py,sha256=aV9LXXEi-iHJFIefDTmuZ2HdlSkDV6X-vTqWSt6AnhE,29207
|
|
6
|
+
wp_cli_mcp/transport.py,sha256=5pLEVFftnzG2pTFPwWbOB5t5EYBnsO8EEQzfxRfypM0,4810
|
|
7
|
+
mcp_wp_cli_terminus-0.1.0.dist-info/METADATA,sha256=CkM1OeiU3UsAtGje67YlzS2yEa26RClmmiwT4sql-8o,7911
|
|
8
|
+
mcp_wp_cli_terminus-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
9
|
+
mcp_wp_cli_terminus-0.1.0.dist-info/entry_points.txt,sha256=ye8OcxrHlxc-ESadDV6NqW4kVmcgns9Tv4NzfT-IrnM,56
|
|
10
|
+
mcp_wp_cli_terminus-0.1.0.dist-info/licenses/LICENSE,sha256=8zLVabyV4oHMDiRDUDnLfwhQ55ynSBNK3-67CEFNrhU,1068
|
|
11
|
+
mcp_wp_cli_terminus-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EarthmanWeb
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
wp_cli_mcp/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""wp_cli — a transport-agnostic WP-CLI MCP server package.
|
|
3
|
+
|
|
4
|
+
Exposes a stdio JSON-RPC MCP server with three tools:
|
|
5
|
+
wp_cli, wp_copy_post, wp_copy_post_meta.
|
|
6
|
+
|
|
7
|
+
Public API is re-exported here so callers (and tests) can `from wp_cli import ...`
|
|
8
|
+
without reaching into submodules.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .config import (
|
|
12
|
+
CONF_RELATIVE_PATH,
|
|
13
|
+
DESTRUCTIVE_PREFIXES,
|
|
14
|
+
SEARCH_REPLACE,
|
|
15
|
+
ConfigError,
|
|
16
|
+
get_project_root,
|
|
17
|
+
guard_enabled,
|
|
18
|
+
load_config,
|
|
19
|
+
require,
|
|
20
|
+
resolve_site,
|
|
21
|
+
)
|
|
22
|
+
from .command import (
|
|
23
|
+
MAX_ARG_STRLEN,
|
|
24
|
+
build_command,
|
|
25
|
+
is_destructive,
|
|
26
|
+
log_failure,
|
|
27
|
+
run_php_eval,
|
|
28
|
+
run_wp,
|
|
29
|
+
set_runner,
|
|
30
|
+
transport_label,
|
|
31
|
+
uses_terminus,
|
|
32
|
+
)
|
|
33
|
+
from .tools import (
|
|
34
|
+
TOOL_DEFINITIONS,
|
|
35
|
+
TOOL_REGISTRY,
|
|
36
|
+
tool_wp_cli,
|
|
37
|
+
tool_wp_copy_post,
|
|
38
|
+
tool_wp_copy_post_meta,
|
|
39
|
+
)
|
|
40
|
+
from .transport import (
|
|
41
|
+
SERVER_NAME,
|
|
42
|
+
SERVER_VERSION,
|
|
43
|
+
PROTOCOL_VERSION,
|
|
44
|
+
handle_initialize,
|
|
45
|
+
handle_tools_call,
|
|
46
|
+
handle_tools_list,
|
|
47
|
+
main,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"CONF_RELATIVE_PATH", "DESTRUCTIVE_PREFIXES", "SEARCH_REPLACE", "ConfigError",
|
|
52
|
+
"get_project_root", "guard_enabled", "load_config", "require", "resolve_site",
|
|
53
|
+
"MAX_ARG_STRLEN", "build_command", "is_destructive", "log_failure",
|
|
54
|
+
"run_php_eval", "run_wp", "set_runner", "transport_label", "uses_terminus",
|
|
55
|
+
"TOOL_DEFINITIONS", "TOOL_REGISTRY",
|
|
56
|
+
"tool_wp_cli", "tool_wp_copy_post", "tool_wp_copy_post_meta",
|
|
57
|
+
"SERVER_NAME", "SERVER_VERSION", "PROTOCOL_VERSION",
|
|
58
|
+
"handle_initialize", "handle_tools_call", "handle_tools_list", "main",
|
|
59
|
+
]
|
wp_cli_mcp/__main__.py
ADDED
wp_cli_mcp/command.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""WP-CLI MCP server — command construction, transport selection, execution seam.
|
|
3
|
+
|
|
4
|
+
Builds the full argv for each transport (local Docker / Pantheon Terminus / SSH),
|
|
5
|
+
detects destructive commands, and runs commands through an injectable runner seam
|
|
6
|
+
(set_runner) so orchestration can be unit-tested without a real WP-CLI. Stdlib only.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from typing import Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from .config import (
|
|
16
|
+
CONF_RELATIVE_PATH,
|
|
17
|
+
DESTRUCTIVE_PREFIXES,
|
|
18
|
+
SEARCH_REPLACE,
|
|
19
|
+
ConfigError,
|
|
20
|
+
require,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# ──────────────────────────────────────────────────────────────────
|
|
24
|
+
# Failure logging (temp dir, failures only)
|
|
25
|
+
# ──────────────────────────────────────────────────────────────────
|
|
26
|
+
#
|
|
27
|
+
# Debugging aid: when a WP-CLI invocation exits non-zero we append a record to a
|
|
28
|
+
# log FILE IN THE SYSTEM TEMP DIR (never inside the package/code tree). Successful
|
|
29
|
+
# calls are NOT logged — this keeps the log to actionable failures only. Override
|
|
30
|
+
# the location with WP_CLI_MCP_LOG_DIR; disable entirely with WP_CLI_MCP_LOG=0.
|
|
31
|
+
|
|
32
|
+
def _log_dir() -> str:
|
|
33
|
+
return os.environ.get("WP_CLI_MCP_LOG_DIR") or os.path.join(tempfile.gettempdir(), "wp-cli-mcp")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _logging_enabled() -> bool:
|
|
37
|
+
return os.environ.get("WP_CLI_MCP_LOG", "1") not in ("0", "false", "no", "off")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def log_failure(argv, returncode, stderr, note: str = ""):
|
|
41
|
+
"""Append a failure record to the temp-dir log. Best-effort: never raises."""
|
|
42
|
+
if not _logging_enabled():
|
|
43
|
+
return
|
|
44
|
+
try:
|
|
45
|
+
d = _log_dir()
|
|
46
|
+
os.makedirs(d, exist_ok=True)
|
|
47
|
+
if isinstance(stderr, (bytes, bytearray)):
|
|
48
|
+
stderr = stderr.decode("utf-8", "replace")
|
|
49
|
+
# Redact anything after --ssh= (may contain a host/user) for safety.
|
|
50
|
+
safe_argv = []
|
|
51
|
+
for a in argv:
|
|
52
|
+
safe_argv.append(a.split("=", 1)[0] + "=<redacted>" if a.startswith("--ssh=") else a)
|
|
53
|
+
stamp = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
54
|
+
line = (
|
|
55
|
+
f"[{stamp}] rc={returncode}"
|
|
56
|
+
+ (f" note={note}" if note else "")
|
|
57
|
+
+ f"\n cmd: {' '.join(safe_argv)}"
|
|
58
|
+
+ f"\n stderr: {(stderr or '').strip()[:2000]}\n"
|
|
59
|
+
)
|
|
60
|
+
with open(os.path.join(d, "failures.log"), "a", encoding="utf-8") as fh:
|
|
61
|
+
fh.write(line)
|
|
62
|
+
except Exception:
|
|
63
|
+
pass # logging must never break the tool
|
|
64
|
+
|
|
65
|
+
# Linux caps a SINGLE argv/env string at MAX_ARG_STRLEN = 32 * PAGE_SIZE (4096)
|
|
66
|
+
# = 131072 bytes, independent of total ARG_MAX. A `wp eval <php>` invocation
|
|
67
|
+
# passes the whole PHP as one argv element, so large payloads must go via STDIN
|
|
68
|
+
# (`wp eval-file -`) which is exempt. This is the ceiling we pre-check against.
|
|
69
|
+
MAX_ARG_STRLEN = 131072
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ──────────────────────────────────────────────────────────────────
|
|
73
|
+
# Destructive detection
|
|
74
|
+
# ──────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def _leading_words(arg_tokens: List[str]) -> List[str]:
|
|
77
|
+
"""Return the leading non-flag tokens (the WP-CLI command path)."""
|
|
78
|
+
words = []
|
|
79
|
+
for tok in arg_tokens:
|
|
80
|
+
if tok.startswith("-"):
|
|
81
|
+
break
|
|
82
|
+
words.append(tok)
|
|
83
|
+
return words
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_destructive(arg_tokens: List[str]) -> bool:
|
|
87
|
+
"""True if the command matches a destructive prefix.
|
|
88
|
+
|
|
89
|
+
search-replace is destructive UNLESS --dry-run is present.
|
|
90
|
+
"""
|
|
91
|
+
words = _leading_words(arg_tokens)
|
|
92
|
+
|
|
93
|
+
if words[:1] == SEARCH_REPLACE:
|
|
94
|
+
return "--dry-run" not in arg_tokens
|
|
95
|
+
|
|
96
|
+
for prefix in DESTRUCTIVE_PREFIXES:
|
|
97
|
+
if words[: len(prefix)] == prefix:
|
|
98
|
+
return True
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def uses_terminus(block: Dict[str, str]) -> bool:
|
|
103
|
+
"""Production transport for this site is Terminus iff TERMINUS_SITE is set."""
|
|
104
|
+
return bool(block.get("TERMINUS_SITE", "").strip())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ──────────────────────────────────────────────────────────────────
|
|
108
|
+
# Command construction
|
|
109
|
+
# ──────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
def build_command(
|
|
112
|
+
block: Dict[str, str],
|
|
113
|
+
site_name: str,
|
|
114
|
+
target: str,
|
|
115
|
+
arg_tokens: List[str],
|
|
116
|
+
env: Optional[str] = None,
|
|
117
|
+
*,
|
|
118
|
+
stdin: bool = False,
|
|
119
|
+
) -> List[str]:
|
|
120
|
+
"""Build the full argv for the requested target.
|
|
121
|
+
|
|
122
|
+
local: docker exec [-i] [-w WORKDIR] CONTAINER wp --path=PATH <args> --allow-root
|
|
123
|
+
production (ssh): docker exec [-i] CONTAINER wp --ssh=REMOTE_SSH <args> --allow-root
|
|
124
|
+
production (terminus): terminus remote:wp TERMINUS_SITE.<env> -- <args>
|
|
125
|
+
|
|
126
|
+
Terminus runs on the HOST (not via docker exec) and takes no --path/--allow-root.
|
|
127
|
+
The production transport is chosen by the site's conf: TERMINUS_SITE → Terminus,
|
|
128
|
+
else REMOTE_SSH → WP-CLI --ssh.
|
|
129
|
+
|
|
130
|
+
`stdin=True` adds `docker exec -i` for the Docker transports so a piped payload
|
|
131
|
+
actually reaches the process (without -i, docker exec delivers an empty STDIN).
|
|
132
|
+
Terminus does not use STDIN (it hangs — Pantheon #1615), so the flag is ignored
|
|
133
|
+
there.
|
|
134
|
+
"""
|
|
135
|
+
if target == "production" and uses_terminus(block):
|
|
136
|
+
terminus_site = require(block, "TERMINUS_SITE", site_name)
|
|
137
|
+
resolved_env = (env or block.get("TERMINUS_ENV", "")).strip()
|
|
138
|
+
if not resolved_env:
|
|
139
|
+
raise ConfigError(
|
|
140
|
+
f"No Terminus environment for [site:{site_name}]. Set TERMINUS_ENV in "
|
|
141
|
+
f"{CONF_RELATIVE_PATH} or pass env=<dev|test|live|...>."
|
|
142
|
+
)
|
|
143
|
+
cmd: List[str] = ["terminus", "remote:wp", f"{terminus_site}.{resolved_env}", "--"]
|
|
144
|
+
cmd += arg_tokens
|
|
145
|
+
return cmd
|
|
146
|
+
|
|
147
|
+
container = require(block, "LOCAL_CONTAINER", site_name)
|
|
148
|
+
cmd = ["docker", "exec"]
|
|
149
|
+
if stdin:
|
|
150
|
+
cmd.append("-i")
|
|
151
|
+
|
|
152
|
+
if target == "production":
|
|
153
|
+
remote_ssh = require(block, "REMOTE_SSH", site_name)
|
|
154
|
+
cmd += [container, "wp", f"--ssh={remote_ssh}"]
|
|
155
|
+
cmd += arg_tokens
|
|
156
|
+
cmd += ["--allow-root"]
|
|
157
|
+
else:
|
|
158
|
+
path = require(block, "LOCAL_PATH", site_name)
|
|
159
|
+
workdir = block.get("LOCAL_WORKDIR", "")
|
|
160
|
+
if workdir:
|
|
161
|
+
cmd += ["-w", workdir]
|
|
162
|
+
cmd += [container, "wp", f"--path={path}"]
|
|
163
|
+
cmd += arg_tokens
|
|
164
|
+
cmd += ["--allow-root"]
|
|
165
|
+
|
|
166
|
+
return cmd
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def transport_label(block: Dict[str, str], target: str) -> str:
|
|
170
|
+
"""Human-readable transport for a side: local / terminus / ssh."""
|
|
171
|
+
if target != "production":
|
|
172
|
+
return "local"
|
|
173
|
+
return "terminus" if uses_terminus(block) else "ssh"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ──────────────────────────────────────────────────────────────────
|
|
177
|
+
# Execution seam
|
|
178
|
+
# ──────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
# Command runner seam: production uses subprocess.run; tests inject a fake via
|
|
181
|
+
# set_runner() to exercise orchestration without a real WP-CLI. The runner
|
|
182
|
+
# receives (argv, text, stdin) and returns an object with
|
|
183
|
+
# .returncode/.stdout/.stderr (a subprocess.CompletedProcess in production).
|
|
184
|
+
# `stdin` (bytes or None) is fed to the process's standard input — used for the
|
|
185
|
+
# large-payload `wp eval-file -` path that sidesteps the argv size limit.
|
|
186
|
+
def _default_runner(argv: List[str], text: bool, stdin=None):
|
|
187
|
+
return subprocess.run(argv, capture_output=True, text=text, input=stdin)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
_RUNNER = _default_runner
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def set_runner(fn):
|
|
194
|
+
"""Override the command runner (tests only). Pass None to restore the default.
|
|
195
|
+
|
|
196
|
+
The runner is called as fn(argv, text, stdin=None). Fakes may accept just
|
|
197
|
+
(argv, text) if they never exercise the STDIN path, but supporting the third
|
|
198
|
+
parameter is recommended.
|
|
199
|
+
"""
|
|
200
|
+
global _RUNNER
|
|
201
|
+
_RUNNER = fn or _default_runner
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _call_runner(argv, text, stdin=None):
|
|
205
|
+
"""Invoke the runner, tolerating fakes that only accept (argv, text).
|
|
206
|
+
|
|
207
|
+
Logs (to the temp-dir failure log) any invocation that exits non-zero, so
|
|
208
|
+
failures leave a durable, greppable trace. Successes are not logged.
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
proc = _RUNNER(argv, text, stdin)
|
|
212
|
+
except TypeError:
|
|
213
|
+
if stdin is not None:
|
|
214
|
+
raise
|
|
215
|
+
proc = _RUNNER(argv, text)
|
|
216
|
+
rc = getattr(proc, "returncode", None)
|
|
217
|
+
if rc not in (0, None):
|
|
218
|
+
log_failure(argv, rc, getattr(proc, "stderr", ""))
|
|
219
|
+
return proc
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def run_wp(block: Dict[str, str], site_name: str, target: str, arg_tokens: List[str], env: Optional[str], *, text: bool = True):
|
|
223
|
+
"""Run a single WP-CLI invocation on the given side and return (proc, full_cmd).
|
|
224
|
+
|
|
225
|
+
`text=False` captures raw bytes (used when reading post_content so no locale
|
|
226
|
+
re-encoding can alter it). The command is built via build_command(), so
|
|
227
|
+
transport selection (Docker/Terminus/SSH) is identical everywhere. Execution
|
|
228
|
+
is delegated to the injectable runner seam.
|
|
229
|
+
"""
|
|
230
|
+
full_cmd = build_command(block, site_name, target, arg_tokens, env)
|
|
231
|
+
return _call_runner(full_cmd, text), full_cmd
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def run_php_eval(block, site_name, target, php, env, *, text=True):
|
|
235
|
+
"""Execute a PHP snippet via WP-CLI, choosing a transport-safe delivery.
|
|
236
|
+
|
|
237
|
+
A `wp eval <php>` call passes the whole PHP as ONE argv element, which Linux
|
|
238
|
+
caps at MAX_ARG_STRLEN (131072 bytes) — large post_content/meta payloads blow
|
|
239
|
+
past it with E2BIG. To stay correct on every transport:
|
|
240
|
+
|
|
241
|
+
- local / ssh (docker exec): deliver the PHP on STDIN via `wp eval-file -`,
|
|
242
|
+
which is exempt from the argv size limit and works for any payload size.
|
|
243
|
+
NOTE: `wp eval-file` executes a PHP *file*, so the payload MUST start with a
|
|
244
|
+
`<?php` open tag or WP-CLI just echoes it verbatim (proven on WP-CLI 2.12).
|
|
245
|
+
`wp eval` (the argv form) is the opposite — it wants a raw expression with
|
|
246
|
+
NO open tag. We add the tag only on the STDIN path.
|
|
247
|
+
- terminus: STDIN piping hangs over `terminus remote:wp` (Pantheon issue
|
|
248
|
+
#1615), so we must use the argv `wp eval` form (no open tag). If the single
|
|
249
|
+
token would exceed MAX_ARG_STRLEN we FAIL LOUD with a clear error rather than
|
|
250
|
+
emit a raw E2BIG.
|
|
251
|
+
|
|
252
|
+
`php` is a raw expression body (no `<?php`); this function adds the tag where
|
|
253
|
+
the chosen delivery needs it. Returns (proc, full_cmd, mode); mode is
|
|
254
|
+
"stdin" or "argv".
|
|
255
|
+
"""
|
|
256
|
+
php_str = php.decode("utf-8") if isinstance(php, (bytes, bytearray)) else php
|
|
257
|
+
|
|
258
|
+
if target == "production" and uses_terminus(block):
|
|
259
|
+
# Terminus: argv `wp eval` (raw expression, no open tag). Guard the size.
|
|
260
|
+
token_len = len(php_str.encode("utf-8"))
|
|
261
|
+
if token_len >= MAX_ARG_STRLEN:
|
|
262
|
+
raise ConfigError(
|
|
263
|
+
f"PHP eval payload is {token_len} bytes, over the {MAX_ARG_STRLEN}-byte "
|
|
264
|
+
f"single-argument execve limit, and the Terminus transport cannot use STDIN "
|
|
265
|
+
f"(hangs — Pantheon issue #1615). This post/meta is too large to copy to a "
|
|
266
|
+
f"Terminus destination in one call. Copy to a local/SSH destination, or split "
|
|
267
|
+
f"the payload."
|
|
268
|
+
)
|
|
269
|
+
full_cmd = build_command(block, site_name, target, ["eval", php_str], env)
|
|
270
|
+
return _call_runner(full_cmd, text), full_cmd, "argv"
|
|
271
|
+
|
|
272
|
+
# local / ssh: STDIN via `wp eval-file -` (no argv size limit). eval-file runs
|
|
273
|
+
# a PHP FILE, so prepend the `<?php` open tag; requires `docker exec -i`
|
|
274
|
+
# (stdin=True) or the payload arrives empty.
|
|
275
|
+
file_php = "<?php " + php_str
|
|
276
|
+
full_cmd = build_command(block, site_name, target, ["eval-file", "-"], env, stdin=True)
|
|
277
|
+
payload = file_php if text else file_php.encode("utf-8")
|
|
278
|
+
return _call_runner(full_cmd, text, payload), full_cmd, "stdin"
|