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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-wp-cli-terminus = wp_cli_mcp:main
@@ -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
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env python3
2
+ """Entry point for `python -m wp_cli` (and the console-script when packaged)."""
3
+
4
+ from .transport import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
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"