--- name: wordpress-cli-remote-execution description: Use when running wp-cli commands against a WordPress site living inside a remote jail/container (via ssh + a jail-exec wrapper). Covers the output-prefix stripping gotcha, the correct user/path invocation pattern, pre-write environment/blast-radius inspection, multisite targeting, and safe search-replace/db-export-import/cache-flush workflows. --- # WordPress CLI Remote Execution ## Invocation pattern ```bash ssh user@host "bastille cmd sh -c 'cd /path/to/site && su -m www -c \"wp --path=/path/to/site\"'" ``` Key points: - Run wp-cli as the **web server user** (`www`, `www-data`, etc.), not root — WordPress file ownership assumptions and some plugin behavior depend on this. - Always pass `--path=` explicitly rather than relying on `cd` alone propagating through every nested layer correctly. - This is not a theoretical concern on this fleet: `docs/server-granja.md` documents a real incident where `wp-content` (the `upgrade` dir, `languages/`, and several plugin dirs) drifted to `root:www` ownership from `wp` commands run as root via `bastille cmd ... wp ... --allow-root` instead of switching to `www` first, which then broke every admin-initiated core/plugin update until a `chown -R www:www` fixed it. Prefer the `su -m www` pattern above over `--allow-root`-as-root for exactly this reason. ## Guardrails: confirm environment and blast radius before any write Before running anything that writes (plugin/theme changes, `search-replace`, `db import`/`db reset`, bulk deletes, cron triggers, cache/rewrite flushes on a busy site), confirm you're pointed at the right target. This fleet runs 13+ real WordPress jails on granja alone (`docs/server-granja.md`), plus more on staging and gringo (`docs/server-staging.md`, `docs/server-gringo.md`) — mixing up a jail name or `--path` when several `ssh`/`bastille` panes are open is the easiest way to write to the wrong site. Don't assume production is safe to write to just because a command "should" be harmless. Run this sequence (through the invocation pattern above, one `wp` call at a time so each exit code/output is unambiguous) before the real operation: ```bash # 1. Confirm wp-cli actually resolves at this exact path/jail ssh user@host "bastille cmd sh -c 'su -m www -c \"wp --path=/path/to/site core is-installed\"'" # 2. Confirm which site this really is — read it back, don't assume from the jail name ssh user@host "bastille cmd sh -c 'su -m www -c \"wp --path=/path/to/site option get siteurl\"'" # 3. Confirm core version, for context ssh user@host "bastille cmd sh -c 'su -m www -c \"wp --path=/path/to/site core version\"'" # 4. Confirm single-site vs multisite BEFORE choosing --url targeting (see below) ssh user@host "bastille cmd sh -c 'su -m www -c \"wp --path=/path/to/site core is-installed --network\"'" ``` Step 4's exit code is the signal: `0` means the install is network-activated (multisite); nonzero means an ordinary single site. Don't skip this check and assume — this fleet's WordPress jails haven't all been surveyed for multisite, and treating a multisite install as single-site (or vice versa) is exactly the kind of mistake that silently affects the wrong site. ## Multisite targeting checklist If step 4 above confirms multisite (or you're unsure and haven't checked): - Every per-site command needs an explicit `--url=` — an omitted `--url` on a multisite install can operate on the network's primary site instead of the one you actually meant, with no error to warn you. - `wp --path=/path/to/site site list` to see every site in the network before touching any of them. - `wp --path=/path/to/site option get siteurl --url=` to confirm you're targeting the intended site within the network. - For a change that's meant to apply network-wide, prefer scripting it as list-then-iterate (`site list` → loop → run the safe per-site command) over trusting a single `--network`-flagged command to do the right thing for every site's data — a loop you can log and interrupt is easier to recover from than one opaque network-wide mutation. The jail audits in `docs/server-granja.md`, `docs/server-staging.md`, and `docs/server-gringo.md` map each jail to a single domain via `wp option get siteurl` — but that check confirms the site's URL, not whether the install itself is network-activated. None of those audits ran `core is-installed --network`, so multisite status per jail is genuinely unverified, not "probably fine because it's one domain per jail." Run step 4 above rather than assuming either way for a jail you haven't personally checked. ## Safe `wp search-replace` / domain migration workflow Follow this sequence for any URL/domain change, protocol switch (`http://` → `https://`), or path migration — don't run `search-replace` directly against production without it: 1. **Backup first:** `wp --path=/path/to/site db export` (through the invocation pattern above) before touching anything. Confirm the backup file actually landed and is non-empty — `wordpress-plugin-staging-verification` documents a cosmetic `proc_open`/`posix_spawn` error some `wp-cli` write commands print in this sandbox even when the underlying operation actually succeeded (and, less often, the reverse) — don't trust the command's own success/failure text alone for something this important; check the file. 2. **Dry run:** `wp search-replace 'OLD' 'NEW' --dry-run --all-tables-with-prefix` and read the reported row count before doing anything else. 3. **Real replace:** the same command minus `--dry-run`. Add `--skip-columns=...` for any known binary/blob columns, and `--precise` if the dry run surfaced questionable matches. 4. **Flush after:** `wp cache flush` then `wp rewrite flush` — a stale object cache or stale rewrite rules are the most common "why does the old URL still show up" symptom after a migration that actually worked fine at the DB level. On multisite, decide up front whether the replace is scoped to one site (`--url=...`) or the whole network (iterate `wp site list`, per the checklist above) — the same "which site did this actually touch" risk applies here as everywhere else in this skill. ## DB export/import guardrails - `wp db export` before any of the high-risk operations below — cheap insurance, always take it. - `wp db import` **overwrites the target database wholesale.** Re-confirm the jail/site one more time immediately before running it (repeat step 2 of the guardrails sequence above) — this is the single easiest way to destroy the wrong site's data when several `ssh`/`bastille` sessions are open at once across this fleet's jails. - Treat these as requiring explicit confirmation before running, same as bulk deletes (`wp post delete --force --all`, `wp user delete --reassign`) and mass plugin/theme updates on a live production jail. ## Plugin/theme operations guardrails - `wp plugin list` / `wp theme list` first, to see current state before changing it. - Avoid `wp plugin update --all` / `wp theme update --all` against a production jail without a deliberate maintenance window — this fleet's own 2026-08-12 fleet-wide core-security rollout (CVE-2026-65640, `docs/server-granja.md`) was done as a coordinated pass across all 13 jails, not a casual one-off `update --all`. - On multisite, plugin activation can be per-site or network-activated — confirm which one is intended (see the multisite checklist above) before running `wp plugin activate`. ## Cron and cache/rewrite flush guardrails - `wp cron event list` to see what's actually scheduled before running anything. - `wp cron event run ` to run one specific event for debugging, rather than triggering every scheduled event at once. - `wp cache flush` / `wp rewrite flush` are generally low-risk but can cause a brief load spike on a high-traffic content site — worth confirming intent rather than flushing reflexively as a first debugging step. ## GOTCHA: jail-exec wrappers prefix their own output A wrapper like `bastille cmd ` prints a `[jailname]:` header line before the actual command output — this is the wrapper's own framing, not part of the real output. Any script that captures this output for further parsing must filter it out: ```bash OUTPUT=$(ssh user@host "bastille cmd " 2>&1 | grep -v "^\[\]:$") ``` Forgetting this is a common, easy-to-miss source of "the command mysteriously failed to parse" bugs when scripting on top of jail-exec wrappers. ## GOTCHA: `error_reporting(0)` in bootstrap files can mask real errors Some CMS/application bootstrap files set `error_reporting(0)` globally near the very top (often to suppress noisy legacy warnings). If you're writing a standalone diagnostic/one-off script that `require`s such a bootstrap file, your own errors after that point are ALSO silently suppressed — a script can fail with **zero output and just a bad exit code**, giving no clue why. Fix: explicitly `error_reporting(E_ALL); ini_set('display_errors', '1');` again **after** requiring the bootstrap, and add a `register_shutdown_function` that dumps `error_get_last()` — this turns a silent failure into an actual, readable error message. ## GOTCHA: glob patterns fail silently under some remote shells `bastille cmd grep -rln 'pattern' /some/path/*.php` can fail with `zsh: no matches found: ...` if the jail's default shell is zsh and the glob doesn't expand as expected in that invocation context — with no useful indication that this is a shell-globbing issue rather than "no files matched." Fix: drop the glob and grep the directory recursively instead (`grep -rln 'pattern' /some/path/`, no trailing `/*.ext`) — this avoids the shell needing to expand anything at all. ## When you need to run PHP with full application bootstrap, standalone If you need to call an application's own PHP functions/classes outside the normal web request flow (e.g. to directly test a code path), don't guess at which files to `require` — trace the **actual real bootstrap sequence** the application's own front controller uses (e.g. its `index.php`) by reading it, and replicate that exact require order in your standalone script. Skipping steps because "this part probably isn't needed" reliably produces a cascade of "undefined function/class" errors that have to be debugged one require statement at a time — reading the real bootstrap sequence once up front is faster than that cascade. ## Provenance The guardrails/blast-radius sequencing, multisite targeting checklist, and safe search-replace/db-export-import/cache-flush workflow above were adapted from WordPress's own official `wp-wpcli-and-ops` skill (https://github.com/WordPress/agent-skills/tree/trunk/skills/wp-wpcli-and-ops, also listed at https://skills.sh/wordpress/agent-skills/wp-wpcli-and-ops), reworked for this fleet's `bastille cmd` + `su -m www` invocation model rather than the upstream skill's assumption of local or SSH-direct WP-CLI access. Its bundled `wpcli_inspect.mjs` Node.js inspector script was not copied — this fleet doesn't run wp-cli operations through Node tooling — the same inspection sequence is reproduced above as plain `wp` calls through the existing invocation pattern instead. Notably, the upstream skill's own debugging notes independently warn against `--allow-root` "unless you understand the environment and have no alternative," which lines up with the real `wp-content` ownership-drift incident cited in the Invocation pattern section above — two independent sources converging on the same conclusion for this fleet.