Files
MalinandClaude Sonnet 5 d6ad3fa684 Merge multisite/blast-radius safety practices into wordpress-cli-remote-execution
Both the skills.sh and autoskills.sh scans (dispatched this session, see
granja/_temp/codex-logs/{skills-sh-scan,autoskills-sh-scan}.md) independently
flagged wordpress/agent-skills' official wp-wpcli-and-ops module as high-value
source material for this skill: safe search-replace, db export/import,
multisite targeting, and deterministic environment inspection before any
destructive command.

Merged in (adapted to this fleet's bastille cmd + su -m www invocation model,
not the upstream's local/SSH-direct WP-CLI assumption):
- pre-write environment/blast-radius inspection sequence (plain wp-cli calls
  through the existing invocation pattern, not the upstream's Node.js
  wpcli_inspect.mjs script)
- multisite targeting checklist (--url requirement, site list iteration)
- safe search-replace/domain-migration workflow (backup, dry-run, flush)
- db export/import, plugin/theme, and cron/cache-flush guardrails

Ties the upstream --allow-root warning to this fleet's own documented
wp-content ownership-drift incident (docs/server-granja.md) and cites the
real jail counts/CVE rollout from docs/server-granja.md, server-staging.md,
and server-gringo.md for grounding. Provenance noted in the file with source
URLs. Deliberately left out generic plugin-development/performance/PHPStan
material from the same upstream repo — out of scope for this remote-execution
skill.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 21:25:44 +02:00

224 lines
12 KiB
Markdown

---
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 <jail-name> sh -c 'cd /path/to/site && su -m www -c \"wp <command> --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 <jail-name> 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 <jail-name> 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 <jail-name> 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 <jail-name> 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=<site-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=<site-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 <hook>` 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 <jail>` 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 <jail> <command>" 2>&1 | grep -v "^\[<jail>\]:$")
```
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 <jail> 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.