Audit, clean, optimize, and verify WordPress sites — from FTP or an offline zip + SQL dump.
wpt is a Python 3.12+ command-line toolkit for WordPress site operations. It
treats a site as a reproducible artifact: a live FTP webroot, an offline zip +
MySQL dump. Every command plans first, mutates only on
request, snapshots what it touches, and verifies the result.
It is built for migrations, incident response, and recurring maintenance — the moments when a site is too important for an unverified one-liner.
- Plan, then apply. Every mutating command prints a dry-run plan.
--applyexecutes it;--yesskips the interactive confirmation. - Snapshots and rollback. FTP mutations snapshot their affected scope first (fail-closed). Local mode rehearses into a new zip and leaves the original untouched.
- One audit, 38 checks. Core integrity, security, database health, media, performance, and configuration — with a Markdown report and a JSON twin.
- Works offline. Audit and rehearse from a zip + SQL dump with no live server, then deploy or verify when the site is reachable.
- Self-bounding. Deadlines, circuit breakers, and graceful SIGTERM handling keep a bad FTP channel from turning into an all-day run.
- Secrets stay out of files. Passwords normally come from environment
variables or prompts, and the single logging path redacts them before they
reach a log, report, or exception. An inline
--db-uripassword is accepted for compatibility, but it is visible in shell history; use--db-password-envinstead.
From a checkout of this repository:
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -e ".[db]"
wpt --versionThe db extra enables offline --db-uri operations and the re-dump writer.
When published, the same install is available as pip install "wpt[db]".
Requirements: Python 3.12+, PHP 8.3+ for the bridge and PHP probes, and OpenSSL for the local proxy certificate. The full test suite is POSIX; Windows is covered by a wheel/CLI smoke test.
Audit an offline archive and dump:
wpt --source local --zip site.zip --sql dump.sql audit --report audit.mdAudit a live site without changing DNS:
export FTP_PASS='...'
wpt --source ftp://host:21 --user U --password-env FTP_PASS \
--webroot domains/site/public_html --wp-url https://site.test \
--override-ip 192.0.2.44 audit --report audit.mdPreview and apply a cleanup:
wpt --source local --zip site.zip --sql dump.sql clean-files
wpt --source local --zip site.zip --sql dump.sql clean-files --apply --yesThe first command is a plan. The second executes it. --report writes the
human-readable report plus a JSON twin for automation.
Every command accepts --log-level, --log-file PATH (adds a file sink; the
console keeps receiving everything), --log-json, and --quiet (console
only). The other global options are --version, --profile PATH (a settings
overlay), --set key=value (repeatable), --backup-dir DIR, and
--db-password-env VAR (the password for --db-uri).
There is one logger, two handlers, and the same redaction filter on both. A refusal is never only in the file: the console and the report agree.
Every command works from either source unless its row says otherwise. Run
wpt <command> --help for the full flag set.
Self-bounding runs (never killed from outside): every command accepts
--deadline SECONDS (default run.deadline_s = 3600; 0 = unbounded) and
ends ITSELF through the normal error contract when the budget expires —
cleanup, teardown, rc 1. SIGTERM is likewise a cooperative stop the run
answers gracefully. The two long-running listeners are the deliberate
exception: serve and proxy own their process until interrupted, so
--deadline does not stop their accept loop (SIGTERM still ends both
cleanly through the same teardown). Bulk FTP reads run through
ftp.parallel_reads (default 4) dedicated worker sessions with two channel
bounds: a consecutive-failure breaker and a slow-op window (10 near-timeout
ops = trip), so both a dead and a merely crawling passive channel end in
minutes instead of hours.
The command set (all work from either source unless noted):
| command | what it does |
|---|---|
audit |
Full read-only audit (38 checks); --only / --skip select checks (an unknown or mode-inapplicable name fails loud, never an empty report); --baseline prev.json diffs against a previous run (NEW / resolved / changed, naming the fields that differ: detail, count, risk, bytes); writes markdown + JSON with --report |
updates |
The audit restricted to core/plugin/theme update checks |
clean-files |
Delete junk/backup/stale/migration files from the webroot |
clean-db |
Named DB cleanups via the bridge (revisions, transients, orphan meta, ...); offline against a direct database with --db-uri (needs PyMySQL), --re-dump writes a fresh dump — FTP only for the bridge; --db-uri runs the same plan offline in local mode |
prune-core |
Delete STALE WordPress core files the current release no longer ships — proven by the previous release's checksums or a core_integrity.known_stale signature; unknown files stay investigate-only (snapshot first, survivors re-walked after) |
park-backups |
Move full-site backup payloads (*.zip, *.tar.gz, dead duplicate webroots, …) OUT of the document root — never deletes them; degrades to advice without a provable reference set or an outside destination |
cull-media |
Delete oversized image DERIVATIVES of dead stem families (measured, fail-closed, never resizes) — FTP only |
webp-media |
Convert uploads rasters to WebP and rewrite every reference (posts/postmeta/options rows, sizes[*].file basenames, generated CSS url()); animated/CMYK/>16383px/tiny/data-tree/duplicate images are skipped with a report line, originals are KEPT unless --purge-originals deletes them after the rewritten set verifies |
update-core |
Update WordPress core to an EXPLICIT --to X.Y.Z (never implicit): the plan lists the target's db_version, the exact replaced file set, whether the chosen driver is WordPress's own extractor/copy or the tool's tree swap, the release's PHP/MySQL floors and the snapshot scope; --apply proves the release against wp.org's checksums before writing, writes with the selected driver (swap: each core tree staged beside its original and swapped by one rename under .maintenance; wordpress: core's own extractor/copier overwrites, then the plan's stale files are removed), migrates the database through core's own wp_upgrade(), then verifies the versions, every digest and the live site. FTP snapshots the replaced trees + every table first; local runs the same sequence into a rehearsed copy and the ORIGINAL zip is the rollback (no pre-action snapshot, no tables) |
deploy |
Stream a prepared webroot zip into a host over SSH without the archive ever landing there (quota preflight → normalized tar stream → layout assertion → manifest verification); additive, never deletes — local mode rehearses into local:<dir> |
trim-media |
Delete media not referenced by live content — the keep-set is the UNION of the live walk and the database references (a walk-only set deletes what the walk never sees); --apply is fail-closed until the full union is measurable |
optimize-media |
Recompress images in place (per-file local backups kept) |
harden |
Apply .htaccess/wp-config hardening (diff-printed, idempotent) |
deactivate-plugin <slug> |
Deactivate a plugin safely (options-table snapshot first; reactivate in wp-admin or restore --undo-last --table wp_options) — FTP only |
users_reset --login LOGIN |
Reset the named account's password to a random value (--login repeatable; a run with no target refuses, --all resets every user); the new credentials print once on stdout (never in plan/report/log), session tokens destroyed; snapshots wp_users + wp_usermeta — FTP only |
user-create --login LOGIN --email EMAIL |
Create a new WordPress user (administrator unless --role); the plan validates login/email/role read-only, --apply creates through core's wp_insert_user() and reads the account back; the generated password prints once on stdout (never in plan/report/log) — FTP only |
reconcile-plugins |
Rewrite active_plugins against the installed plugin set (deactivates entries whose plugin is gone; wp_options snapshot first, verified by re-read) — FTP only |
heal-core |
Restore checksum-proven tampered/missing WordPress core files from the official wp.org release of the site's exact version (snapshot first, md5-verified after; the "added" files are listed for investigation, never auto-deleted) |
verify |
Re-walk the site and re-fetch EVERY live reference — rc 1 if any is broken (ftp; or local + --wp-url against a serve rehearsal) |
backup |
Full-site snapshot: every walked file + every table, manifest + payloads under .wpt-backup/ (ftp only); every manifest records the current identity keys site_raw + webroot |
restore |
List snapshots, or restore one — --undo-last rolls back the newest; targetable with --file, --path-prefix, --table, --config, --media; dry-run unless --apply (listing works from either source; applying a restore is FTP only). Snapshots require the current identity format (site_raw + webroot); a pre-identity manifest is refused, managed or explicit, with a regenerate-the-snapshot message — there is no legacy migration/import path |
walk |
Crawl the live site read-only: pages visited, media references, forms (ftp only) |
serve |
Serve a webroot zip on http://127.0.0.1:<port>/ — the offline rehearsal target for verify (local only) |
doctor |
Preflight self-check: ftp+webroot, WordPress layout, write access, standalone PHP runtime, bridge round-trip, site reachability, TLS certificate/policy, host error log, wp.org API, disk — PASS/WARN/FAIL in seconds |
scan |
Source-only dangerous-PHP-pattern scan (request-driven session_id, reflected request data, SQL/table identifiers, includes, code execution, unserialize) — no WordPress/bridge/DB; local mode needs --zip only; a truncated scan emits scan-partial and exits 1 |
logs |
Read bounded host/webroot error logs (DirectAdmin/panel log dirs, error_log, debug.log, .tar.gz archives) and report fatal/error patterns — FTP only, no --wp-url needed |
discover |
Find WordPress webroot candidates from the FTP account root (domains/*/public_html, cPanel-style <domain>/public_html, public_html, …) — FTP only, no --webroot/--wp-url needed |
files |
Snapshot (--backup PATH), transactionally edit (--path PATH --from LOCAL, --apply), or roll back (--restore SNAPSHOT, --apply) selected regular files; every apply snapshots first; every restore (managed name/last or an explicit path) requires the current manifest identity (site_raw + webroot) — an explicit path selects the snapshot location, never a trust bypass; --backup may READ protected files, while edit/restore refuse WordPress core/config paths (including any .htaccess). Local mode can only back up from a zip |
proxy |
Local TLS-terminating browser proxy for pre-migration sites (DNS override) |
The proxy's certificate in a browser: wpt proxy mints a throwaway
self-signed localhost certificate (CN localhost; SAN localhost,
127.0.0.1, ::1), so a browser may show its "connection is not private"
interstitial. The browser talks to https://localhost:<bound-port>, not to
the overridden hostname. The pair is generated on first use and reused
until it is within a day of its 7-day expiry, and only while its key matches
and its subject/SAN still name localhost + 127.0.0.1 + ::1. It is not regenerated per run,
and it is not a trust anchor. For Chrome, type thisisunsafe while that
page has focus to bypass the interstitial for this throwaway cert (it is
Chrome's own keyboard bypass, not a wpt command). Prefer a browser profile
or --override-ip setup you can throw away for a migration session.
Audit checks worth knowing: core.integrity (wp.org official checksums —
tampered/added/missing core files, the hacked-site proof; default-theme
files that are a pristine official theme AUTO-UPDATE past the bundled copy
are cross-checked against the official theme release and reclassified
[L12], and well-known plugin bootstraps like Wordfence's
wordfence-waf.php report as a low-risk "added-known" note instead of
compromise evidence [L16]), security.suspicious
(high-precision backdoor signatures in the PHP WordPress actually executes —
every .php under wp-content/ at any depth, wp-admin/, the webroot
and wp-config.php — matched against executable code only, never text
quoted in a comment or a string, and found even when a comment or a line
break splits the call),
security.php_version
(EOL PHP runtime), security.source_patterns (source-only legacy-PHP
patterns: request-driven sessions, reflected request data, SQL/table
identifiers, includes, code execution — the non-WordPress path), and
security.uploads_perms (world-writable files/directories under
wp-content/uploads/ when the FTP LIST carries modes), db.charset
(non-utf8mb4 tables), db.admin_accounts (default admin login) — plus
the original webroot/plugins/db/media/security suite. Media findings cover media-extension files only — uploads
.htaccess/index.php guards are never flagged or trimmed [L14].
Reports end with Suggested next steps — each finding mapped to the
exact command that fixes it. After a successful action --apply the site's
page caches are purged (LiteSpeed/W3TC/WP Super Cache) so changes are visible
immediately; restore --apply restores files rather than live output and
does not purge.
Reliability (live-host hardened): the FTP source trips a consecutive-failure breaker when the passive data channel goes stale (bounded to minutes, never hours); stale bridge files and expired PHP probes from killed runs are swept automatically; long captures log heartbeat progress (webroot walks, bulk scans, snapshot captures — a run is never silently grinding); bulk scans carry circuit breakers.
All mutating commands (including restore) print a dry-run plan first;
pass --apply to execute and --yes to skip the interactive confirm.
Snapshots land in .wpt-backup/ locally — never in the webroot — and
every FTP mutating action snapshots its own affected scope before
applying (fail-closed: no snapshot, no apply). In local mode the
action mutates only an in-memory overlay and materializes a rehearsed
copy; the original zip is the rollback, so there is no pre-action
snapshot and no database. Every command with --report writes a
markdown report plus a JSON twin for scripted use.
The proxy's own inputs are --hostname, --ip, --port, --cert-dir, and
--report; it defaults --source to local and reads no zip, dump, webroot, or
site URL.
Reports are intentionally boring: Markdown for people, JSON for scripts, and
the same finding IDs in both. Use --baseline to see only what changed since
the last run.
In local mode, --apply does not mutate the original zip: the action
stages in memory and materializes a cleaned copy beside it
(<zip>-rehearsed-<timestamp>.zip). The original is the rollback.
Serve the cleaned copy and verify it offline:
wpt --source local --zip site.zip --sql dump.sql --apply --yes clean-files
wpt --source local --zip site-rehearsed-<ts>.zip --sql dump.sql serve --port 8123
wpt --source local --zip site-rehearsed-<ts>.zip --sql dump.sql \
--wp-url http://127.0.0.1:8123 verify(Serving is static — rendered-page crawling still needs the real site; media references and file checks work fully offline.)
- Quickstart — install, first audit, reports, and apply.
- Commands — task-oriented workflows for the command table.
- Safety model — plans, snapshots, rollback, and local mode.
- Architecture — source adapters, actions, audit, bridge, and the test strategy.
- Development — local setup, tests, packaging, and release.
- FAQ — supported platforms, databases, and operational limits.
- SPEC.md — the binding engineering contract.
- CHANGELOG.md — release history.
python3 -m pip install -e ".[dev,db]"
# Fast: parallel, work-stealing distribution.
python3 -m pytest tests/ -q -n auto --dist worksteal
# Serial fallback: same suite, one worker, deterministic order.
python3 -m pytest tests/ -q
python3 -m ruff check wpt testsUse -n auto (or a number tuned to your machine) for the parallel run. The
full suite is POSIX; Windows is smoke-tested only. See
CONTRIBUTING.md for the test, documentation, and review
contract.
- Apply plugin/theme updates after audit approval (core updates already ship
as
update-core) - DirectAdmin / cPanel panel connectors
- SSH connector, multisite support
MIT. Copyright © 2026 The wpt authors. See LICENSE.