npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bernsteinkraft/cwp

v0.3.0

Published

CLI for managing Cloudron-hosted WordPress projects with DDEV

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

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 doctor

cwp 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 open

The 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 baseline

Restoring 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.

  1. Data flows down, code flows up. Database and uploads move remote → local. Code (tracked paths) moves local → remote. Any command that goes against the grain needs an explicit flag and a confirmation.

  2. Protected environments refuse upward writes. An environment marked protected: true rejects every push/deploy unless you pass --force. Mark production protected and you cannot deploy to it by muscle memory.

  3. A database push is deliberately awkward. cwp db push is 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 --force every 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.)

  4. 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.

  5. A local snapshot is taken before any downward write. cwp pull replaces 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, as pre-pull-<env>-<timestamp>, and a snapshot that fails aborts the pull rather than warning. Recovery is cwp db restore <name>. This is rule 4's mirror image: the remote has Cloudron's backups, the local side has this.

  6. Every pull installs a mail guard. A pulled production database contains real customer addresses and can be tricked into sending mail. cwp pull always 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-site

Does 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.com

Does 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-dbwp search-replace (remote URL → local URL, all tables, --skip-columns=guid) → uploads per strategy → scrub → Bricks post-import steps → wp cache flushpost-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 cwp cannot 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 nothing

Recovering the state a pull replaced:

cwp db list                                  # find pre-pull-dev-20260728-113000
cwp db restore pre-pull-dev-20260728-113000

Does 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 tarcloudron 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 prompt

Does 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:

  1. refuse if the environment is protected and --force was not given (exit 5);
  2. resolve tracked — an empty list, a missing path or one outside the docroot is refused, never interpreted;
  3. show what would move and ask for confirmation (unless --yes; refused with exit 6 when cwp cannot prompt);
  4. run pre-push.sh — a non-zero exit aborts;
  5. take a remote backup (unless --no-backup);
  6. transfer with cloudron sync push, hand the files to www-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.txt

The 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 exactly

Does 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 protected

Snapshots 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-migration

Recovering 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-113000

Does 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 first

Three things are deliberately not cwp's business here:

  • The terminal belongs to WP-CLI. stdio is inherited rather than captured, so wp shell and wp db cli are 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.json

Remote 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 environment

Does 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 scrub

User 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 app

Like 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 environment

Does 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 production

These 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 for

The 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:

  1. A pull with --no-snapshot or --uploads-only leaves no snapshot and is therefore invisible here.
  2. cwp db rm on a pre-pull-* snapshot deletes the record along with the restore point — reclaiming disk space quietly rewrites this history.
  3. 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.

  1. Neutralise outbound mail — install cwp-mail-guard.php (always, even with scrubbing off).

  2. Anonymise users — except the site's own staff. Users holding one of pull.keep_roles (default administrator and editor), super admins, and pull.keep_admin_email keep their real data. Everyone else — subscribers, customers, visitors, where the bulk personal data lives — gets user-<ID>@example.invalid, a blank display name and a reset password. Written straight to the database rather than through wp_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.

  3. 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.

  4. 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 through wp_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_redirects is deliberately not in the defaults — that is the redirect rules post type, your site configuration, not a log.

  5. Delete transients and expired sessions.

  6. Set blog_public = 0 so the local copy is never indexed.

  7. 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 cwp checks 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. cwp reports 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() on init.

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=json

Comparing 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/hooks

The 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.com

The 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.json gives you clean JSON and still shows progress in the terminal.
  • --verbose echoes every subprocess command before it runs.
  • --dry-run prints the plan and exits 0 without changing anything.
  • The three pass-throughs are the exception. cwp wp, cwp shell and cwp logs have 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.