@bernsteinkraft/cwp
v0.3.0
Published
CLI for managing Cloudron-hosted WordPress projects with DDEV
Maintainers
Readme
cwp
Cloudron WordPress — a CLI for managing WordPress projects that are hosted on Cloudron and developed locally with DDEV.
cwp is middleware. It orchestrates the Cloudron CLI, DDEV and WP-CLI and adds
the WordPress- and Bricks-specific glue those tools don't provide. It never
reimplements what they already do: DDEV owns the local container, the Cloudron
CLI owns remote transport, WP-CLI owns WordPress. cwp owns sequencing,
safety and project conventions.
If you manage a handful of Bricks sites and you are tired of hand-running
wp db export, search-replace, upload copies and mail-guard plugins every
time you refresh a local copy, this is that workflow with the footguns removed.
Table of contents
- Requirements
- Installation
- Quickstart
- Onboarding an existing site
- The safety model — read this once
- Commands
- Uploads strategies
- The scrub pipeline
- Bricks integration
- Hooks
- Configuration reference
- Output, JSON and exit codes
- Troubleshooting
Requirements
cwp shells out to these; it does not bundle them. Run cwp doctor
to check them all at once.
| Tool | Why | Install |
|---|---|---|
| Node.js ≥ 22 | runtime | brew install node |
| Docker runtime | DDEV needs one | brew install --cask orbstack, then start it |
| DDEV | local WordPress container | brew install ddev/ddev/ddev |
| mkcert | local HTTPS | mkcert -install |
| Cloudron CLI | remote transport | npm i -g cloudron |
You also need to be logged in to your Cloudron: cloudron login my.example.com.
Installation
npm i -g @bernsteinkraft/cwp
cwp --version
cwp doctorcwp is installed globally, once per machine — not per project. Project
state lives in the repo (.cwp.yml) and in a machine config
(~/.config/cwp/config.yml).
Quickstart
From nothing to a working local copy of a production site:
# 1. In an empty project directory
mkdir -p ~/Code/example/example.com
cd ~/Code/example/example.com
# 2. Scaffold. Interactive: asks for the project name and the Cloudron app,
# probes the remote for package type and PHP version, writes the DDEV setup.
cwp init
# 3. Start the local container and put WordPress core in place
# (core is gitignored, so it is never in the repo)
ddev start
ddev wp core download
# 4. Fetch the themes and plugins the site needs to render
cwp fetch
# 5. Pull the production database and (by default) proxy the uploads.
# Runs search-replace, scrubs personal data, installs the mail guard,
# runs Bricks post-import steps.
cwp pull
# 6. Open it
cwp openThe first cwp pull on an image-heavy Bricks site takes about two minutes
because uploads are proxied, not copied — see
Uploads strategies.
Onboarding an existing site
The quickstart gets a local copy running. A site that has been live for a while needs two more things before you start working in it.
Take a baseline snapshot immediately after the first successful pull.
cwp db snapshot baselineRestoring costs five seconds, re-pulling costs minutes. Make it a reflex before
any experiment that touches the database — that is what
cwp db is for.
Then audit where the site's own code actually lives. On a Bricks site with the SNN-BRX child theme, custom code accumulates in two places it should never be:
- the child theme directory, and
- SNN-BRX's code-snippet manager.
Both are a problem, and for the same reason: SNN-BRX auto-updates from GitHub and will overwrite them. Anything of yours that lives there is one update away from being gone.
The fix is the site plugin that cwp init scaffolds for you (--plugin-slug,
<project>-site by default). Move custom code into it and register custom
Bricks elements from there on the init hook. The rule to hold, without
exception:
No project code in
wp-content/themes/. Ever.
cwp does not audit this for you and does not migrate anything — it has no way
to tell your snippet from the theme author's. Read the theme directory and the
snippet manager yourself, plan the move, and take a snapshot before you make
it.
The safety model
cwp exists to make one class of mistake impossible: pushing the wrong thing
in the wrong direction to production. Six rules encode that. They are not
configurable away.
Data flows down, code flows up. Database and uploads move remote → local. Code (
trackedpaths) moves local → remote. Any command that goes against the grain needs an explicit flag and a confirmation.Protected environments refuse upward writes. An environment marked
protected: truerejects everypush/deployunless you pass--force. Mark production protected and you cannot deploy to it by muscle memory.A database push is deliberately awkward.
cwp db pushis a separate command that makes you type the environment name to confirm and always backs up first. On a protected environment it needs the same--forceevery upward write does — the typed-name prompt is the database-specific extra hurdle. Pushing a database upward should feel like defusing a bomb, because it is one. (Ships in 0.4.0.)A remote backup is taken before any upward write (
cloudron backup create), unless you explicitly disable it. If a deploy goes wrong, there is a restore point one command away.A local snapshot is taken before any downward write.
cwp pullreplaces your local database with production's, and on a Bricks site that database is the only copy of your design work — layouts, templates, global classes and theme styles are database state the repository deliberately does not carry. So the pull snapshots it first, aspre-pull-<env>-<timestamp>, and a snapshot that fails aborts the pull rather than warning. Recovery iscwp db restore <name>. This is rule 4's mirror image: the remote has Cloudron's backups, the local side has this.Every pull installs a mail guard. A pulled production database contains real customer addresses and can be tricked into sending mail.
cwp pullalways installs an mu-plugin that intercepts and logs outbound mail instead of sending it — even when data scrubbing is turned off, because the Bricks stack (SNN-BRX) ships its own SMTP that bypasses DDEV's mail catcher.
On top of that, pulling a production database is a GDPR processing operation, so
cwp pull scrubs personal data by default — see
The scrub pipeline.
Commands
Every mutating command supports --json, --verbose and --dry-run. The
environment argument defaults to dev. Human output goes to stderr, machine
output to stdout — see Output, JSON and exit codes.
Three commands have no --json, for one shared reason: cwp wp, cwp shell
and cwp logs hand the terminal to another program, so stdout already belongs
to that program and wrapping it in cwp's envelope would corrupt the very output
you asked for. cwp doctor and cwp status change nothing and therefore ignore
--dry-run.
cwp init
Scaffold a project. Interactive and safe to re-run — re-running repairs the scaffold rather than overwriting your work.
cwp init [--name <name>] [--app <domain>] [--plugin-slug <slug>]
[--keep-admin-email <email>] [--env <env>] [--non-interactive] [--json]It asks for the project name, the Cloudron app and the site-plugin directory,
asks for the Cloudron host if the machine config has none, probes the remote to
detect the package type (managed vs developer) and PHP version, runs the
doctor checks, writes the scaffold (.cwp.yml, .ddev/, the site plugin,
tracking files, CLAUDE.md, README.md, .gitignore), and registers the
project in the machine config.
--plugin-slug exists because the project name is usually the domain while the
plugin keeps a short slug. It defaults to <name>-site, is recorded as
plugin_slug in .cwp.yml, and is what tracked points at.
--keep-admin-email records pull.keep_admin_email — the account that survives
scrubbing. Interactively it is offered with your git address as the default.
Together with pull.keep_roles it is what keeps you able to log in to the local
copy after a pull.
What "repairs" means for .cwp.yml
Every other scaffolded file is left alone if it exists. .cwp.yml is different:
it is the one file cwp itself has opinions about, so re-running init brings
it up to date in place. Currently that means removing the
snn_301_logs / snn_404_logs / snn_search_logs entries from
truncate_tables — those tables never existed — and writing out
truncate_post_types, which is where the SNN-BRX log data actually lives.
Each repair is reported as its own step, so nothing is rewritten silently:
! repair .cwp.yml: pull.truncate_tables — removed snn_301_logs, snn_404_logs,
snn_search_logs — no such table has ever existed (B-001)It only ever removes entries that cannot do anything and adds keys that were absent. Your settings are never changed, and defaults you did not set are not written into the file — so your project keeps following future default corrections instead of pinning today's values. A config that cannot be read or would not validate is left exactly as it is.
Example
cwp init --name example --app example.com --plugin-slug example-siteDoes not: install WordPress core, start DDEV, or pull any data. Run
ddev start and cwp pull afterwards.
cwp doctor
Read-only diagnostics. Each check reports ok, warn or fail, and every
failure prints a copy-pasteable fix. Exits non-zero if anything failed.
cwp doctor [--json]Checks: Node version, Docker runtime, DDEV, mkcert, the Cloudron CLI and login,
both config files, that the remote app is reachable and its wp responds, PHP
parity local vs remote, that the configured package matches the probed remote
wp-content path, that WordPress core is present in the docroot, and whether
DDEV is running.
WordPress core is never committed, so a freshly cloned or freshly scaffolded
project has none. doctor flags that before cwp pull runs into it: the import
would succeed and then wp search-replace would fail with no installation to
load. The fix is ddev wp core download.
Example
cwp doctor
# ✓ Node 22.x
# ✗ Cloudron logged in — run: cloudron login my.example.comDoes not: change anything. It only reports.
cwp pull [env]
The workhorse. Downward sync of the database and uploads, plus everything that has to happen to make a production dump safe and usable locally.
cwp pull [env] [--uploads <sync|skip|proxy>] [--no-scrub] [--no-snapshot]
[--db-only] [--uploads-only] [--yes] [--json]Steps, in order: confirm the local DB will be replaced → snapshot the local
database → remote wp db export → transfer down → ddev import-db →
wp search-replace (remote URL → local URL, all tables, --skip-columns=guid)
→ uploads per strategy → scrub → Bricks post-import steps → wp cache flush →
post-pull hook.
| Flag | Effect |
|---|---|
| --uploads <mode> | Override the uploads strategy (skip = don't fetch uploads) |
| --no-scrub | Skip data scrubbing (the mail guard is still installed) |
| --no-snapshot | Skip the local snapshot taken before the import — you keep no restore point |
| --db-only | Database only; no uploads |
| --uploads-only | Uploads only; leave the database untouched, so scrub, the Bricks steps and the snapshot are skipped too |
| --yes | Skip the "local database will be replaced" confirmation |
--db-only and --uploads-only are mutually exclusive. The scrub and the
Bricks post-import steps belong to the database import, so they run whenever the
database is in scope.
The pre-pull snapshot. Before anything is imported, cwp pull runs
ddev snapshot --name pre-pull-<env>-<timestamp>, so a pull you regret is one
cwp db restore away. Three things about it are deliberate:
- It comes after the confirmation, so a pull you refuse leaves nothing behind — not even a snapshot.
- A failed snapshot aborts the pull (exit 5). Every other late step in the pull degrades to a warning; this one may not, because a safety net you believe in and do not have is worse than none.
- A project with no WordPress installed yet is skipped with a reason, not
treated as a failure — the first pull into a fresh project has nothing to
snapshot. If
cwpcannot tell (a stopped DDEV project fails the probe exactly like an empty one), it takes the snapshot anyway.
Turn it off per run with --no-snapshot, or for good with
defaults.snapshot_before_pull: false in the machine config. Snapshots
accumulate — cwp db list and cwp db rm are the manual answer; nothing is
pruned automatically.
Example
cwp pull # dev, proxy uploads, scrub on, snapshot first
cwp pull prod --uploads sync # full upload copy from prod
cwp pull --db-only --yes # fast DB-only refresh, no prompt
cwp pull --no-snapshot --yes # no restore point — you know what is local
cwp pull --dry-run --verbose # print the plan, run nothingRecovering the state a pull replaced:
cwp db list # find pre-pull-dev-20260728-113000
cwp db restore pre-pull-dev-20260728-113000Does not: ever write upward. There is no cwp pull flag that touches the
remote beyond a temporary export file in the container's /tmp, which it
deletes again — including when the transfer fails. The downloaded dump is
deleted right after the import, so an unscrubbed copy of production never
lingers on disk.
cwp fetch [env]
Fetch third-party themes and plugins down. They are gitignored and cwp
otherwise moves code upward only, so a freshly initialised project has a
database that references a theme which is not on disk. This is the command that
closes that gap.
cwp fetch [env] [--themes] [--plugins] [--yes] [--dry-run] [--json]| Flag | Effect |
|---|---|
| --themes | Themes only |
| --plugins | Plugins only |
| --yes | Skip the confirmation before overwriting existing local directories |
It lists the remote directories, skips everything inside a tracked path,
asks before overwriting local directories that already exist, and transfers each
remaining directory on its own — so your site plugin is never in the blast
radius.
Each directory travels as a tarball (remote tar → cloudron pull the archive
→ extract locally), because cloudron sync pull exits 0 having transferred
nothing in Cloudron CLI 8.2.6. Every transfer is then checked against the remote
file count: cwp will not report success over an empty directory.
Example
cwp fetch # themes and plugins from dev
cwp fetch --themes --yes # just the themes, no promptDoes not: write upward, touch tracked paths, install or activate anything,
or decide what should be installed — that inventory is cwp plugins in 0.4.0.
cwp push [env] and cwp deploy [env]
Upward code sync. push transfers the tracked paths; deploy is push plus
remote post-steps (wp cache flush, Bricks CSS regeneration, post-deploy
hook).
cwp push [env] [--force] [--no-backup] [--delete] [--yes] [--dry-run] [--json]
cwp deploy [env] [--force] [--no-backup] [--delete] [--yes] [--dry-run] [--json]Order, and every step is a guard:
- refuse if the environment is
protectedand--forcewas not given (exit 5); - resolve
tracked— an empty list, a missing path or one outside the docroot is refused, never interpreted; - show what would move and ask for confirmation (unless
--yes; refused with exit 6 when cwp cannot prompt); - run
pre-push.sh— a non-zero exit aborts; - take a remote backup (unless
--no-backup); - transfer with
cloudron sync push, hand the files towww-data(the user WordPress runs as — the transfer leaves them owned by your local uid), then verify every file by digest — the exit code alone is not evidence that anything moved.
Nothing happens at all until the confirmation passes: a refused push takes no backup and does not run your hook.
| Flag | Effect |
|---|---|
| --force | Allow the write to a protected environment |
| --no-backup | Skip the pre-push remote backup (warns) |
| --delete | Mirror: also delete remote files that are absent locally |
| --yes | Skip the confirmation |
| --dry-run | Print the plan, change nothing, run no hooks |
Additive by default, mirror with --delete
cloudron sync push only ever adds and overwrites. A file you delete locally
stays on the remote — and in a plugin directory it keeps being loaded, so a
file you think you removed can still be running. That is the default, because
deleting live files as a side effect of a routine deploy is the kind of surprise
this tool exists to prevent.
The default is not silent about it. When the remote holds files your tracked path does not, cwp says so:
! 2 file(s) on /app/data/wp-content/plugins/example-site are not in
public/wp-content/plugins/example-site — push is additive and left them in
place; pass --delete to mirror--delete mirrors instead: after it, the remote matches your local tree exactly.
It is destructive, so it sits behind every guard above — protected environments
still refuse, the confirmation still runs (and says outright that files will be
deleted), and the backup is still taken first. cwp push --delete --dry-run
lists the exact files it would remove and changes nothing.
Verification
After the transfer, cwp compares an md5sum of every remote file against your
local tree. A file that never arrived, or arrived with different contents, fails
the push and is named:
✗ the push of public/wp-content/plugins/example-site did not land intact —
1 differing (inc/alpha.txt)This matters because cloudron sync decides what to send by size and mtime. A
remote file corrupted to the same length with its mtime intact is reported as
Already up to date and never resent — so re-running the push does not fix
it. Force a resend with touch on the local file, or delete the remote copy.
The error says so.
If the remote image has no md5sum, cwp warns and falls back to comparing file
counts, which is weaker but does not fail a good push.
The run summary
Every push and deploy ends with an account of what actually happened:
push summary:
target dev (example.com)
mode mirror — remote files absent locally were deleted
backup taken (example.com)
files sent 2
files deleted 1
public/wp-content/plugins/example-site → /app/data/wp-content/plugins/example-site
2 sent, 1 deleted, 7 file(s) now on the remote
deleted: inc/gamma.txtThe counts are read from the Cloudron CLI's own report of what it moved, not inferred by cwp.
What "show what would move" is, precisely: the tracked paths, their local
file counts and their remote targets. It is not a file-level diff —
cloudron sync offers no dry-run mode, so cwp does not pretend to know which
individual files differ. With --delete the deletion set is listed exactly,
because that one can be computed from a remote listing.
Example
cwp deploy dev # deploy tracked code to dev
cwp deploy prod --force # prod is protected; --force is required
cwp push dev --dry-run # see what would be sent
cwp push dev --delete --dry-run # see what would be sent *and removed*
cwp deploy dev --delete # make the remote match local exactlyDoes not: push the database (use cwp db push), push anything outside the
tracked list in .cwp.yml, run hooks during a dry run, or delete anything
remotely unless you pass --delete.
cwp db <subcommand>
Local database snapshots on top of ddev snapshot, plus dump import/export.
Entirely local — no cwp db subcommand talks to a Cloudron app. This is the half
of the safety model that has no backup of its own: the remote database has
Cloudron's backups, your local one has whatever you took yourself.
cwp db snapshot [name] # default name is a timestamp
cwp db restore [name] [--yes] # interactive picker if no name given
cwp db list
cwp db rm <name>
cwp db export [file]
cwp db import <file> [--yes]
cwp db push <env> # 0.4.0 — types env name to confirm; --force if protectedSnapshots are database only. Uploads are not included. This is the single most likely source of confusion, so it is repeated in the help of every subcommand: restoring brings back your Bricks layouts, not your media library.
| Subcommand | What it runs | Notes |
|---|---|---|
| snapshot [name] | ddev snapshot --name <name> | Default name is a timestamp, e.g. 20260728-113000 |
| restore [name] | ddev snapshot restore <name> | Replaces the local database — confirms, or --yes |
| list | reads .ddev/db_snapshots/ | Newest first, with creation time |
| rm <name> | ddev snapshot --cleanup --name <name> -y | Refuses an unknown name and lists the real ones |
| export [file] | ddev export-db --file=<file> | Default <project>-<timestamp>.sql.gz in the current directory |
| import <file> | ddev import-db --file=<file> | Replaces the local database — confirms, or --yes |
restore and import are the two destructive ones, and they are guarded like
cwp pull: they ask before replacing the local database, --yes skips the
prompt, and they refuse with exit 6 when cwp cannot prompt (--json, no TTY).
Picking a snapshot in the interactive list is the confirmation — you are not
asked twice about the same decision.
Snapshot names may contain letters, digits, dot, underscore and hyphen. The name
becomes a file under .ddev/db_snapshots/, so anything else is refused rather
than passed on.
Example
cwp db snapshot before-migration
# ... experiment ...
cwp db restore before-migrationRecovering from a pull that overwrote work you wanted to keep:
cwp db list # find pre-pull-dev-20260728-113000
cwp db restore pre-pull-dev-20260728-113000Does not: snapshot or restore uploads, touch the remote, or delete anything
you did not name — cwp db rm always passes the snapshot name to DDEV, because
ddev snapshot --cleanup on its own removes every snapshot of the project.
An exported dump is an unscrubbed copy of the local database. cwp says so
after every export, and the scaffolded .gitignore keeps *.sql / *.sql.gz
out of the repository.
cwp wp <args...>
The escape hatch: every WP-CLI command cwp does not wrap, without having to
remember whether this site wants ddev wp or
cloudron exec --app <app> -- wp. Local by default, remote with --env.
cwp wp [--env <env>] [--verbose] [--dry-run] -- <wp-cli args...>| Flag | Effect |
|---|---|
| --env <env> | Run on that Cloudron environment instead of the local site |
| --verbose | Echo the command before running it |
| --dry-run | Print the command and exit without running it |
The -- is mandatory. Everything before it belongs to cwp, everything
after it goes to WP-CLI untouched — which is what keeps --env or --verbose
from ever colliding with WP-CLI's own --path, --format=json or
--allow-root. Leaving it out is a usage error with a fix hint, not a guess:
$ cwp wp plugin list
✗ cwp wp needs a `--` before the WP-CLI command
→ write it as `cwp wp -- plugin list`. …Example
cwp wp -- plugin list
cwp wp -- db query 'SELECT ID FROM wp_posts LIMIT 1' --skip-plugins
cwp wp -- shell # interactive: the terminal is handed over
cwp wp --env dev -- option get siteurl
cwp wp --env prod --dry-run -- plugin list # see the exact command firstThree things are deliberately not cwp's business here:
- The terminal belongs to WP-CLI. stdio is inherited rather than captured,
so
wp shellandwp db cliare interactive, output streams as it happens, and colour and progress bars survive. - The exit code is WP-CLI's, forwarded verbatim, because a pass-through that
rewrites exit codes is useless in a script.
cwp's own refusals — a missing--, an unknown environment, no project — all happen before WP-CLI starts and use the cwp exit codes. - There is no
--json. stdout is WP-CLI's payload and wrapping it in cwp's envelope would corrupt it. Use WP-CLI's own--format=json.
Getting JSON out of a site
This is the common case — pulling categories, posts, users or options out of a
site as data — and it needs nothing from cwp beyond staying out of the way:
cwp wp -- term list category --format=json > kategorien.json
cwp wp -- post list --format=json --fields=ID,post_title,post_date > posts.json
cwp wp --env prod -- post list --format=json | jq '.[].post_title'It works because there is no cwp --json, not in spite of it. stdio is
inherited, so WP-CLI writes straight into your redirect or your pipe, and every
line cwp itself emits goes to stderr. A redirected file therefore contains
WP-CLI's bytes and nothing else — including on a remote run, where the
"running on prod" notice stays on stderr:
$ cwp wp --env prod -- post list --format=json > posts.json
running on prod (example.com) — a protected environment # ← stderr, not the file
$ cat posts.json
[{"ID":42,"post_title":"Hallo Welt","post_date":"2026-07-01 09:00:00"}]Add 2>/dev/null when that notice is in the way of a pipeline. Had cwp wp
offered a --json of its own, you would instead be digging your categories out
of {"ok":true,"command":"wp","steps":[…]} — and wp shell would have stopped
working, because wrapping the output means buffering it.
One caveat, and it is WP-CLI's rather than cwp's: WP-CLI sends its own
warnings and errors to stderr, but a plugin or theme that echoes or triggers a
PHP notice while loading writes to stdout — and that lands in the middle of
your JSON. If a file will not parse, take the site's own code out of the picture:
cwp wp -- post list --format=json --skip-plugins --skip-themes > posts.jsonRemote invocations run through cloudron exec and add --allow-root only if
the container refuses without it; that is probed once and cached per host. cwp
never passes --path remotely — Cloudron's wp wrapper supplies it, and
WordPress core lives at /app/code, not under the /app/data docroot.
A remote target is always named on stderr before the command runs, and a
protected environment is called out as such:
$ cwp wp --env prod -- plugin list
running on prod (example.com) — a protected environmentDoes not: interpret your WP-CLI arguments, and does not guard them. It
cannot know which WP-CLI subcommands are destructive, so it does not pretend to:
--env prod runs on production, and a --force on cwp wp -- plugin list
would be a habit, not a safeguard. Safety rule 2 covers upward transfers of
files and databases, which is what push, deploy and db push are for.
cwp scrub
Run the scrub pipeline against the local database on its own, so you can re-run it or test it in isolation.
cwp scrub [--dry-run] [--verbose] [--json]Example
cwp scrubUser anonymisation is the one step that is fatal on failure: a database that is
reported as scrubbed but is not is worse than an obvious error. Everything after
it (log tables, transients, blog_public) warns and carries on.
Does not: touch the remote, and does not run search-replace or import data — it only sanitises what is already local.
cwp shell
An interactive shell: ddev ssh locally, cloudron exec on an environment.
cwp shell [env] [--verbose] [--dry-run]Example
cwp shell # a shell in the local DDEV web container
cwp shell prod # a shell inside the Cloudron appLike cwp wp, this hands the terminal over and forwards the shell's
exit code verbatim, so it has no --json. A tty is requested only when cwp
itself has one, so cwp shell prod < script.sh behaves in a pipeline. A remote
target is named on stderr first, with protected called out:
$ cwp shell prod
opening a shell on prod (example.com) — a protected environmentDoes not: guard anything. Opening a shell writes nothing by itself, and cwp cannot know what you will type into it — a confirmation prompt here would only train the reflex that dismisses the prompts that matter.
cwp logs
Tail the logs: ddev logs locally, cloudron logs for an environment.
cwp logs [env] [-f] [--lines <n>] [--verbose] [--dry-run]| Flag | Meaning |
|---|---|
| -f, --follow | Stream new lines until interrupted (Ctrl-C). |
| --lines <n> | Print n lines of history. |
Example
cwp logs # the local web container
cwp logs --lines 50 # the last fifty local lines
cwp logs prod -f # follow productionThese two flag names are cwp's own, and that is the point of the command. The tools underneath disagree, and one spelling means opposite things:
| | follow | line count |
|---|---|---|
| ddev logs | -f, --follow | --tail <n> |
| cloudron logs | -f, --tail | -l, --lines <n> |
A --tail 50 typed from memory prints fifty lines locally and starts
following production remotely. cwp logs -f and cwp logs --lines <n> each
mean one thing, and cwp maps them to whichever spelling each tool wants.
Interrupting a follow exits 130 (the shell's convention for SIGINT), not 1 —
stopping -f is how the command ends, not a failure.
Does not: read the server's logs. cloudron logs --system is about the
machine; this command is about your project, so only the app's own logs are in
scope. There is no --json: the output belongs to the tool underneath, and -f
never terminates on its own, so there would be no envelope to close.
cwp open
Open the site in a browser.
cwp open [env] [--print] [--verbose] [--dry-run] [--json]Example
cwp open # the local DDEV URL
cwp open prod # the environment's configured url
open "$(cwp open prod --print)" # on a machine cwp has no opener forThe URL comes from DDEV itself locally (falling back to the conventional
<name>.ddev.site when the project is stopped) and from environments.<env>.url
remotely. open is used on macOS and xdg-open elsewhere.
A missing opener is not a failure. On a headless machine, over SSH, or on
Windows — where cwp deliberately ships no opener rather than an untested
cmd /c start — it prints the URL and exits 0.
--print writes the URL to stdout and opens nothing; that is the scriptable
form, and the one place in this command where stdout carries a payload. It
cannot be combined with --json, since both claim stdout; cwp refuses rather
than corrupting one of them.
Does not: start anything. If DDEV is stopped, cwp open shows you the URL
the project will have — run ddev start yourself.
cwp status
The "where am I in this project" screen, so the answer does not need
ddev describe, cat .cwp.yml and cwp db list separately.
cwp status [--verbose] [--json]Example
$ cwp status
✓ project — example.com — /Users/you/Code/example/example.com
✓ docroot — public
✓ php — 8.3
✓ environment dev — https://dev.example.com — app dev.example.com — managed
✓ environment prod — https://example.com — app example.com — developer — protected
✓ ddev — running — https://example-com.ddev.site
– last pull with a snapshot (dev) — unknown — no pre-pull snapshot on disk […]
✓ last pull with a snapshot (prod) — 2026-07-27 09:15 — pre-pull-prod-20260727-091500
✓ snapshots — 3 — newest baseline (2026-07-28 09:12)It makes no remote calls at all. It reads the config, the DDEV state and the
snapshot directory — nothing else. It must be instant and must work on a train,
with Docker stopped and no Cloudron login. cwp doctor is the
command that talks to the remote and takes as long as that costs.
The last pull is read off the snapshots, not from a state file cwp would
otherwise have to write, gitignore and keep consistent: since every pull leaves
pre-pull-<env>-<timestamp>, the restore point already records both when and
from which environment. That reading has three limits, and the output says
"last pull with a snapshot" because of the first:
- A pull with
--no-snapshotor--uploads-onlyleaves no snapshot and is therefore invisible here. cwp db rmon apre-pull-*snapshot deletes the record along with the restore point — reclaiming disk space quietly rewrites this history.- The snapshot is taken before the import, so it proves a pull got past the confirmation guard, not that it finished. A pull that died on the transfer leaves the same trace as one that succeeded.
An environment with no pre-pull-* snapshot is therefore reported as unknown
with the reason — never as "never pulled", which cwp cannot know.
Does not: contact Cloudron, check whether the remote is reachable, or verify
that the local database matches anything. --dry-run is meaningless here and is
ignored, exactly as cwp doctor ignores it.
Still to come (0.4.0)
cwp projects # list registered projects on this machine
cwp config get <key>
cwp config set <key> <value>cwp config will read and write both config layers through the typed config
module — it never hand-edits YAML. Use a dotted path, e.g.
cwp config set pull.uploads sync. Note that pull.uploads (project) and
defaults.uploads (machine) are different keys in different files.
Neither command exists yet; they are tracked as F-022 and F-026.
Uploads strategies
Set the default in .cwp.yml under pull.uploads; override per run with
cwp pull --uploads <mode>.
| Mode | Behaviour | When |
|---|---|---|
| proxy | Default. No transfer. A generated mu-plugin serves missing images from the remote host. | Day-to-day. Turns a 20-minute first setup into two minutes. |
| sync | Full copy of the remote uploads directory. Correct, slow, large. | When you need every asset locally (offline work, media edits). |
| skip | Nothing. Images are broken locally. | DB-only work where you don't care about media. |
The proxy mu-plugin filters wp_get_attachment_url,
wp_get_attachment_image_src and wp_calculate_image_srcset: if the file
exists locally it is served locally, otherwise the URL falls back to the remote
host. It is written by cwp pull and is gitignored.
proxy keeps the local copy coupled to production — a missing image is fetched
live from the remote host, so a scrubbed database still loads original
production media on demand. Because of that, sync and skip remove a
proxy plugin left over from an earlier pull instead of leaving it active.
The scrub pipeline
Runs after every pull unless you pass --no-scrub. Pulling a production
database onto a laptop is a GDPR processing operation; scrubbing is the default
for that reason, not for convenience.
Neutralise outbound mail — install
cwp-mail-guard.php(always, even with scrubbing off).Anonymise users — except the site's own staff. Users holding one of
pull.keep_roles(defaultadministratorandeditor), super admins, andpull.keep_admin_emailkeep their real data. Everyone else — subscribers, customers, visitors, where the bulk personal data lives — getsuser-<ID>@example.invalid, a blank display name and a reset password. Written straight to the database rather than throughwp_update_user(), so plugins that sync users to external services are not triggered.Keeping staff accounts is deliberate: they are what you log in with locally. Set
keep_roles: []if you want no exceptions — but then no local account has a password you know. Sessions are destroyed for everyone, staff included.Truncate log and submission tables — Bricks form submissions by default, configurable via
pull.truncate_tables; existence is checked first, so a name that does not apply to your site is reported as absent rather than failing.Delete log posts — the SNN-BRX 404, redirect, activity and mail logs, configurable via
pull.truncate_post_types.These are not tables. Every SNN-BRX logging feature registers a custom post type and writes entries to
wp_posts, so truncating tables could never reach them — and the mail log stores message bodies. Deletion goes throughwp_delete_post(..., true), which takes the postmeta with it (that is where the IP addresses and user agents live) and forces past the trash.Like anonymisation, a failure here aborts the scrub: a database still holding production logs while reporting success is the outcome this exists to prevent.
snn_301_redirectsis deliberately not in the defaults — that is the redirect rules post type, your site configuration, not a log.Delete transients and expired sessions.
Set
blog_public = 0so the local copy is never indexed.Report what was scrubbed.
Re-runnable on its own with cwp scrub.
Bricks integration
When bricks.enabled is true, cwp pull runs these best-effort steps (a failure
warns, it does not abort the pull):
- Regenerate code signatures. Bricks signs code elements per site; imported
content stays blocked until re-signed. There is no official WP-CLI command for
this yet, so
cwpchecks whether one exists and otherwise tells you to regenerate under Bricks → Settings → Custom Code → Code signatures. - Regenerate CSS via
wp bricks regenerate_assets, when the site uses external CSS files. - Report license state — Bricks is licensed per site; your local install may
need its own activation.
cwpreports this; it does not try to fix it.
Convention: SNN-BRX occupies the WordPress child-theme slot and auto-updates from GitHub. WordPress has no grandchild themes, so project code never goes in the theme — it belongs in the site plugin (
wp-content/plugins/<name>-site). Custom Bricks elements are registered from there via\Bricks\Elements::register_element()oninit.
cwp bricks export|import (round-tripping templates through bricks/*.json)
lands in 0.4.0.
Custom post types belong in the site plugin
Register them in code — plugins/<name>-site/inc/post-types/, on init — not
with CPT UI, not with the SNN-BRX post-type manager, not with ACF post types.
The reason is the same one the whole tool is built on: the database only ever
flows downward. A post type defined in the database lives in wp_options or
wp_posts, so the only way to get it to production is cwp db push — typed
environment name, mandatory backup, --force on a protected environment, and it
takes your entire local database with it. That path exists for data. A post type
is structure, and structure travels with your code, via cwp deploy.
.cwp.yml is not the place either. It configures cwp, not your site. (Its one
post-type setting, pull.truncate_post_types, is about deleting log entries —
see The scrub pipeline.)
You do not need to write the registration boilerplate by hand — WP-CLI has a
generator, and cwp wp gets you to it:
cwp wp -- scaffold post-type buch --plugin=<your-site-plugin> --label="Bücher"To see what is actually registered where — the definitive answer, read from a live install rather than from your code:
cwp wp --env prod -- post-type list --format=jsonComparing the two sides automatically is planned as a cwp doctor check
(F-025); today it is a manual diff.
Hooks
Executable shell scripts in .cwp/hooks/, run with the project root as the
working directory and a documented set of environment variables: CWP_ENV,
CWP_PROJECT, CWP_LOCAL_URL, CWP_REMOTE_URL, CWP_REMOTE_APP,
CWP_WP_CONTENT.
| Hook | Runs | On a non-zero exit |
|---|---|---|
| pre-push.sh | before cwp push / cwp deploy | aborts — exit 5, nothing is transferred |
| post-pull.sh | after cwp pull | warns; the pull already happened |
| post-deploy.sh | after cwp deploy | warns; the files are already on the remote |
Only pre-push can stop anything, and that is the point of it: it runs before
the one operation in this tool that can damage a live site. The other two run
after the fact, so failing the whole command would misreport what actually
happened.
cwp init generates them as commented no-op samples. A hook that exists but is
not executable is reported with a chmod +x hint rather than silently skipped.
Configuration reference
Two layers, both YAML, both maintained by cwp (init and config set) rather
than by hand.
Project config — .cwp.yml (committed)
version: 1
name: example
docroot: public
php: "8.3"
plugin_slug: example-site # site plugin dir; default <name>-site
environments:
dev:
cloudron_app: example.com # the Cloudron app location (= --app)
url: https://example.com
package: managed # managed | developer
protected: false
prod:
cloudron_app: www.example.com
url: https://www.example.com
package: managed
protected: true # blocks upward writes without --force
tracked: # paths cwp push/deploy send upward
- wp-content/plugins/example-site
pull:
uploads: proxy # sync | skip | proxy
scrub: true
keep_admin_email: [email protected] # this user survives scrubbing
keep_roles: # these roles survive scrubbing
- administrator
- editor
truncate_tables: # log/submission tables the scrub empties,
- bricks_form_submissions # written without the table prefix; a name
# that does not exist is simply reported
# as absent, never an error
truncate_post_types: # post types whose posts the scrub deletes.
- snn_404_logs # SNN-BRX logs are posts, not tables — and
- snn_redirect_logs # note snn_301_redirects is NOT here: that
- snn_activity_log # is the redirect *rules* type, which is
- snn_mail_logs # configuration and must not be deleted
bricks:
enabled: true
child_theme: snn-brx-child-theme
export_dir: bricks
hooks_dir: .cwp/hooksThe remote wp-content path is derived from package, not stored:
managed → /app/data/wp-content, developer → /app/data/public/wp-content.
cwp doctor verifies the configured value against the real remote path.
Machine config — ~/.config/cwp/config.yml (never committed)
version: 1
cloudron_host: my.example.com
defaults:
uploads: proxy
scrub: true
backup_before_push: true
snapshot_before_pull: true
projects:
example: ~/Code/example/example.comThe projects registry is maintained by cwp init and powers cwp projects.
Output, JSON and exit codes
- Human output → stderr, one spinner per step.
--json→ stdout, a single object:{ "ok": bool, "command": str, "env": str, "steps": [...], "warnings": [...], "error": {...}|null }. Because human output is on stderr,cwp pull --json > result.jsongives you clean JSON and still shows progress in the terminal.--verboseechoes every subprocess command before it runs.--dry-runprints the plan and exits 0 without changing anything.- The three pass-throughs are the exception.
cwp wp,cwp shellandcwp logshave no--json, because stdout carries the other program's own output and wrapping it would corrupt it — for WP-CLI, use its--format=json. They also forward that program's exit code verbatim, so a code from the table below may there mean whatever the program meant by it. cwp's own refusals still use these codes and all happen before the program starts.
Exit codes
| Code | Meaning | |---|---| | 0 | success | | 1 | generic error | | 2 | configuration error | | 3 | missing dependency | | 4 | remote / Cloudron error | | 5 | refused by a safety guard | | 6 | aborted by the user |
A pass-through whose program was killed by a signal reports the shell's
128 + signum instead — so interrupting cwp logs -f with Ctrl-C exits 130,
which is how that command normally ends rather than a failure.
Troubleshooting
cwp doctor says the package path doesn't match. The package value in
.cwp.yml disagrees with the real remote wp-content location. Set it to
managed or developer to match what doctor probed — a wrong value sends files
to the wrong path on push.
Images are broken locally. You are on --uploads skip, or proxy mode is off.
Run cwp pull --uploads proxy (or sync for a full copy).
Local site sends real email. It should not — the mail guard is installed on
every pull. If you imported a database by hand instead of via cwp pull, run
cwp scrub to install the guard.
deploy to production is refused. Production is protected. That is the
safety guard working. Pass --force only when you mean it.
A remote wp command fails with a root error. cwp retries with
--allow-root and caches the result. If it persists, run
cloudron exec --app <app> -- wp --info to see the raw error.
A pull aborts on the pre-pull snapshot. cwp refuses to replace the local
database when it could not create a restore point, and nothing has been imported
at that point. Usually the DDEV project is not running — ddev start, then pull
again. cwp pull --no-snapshot proceeds without a restore point, which is only
sensible when you know nothing local is worth keeping.
cwp db list fills up with pre-pull-… snapshots. Every pull leaves one and
nothing prunes them. Delete the ones you no longer want with cwp db rm <name>.
Cloudron CLI not found. npm i -g cloudron, then cloudron login <host>.
Versioning and licence
Semantic versioning, tracked in VERSION.txt; changes in CHANGELOG.md
(Keep a Changelog format). See SPEC.md for the authoritative design and
FEATURES.md for the roadmap.
MIT — see LICENSE. The licence covers cwp itself, not the tools
it orchestrates: Cloudron, DDEV, WP-CLI and WordPress carry their own, and
Bricks is commercial and licensed per site.
