{"_id":"@bernsteinkraft/cwp","_rev":"3-d3c66f9b12876144b98f703a27e67293","name":"@bernsteinkraft/cwp","dist-tags":{"latest":"0.3.0"},"versions":{"0.2.0":{"name":"@bernsteinkraft/cwp","version":"0.2.0","keywords":["cloudron","wordpress","ddev","wp-cli","bricks","bricks-builder","cli","deployment","devops","self-hosted","staging","database-sync","search-replace","site-migration"],"author":{"name":"Dirk Brünsicke","email":"dirk@bruensicke.com"},"license":"MIT","_id":"@bernsteinkraft/cwp@0.2.0","maintainers":[{"name":"bernsteinkraft","email":"dirk@bernsteinkraft.com"}],"homepage":"https://gitlab.com/d1rk/cwp#readme","bugs":{"url":"https://gitlab.com/d1rk/cwp/-/issues"},"bin":{"cwp":"dist/cli.js"},"dist":{"shasum":"3c70f51423aa394bb5c4e597661531fa9e07659c","tarball":"https://registry.npmjs.org/@bernsteinkraft/cwp/-/cwp-0.2.0.tgz","fileCount":6,"integrity":"sha512-HQHLN5t75l52VLvGSz+TSZWboPPyB6wVL4YMp8selA/eBZGAEWMvunfrTJ/kIRiQOLva5b3Zh/H/KBistOFSXQ==","signatures":[{"sig":"MEUCIHycP3g74QQCfNkIWUCm/ApSgekdPEJQHJ9HBhepZqakAiEAh/tHU/rIpNzAcJYu8aDvqqcYIDmnIgRQUSuZKhP+mmM=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":472338},"type":"module","engines":{"node":">=22"},"gitHead":"27156ababa31f21cbcd216202bf453f5850583fd","scripts":{"dev":"tsup --watch","test":"vitest run","build":"tsup","prepack":"npm run build","typecheck":"tsc --noEmit","test:watch":"vitest"},"_npmUser":{"name":"bernsteinkraft","email":"dirk@bernsteinkraft.com"},"repository":{"url":"git+https://gitlab.com/d1rk/cwp.git","type":"git"},"_npmVersion":"11.17.0","description":"CLI for managing Cloudron-hosted WordPress projects with DDEV","directories":{},"_nodeVersion":"26.5.0","dependencies":{"zod":"^4.4.3","yaml":"^2.9.0","execa":"^10.0.0","commander":"^15.0.0","@clack/prompts":"^1.7.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.1","vitest":"^4.1.10","typescript":"^7.0.2","@types/node":"^26.1.1"},"_npmOperationalInternal":{"tmp":"tmp/cwp_0.2.0_1785250123470_0.8949439453940027","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@bernsteinkraft/cwp","version":"0.3.0","keywords":["cloudron","wordpress","ddev","wp-cli","bricks","bricks-builder","cli","deployment","devops","self-hosted","staging","database-sync","search-replace","site-migration"],"author":{"name":"Dirk Brünsicke","email":"dirk@bruensicke.com"},"license":"MIT","_id":"@bernsteinkraft/cwp@0.3.0","maintainers":[{"name":"bernsteinkraft","email":"dirk@bernsteinkraft.com"}],"homepage":"https://gitlab.com/d1rk/cwp#readme","bugs":{"url":"https://gitlab.com/d1rk/cwp/-/work_items"},"bin":{"cwp":"dist/cli.js"},"dist":{"shasum":"22315efbad5593b307eed599aa02af2ec1e2dcd3","tarball":"https://registry.npmjs.org/@bernsteinkraft/cwp/-/cwp-0.3.0.tgz","fileCount":6,"integrity":"sha512-p0Zkb6JKCfsyJ9008fkNQ0pUXYUXUcQiVE+4KzjJWmej5nRIa8Fuo0CteGYj1lVrqFpas9XqDM1aNLQGSrVPQw==","signatures":[{"sig":"MEYCIQDpea3C+azajy1H9f0TOc102qjZnx6ij1NGovbYdBnTzQIhANtPZ82ek2oWhtze8mm7KiDS0BFnOQ9zelNenHEqHprM","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@bernsteinkraft%2fcwp@0.3.0","provenance":{"predicateType":"https://slsa.dev/provenance/v0.2"}},"unpackedSize":576272},"type":"module","engines":{"node":">=22"},"gitHead":"e2971fe61656dec08a6d4d0ad792cdd91572f4ad","scripts":{"dev":"tsup --watch","test":"vitest run","build":"tsup","prepack":"npm run build","typecheck":"tsc --noEmit","test:watch":"vitest"},"_npmUser":{"name":"GitLab CI/CD","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"gitlab","oidcConfigId":"oidc:ca9c2e4f-4aed-43c0-a842-02a8cd216a9e"}},"repository":{"url":"git+https://gitlab.com/d1rk/cwp.git","type":"git"},"_npmVersion":"12.0.1","description":"CLI for managing Cloudron-hosted WordPress projects with DDEV","directories":{},"_nodeVersion":"24.18.0","dependencies":{"zod":"^4.4.3","yaml":"^2.9.0","execa":"^10.0.0","commander":"^15.0.0","@clack/prompts":"^1.7.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.1","vitest":"^4.1.10","typescript":"^7.0.2","@types/node":"^26.1.1"},"_npmOperationalInternal":{"tmp":"tmp/cwp_0.3.0_1785275824352_0.44087399507040836","host":"s3://npm-registry-packages-npm-production"}}},"time":{"created":"2026-07-28T14:48:43.274Z","modified":"2026-09-18T21:57:46.050Z","0.2.0":"2026-07-28T14:48:43.617Z","0.3.0":"2026-07-28T21:57:04.551Z"},"bugs":{"url":"https://gitlab.com/d1rk/cwp/-/work_items"},"author":{"name":"Dirk Brünsicke","email":"dirk@bruensicke.com"},"license":"MIT","homepage":"https://gitlab.com/d1rk/cwp#readme","keywords":["cloudron","wordpress","ddev","wp-cli","bricks","bricks-builder","cli","deployment","devops","self-hosted","staging","database-sync","search-replace","site-migration"],"repository":{"url":"git+https://gitlab.com/d1rk/cwp.git","type":"git"},"description":"CLI for managing Cloudron-hosted WordPress projects with DDEV","maintainers":[{"email":"dirk@bernsteinkraft.com","name":"bernsteinkraft"},{"email":"dirk@bruensicke.com","name":"d1rk"}],"readme":"# cwp\n\n**C**loudron **W**ord**P**ress — a CLI for managing WordPress projects that are\nhosted on [Cloudron](https://cloudron.io) and developed locally with\n[DDEV](https://ddev.com).\n\n`cwp` is middleware. It orchestrates the Cloudron CLI, DDEV and WP-CLI and adds\nthe WordPress- and Bricks-specific glue those tools don't provide. It never\nreimplements what they already do: DDEV owns the local container, the Cloudron\nCLI owns remote transport, WP-CLI owns WordPress. `cwp` owns **sequencing,\nsafety and project conventions**.\n\nIf you manage a handful of Bricks sites and you are tired of hand-running\n`wp db export`, `search-replace`, upload copies and mail-guard plugins every\ntime you refresh a local copy, this is that workflow with the footguns removed.\n\n---\n\n## Table of contents\n\n- [Requirements](#requirements)\n- [Installation](#installation)\n- [Quickstart](#quickstart)\n- [Onboarding an existing site](#onboarding-an-existing-site)\n- [The safety model](#the-safety-model) — read this once\n- [Commands](#commands)\n- [Uploads strategies](#uploads-strategies)\n- [The scrub pipeline](#the-scrub-pipeline)\n- [Bricks integration](#bricks-integration)\n- [Hooks](#hooks)\n- [Configuration reference](#configuration-reference)\n- [Output, JSON and exit codes](#output-json-and-exit-codes)\n- [Troubleshooting](#troubleshooting)\n\n---\n\n## Requirements\n\n`cwp` shells out to these; it does not bundle them. Run [`cwp doctor`](#cwp-doctor)\nto check them all at once.\n\n| Tool | Why | Install |\n|---|---|---|\n| Node.js ≥ 22 | runtime | `brew install node` |\n| Docker runtime | DDEV needs one | `brew install --cask orbstack`, then start it |\n| DDEV | local WordPress container | `brew install ddev/ddev/ddev` |\n| mkcert | local HTTPS | `mkcert -install` |\n| Cloudron CLI | remote transport | `npm i -g cloudron` |\n\nYou also need to be logged in to your Cloudron: `cloudron login my.example.com`.\n\n---\n\n## Installation\n\n```bash\nnpm i -g @bernsteinkraft/cwp\ncwp --version\ncwp doctor\n```\n\n`cwp` is installed **globally**, once per machine — not per project. Project\nstate lives in the repo (`.cwp.yml`) and in a machine config\n(`~/.config/cwp/config.yml`).\n\n---\n\n## Quickstart\n\nFrom nothing to a working local copy of a production site:\n\n```bash\n# 1. In an empty project directory\nmkdir -p ~/Code/example/example.com\ncd ~/Code/example/example.com\n\n# 2. Scaffold. Interactive: asks for the project name and the Cloudron app,\n#    probes the remote for package type and PHP version, writes the DDEV setup.\ncwp init\n\n# 3. Start the local container and put WordPress core in place\n#    (core is gitignored, so it is never in the repo)\nddev start\nddev wp core download\n\n# 4. Fetch the themes and plugins the site needs to render\ncwp fetch\n\n# 5. Pull the production database and (by default) proxy the uploads.\n#    Runs search-replace, scrubs personal data, installs the mail guard,\n#    runs Bricks post-import steps.\ncwp pull\n\n# 6. Open it\ncwp open\n```\n\nThe first `cwp pull` on an image-heavy Bricks site takes about two minutes\nbecause uploads are proxied, not copied — see\n[Uploads strategies](#uploads-strategies).\n\n---\n\n## Onboarding an existing site\n\nThe quickstart gets a local copy running. A site that has been live for a while\nneeds two more things before you start working in it.\n\n**Take a baseline snapshot immediately after the first successful pull.**\n\n```bash\ncwp db snapshot baseline\n```\n\nRestoring costs five seconds, re-pulling costs minutes. Make it a reflex before\nany experiment that touches the database — that is what\n[`cwp db`](#cwp-db-subcommand) is for.\n\n**Then audit where the site's own code actually lives.** On a Bricks site with\nthe SNN-BRX child theme, custom code accumulates in two places it should never\nbe:\n\n- the child theme directory, and\n- SNN-BRX's code-snippet manager.\n\nBoth are a problem, and for the same reason: **SNN-BRX auto-updates from\nGitHub and will overwrite them.** Anything of yours that lives there is one\nupdate away from being gone.\n\nThe fix is the site plugin that `cwp init` scaffolds for you (`--plugin-slug`,\n`<project>-site` by default). Move custom code into it and register custom\nBricks elements from there on the `init` hook. The rule to hold, without\nexception:\n\n> No project code in `wp-content/themes/`. Ever.\n\n`cwp` does not audit this for you and does not migrate anything — it has no way\nto tell your snippet from the theme author's. Read the theme directory and the\nsnippet manager yourself, plan the move, and take a snapshot before you make\nit.\n\n---\n\n## The safety model\n\n`cwp` exists to make one class of mistake impossible: **pushing the wrong thing\nin the wrong direction to production.** Six rules encode that. They are not\nconfigurable away.\n\n1. **Data flows down, code flows up.** Database and uploads move\n   remote → local. Code (`tracked` paths) moves local → remote. Any command that\n   goes against the grain needs an explicit flag *and* a confirmation.\n\n2. **Protected environments refuse upward writes.** An environment marked\n   `protected: true` rejects every `push`/`deploy` unless you pass `--force`.\n   Mark production protected and you cannot deploy to it by muscle memory.\n\n3. **A database push is deliberately awkward.** `cwp db push` is a separate\n   command that makes you type the environment name to confirm and always backs\n   up first. On a protected environment it needs the same `--force` every upward\n   write does — the typed-name prompt is the database-specific extra hurdle.\n   Pushing a database upward should feel like defusing a bomb, because it is one.\n   *(Ships in 0.4.0.)*\n\n4. **A remote backup is taken before any upward write** (`cloudron backup\n   create`), unless you explicitly disable it. If a deploy goes wrong, there is\n   a restore point one command away.\n\n5. **A local snapshot is taken before any downward write.** `cwp pull` replaces\n   your local database with production's, and on a Bricks site that database is\n   the *only* copy of your design work — layouts, templates, global classes and\n   theme styles are database state the repository deliberately does not carry.\n   So the pull snapshots it first, as `pre-pull-<env>-<timestamp>`, and a\n   snapshot that fails **aborts the pull** rather than warning. Recovery is\n   `cwp db restore <name>`. This is rule 4's mirror image: the remote has\n   Cloudron's backups, the local side has this.\n\n6. **Every pull installs a mail guard.** A pulled production database contains\n   real customer addresses and can be tricked into sending mail. `cwp pull`\n   always installs an mu-plugin that intercepts and logs outbound mail instead\n   of sending it — even when data scrubbing is turned off, because the Bricks\n   stack (SNN-BRX) ships its own SMTP that bypasses DDEV's mail catcher.\n\nOn top of that, pulling a production database is a GDPR processing operation, so\n`cwp pull` **scrubs personal data by default** — see\n[The scrub pipeline](#the-scrub-pipeline).\n\n---\n\n## Commands\n\nEvery mutating command supports `--json`, `--verbose` and `--dry-run`. The\nenvironment argument defaults to `dev`. Human output goes to stderr, machine\noutput to stdout — see [Output, JSON and exit codes](#output-json-and-exit-codes).\n\nThree commands have no `--json`, for one shared reason: `cwp wp`, `cwp shell`\nand `cwp logs` hand the terminal to another program, so stdout already belongs\nto that program and wrapping it in cwp's envelope would corrupt the very output\nyou asked for. `cwp doctor` and `cwp status` change nothing and therefore ignore\n`--dry-run`.\n\n### `cwp init`\n\nScaffold a project. Interactive and safe to re-run — re-running repairs the\nscaffold rather than overwriting your work.\n\n```\ncwp init [--name <name>] [--app <domain>] [--plugin-slug <slug>]\n         [--keep-admin-email <email>] [--env <env>] [--non-interactive] [--json]\n```\n\nIt asks for the project name, the Cloudron app and the site-plugin directory,\nasks for the Cloudron host if the machine config has none, probes the remote to\ndetect the package type (`managed` vs `developer`) and PHP version, runs the\n`doctor` checks, writes the scaffold (`.cwp.yml`, `.ddev/`, the site plugin,\ntracking files, `CLAUDE.md`, `README.md`, `.gitignore`), and registers the\nproject in the machine config.\n\n`--plugin-slug` exists because the project name is usually the domain while the\nplugin keeps a short slug. It defaults to `<name>-site`, is recorded as\n`plugin_slug` in `.cwp.yml`, and is what `tracked` points at.\n\n`--keep-admin-email` records `pull.keep_admin_email` — the account that survives\nscrubbing. Interactively it is offered with your git address as the default.\nTogether with `pull.keep_roles` it is what keeps you able to log in to the local\ncopy after a pull.\n\n#### What \"repairs\" means for `.cwp.yml`\n\nEvery other scaffolded file is left alone if it exists. `.cwp.yml` is different:\nit is the one file `cwp` itself has opinions about, so re-running `init` brings\nit up to date in place. Currently that means removing the\n`snn_301_logs` / `snn_404_logs` / `snn_search_logs` entries from\n`truncate_tables` — those tables never existed — and writing out\n`truncate_post_types`, which is where the SNN-BRX log data actually lives.\n\nEach repair is reported as its own step, so nothing is rewritten silently:\n\n```\n! repair .cwp.yml: pull.truncate_tables — removed snn_301_logs, snn_404_logs,\n  snn_search_logs — no such table has ever existed (B-001)\n```\n\nIt only ever removes entries that cannot do anything and adds keys that were\nabsent. **Your settings are never changed**, and defaults you did not set are\nnot written into the file — so your project keeps following future default\ncorrections instead of pinning today's values. A config that cannot be read or\nwould not validate is left exactly as it is.\n\n**Example**\n\n```bash\ncwp init --name example --app example.com --plugin-slug example-site\n```\n\n**Does not:** install WordPress core, start DDEV, or pull any data. Run\n`ddev start` and `cwp pull` afterwards.\n\n---\n\n### `cwp doctor`\n\nRead-only diagnostics. Each check reports `ok`, `warn` or `fail`, and every\nfailure prints a copy-pasteable fix. Exits non-zero if anything failed.\n\n```\ncwp doctor [--json]\n```\n\nChecks: Node version, Docker runtime, DDEV, mkcert, the Cloudron CLI and login,\nboth config files, that the remote app is reachable and its `wp` responds, PHP\nparity local vs remote, that the configured `package` matches the probed remote\n`wp-content` path, that WordPress core is present in the docroot, and whether\nDDEV is running.\n\nWordPress core is never committed, so a freshly cloned or freshly scaffolded\nproject has none. `doctor` flags that before `cwp pull` runs into it: the import\nwould succeed and then `wp search-replace` would fail with no installation to\nload. The fix is `ddev wp core download`.\n\n**Example**\n\n```bash\ncwp doctor\n# ✓ Node 22.x\n# ✗ Cloudron logged in — run: cloudron login my.example.com\n```\n\n**Does not:** change anything. It only reports.\n\n---\n\n### `cwp pull [env]`\n\nThe workhorse. Downward sync of the database and uploads, plus everything that\nhas to happen to make a production dump safe and usable locally.\n\n```\ncwp pull [env] [--uploads <sync|skip|proxy>] [--no-scrub] [--no-snapshot]\n              [--db-only] [--uploads-only] [--yes] [--json]\n```\n\nSteps, in order: confirm the local DB will be replaced → **snapshot the local\ndatabase** → remote `wp db export` → transfer down → `ddev import-db` →\n`wp search-replace` (remote URL → local URL, all tables, `--skip-columns=guid`)\n→ uploads per strategy → scrub → Bricks post-import steps → `wp cache flush` →\n`post-pull` hook.\n\n| Flag | Effect |\n|---|---|\n| `--uploads <mode>` | Override the uploads strategy (`skip` = don't fetch uploads) |\n| `--no-scrub` | Skip data scrubbing (the mail guard is still installed) |\n| `--no-snapshot` | Skip the local snapshot taken before the import — you keep no restore point |\n| `--db-only` | Database only; no uploads |\n| `--uploads-only` | Uploads only; leave the database untouched, so scrub, the Bricks steps and the snapshot are skipped too |\n| `--yes` | Skip the \"local database will be replaced\" confirmation |\n\n`--db-only` and `--uploads-only` are mutually exclusive. The scrub and the\nBricks post-import steps belong to the database import, so they run whenever the\ndatabase is in scope.\n\n**The pre-pull snapshot.** Before anything is imported, `cwp pull` runs\n`ddev snapshot --name pre-pull-<env>-<timestamp>`, so a pull you regret is one\n`cwp db restore` away. Three things about it are deliberate:\n\n- It comes **after** the confirmation, so a pull you refuse leaves nothing\n  behind — not even a snapshot.\n- A **failed snapshot aborts the pull** (exit 5). Every other late step in the\n  pull degrades to a warning; this one may not, because a safety net you believe\n  in and do not have is worse than none.\n- A project with no WordPress installed yet is **skipped with a reason**, not\n  treated as a failure — the first pull into a fresh project has nothing to\n  snapshot. If `cwp` cannot tell (a stopped DDEV project fails the probe exactly\n  like an empty one), it takes the snapshot anyway.\n\nTurn it off per run with `--no-snapshot`, or for good with\n`defaults.snapshot_before_pull: false` in the machine config. Snapshots\naccumulate — `cwp db list` and `cwp db rm` are the manual answer; nothing is\npruned automatically.\n\n**Example**\n\n```bash\ncwp pull                       # dev, proxy uploads, scrub on, snapshot first\ncwp pull prod --uploads sync   # full upload copy from prod\ncwp pull --db-only --yes       # fast DB-only refresh, no prompt\ncwp pull --no-snapshot --yes   # no restore point — you know what is local\ncwp pull --dry-run --verbose   # print the plan, run nothing\n```\n\nRecovering the state a pull replaced:\n\n```bash\ncwp db list                                  # find pre-pull-dev-20260728-113000\ncwp db restore pre-pull-dev-20260728-113000\n```\n\n**Does not:** ever write upward. There is no `cwp pull` flag that touches the\nremote beyond a temporary export file in the container's `/tmp`, which it\ndeletes again — including when the transfer fails. The downloaded dump is\ndeleted right after the import, so an unscrubbed copy of production never\nlingers on disk.\n\n---\n\n### `cwp fetch [env]`\n\nFetch third-party **themes and plugins** down. They are gitignored and `cwp`\notherwise moves code upward only, so a freshly initialised project has a\ndatabase that references a theme which is not on disk. This is the command that\ncloses that gap.\n\n```\ncwp fetch [env] [--themes] [--plugins] [--yes] [--dry-run] [--json]\n```\n\n| Flag | Effect |\n|---|---|\n| `--themes` | Themes only |\n| `--plugins` | Plugins only |\n| `--yes` | Skip the confirmation before overwriting existing local directories |\n\nIt lists the remote directories, **skips everything inside a `tracked` path**,\nasks before overwriting local directories that already exist, and transfers each\nremaining directory on its own — so your site plugin is never in the blast\nradius.\n\nEach directory travels as a tarball (remote `tar` → `cloudron pull` the archive\n→ extract locally), because `cloudron sync pull` exits 0 having transferred\nnothing in Cloudron CLI 8.2.6. Every transfer is then checked against the remote\nfile count: `cwp` will not report success over an empty directory.\n\n**Example**\n\n```bash\ncwp fetch                  # themes and plugins from dev\ncwp fetch --themes --yes   # just the themes, no prompt\n```\n\n**Does not:** write upward, touch `tracked` paths, install or activate anything,\nor decide what *should* be installed — that inventory is `cwp plugins` in 0.4.0.\n\n---\n\n### `cwp push [env]` and `cwp deploy [env]`\n\nUpward code sync. `push` transfers the `tracked` paths; `deploy` is `push` plus\nremote post-steps (`wp cache flush`, Bricks CSS regeneration, `post-deploy`\nhook).\n\n```\ncwp push   [env] [--force] [--no-backup] [--delete] [--yes] [--dry-run] [--json]\ncwp deploy [env] [--force] [--no-backup] [--delete] [--yes] [--dry-run] [--json]\n```\n\nOrder, and every step is a guard:\n\n1. refuse if the environment is `protected` and `--force` was not given (exit 5);\n2. resolve `tracked` — an empty list, a missing path or one outside the docroot\n   is refused, never interpreted;\n3. show what would move and ask for confirmation (unless `--yes`; refused with\n   exit 6 when cwp cannot prompt);\n4. run `pre-push.sh` — a non-zero exit aborts;\n5. take a remote backup (unless `--no-backup`);\n6. transfer with `cloudron sync push`, hand the files to `www-data` (the user\n   WordPress runs as — the transfer leaves them owned by your local uid), then\n   **verify every file by digest** — the exit code alone is not evidence that\n   anything moved.\n\nNothing happens at all until the confirmation passes: a refused push takes no\nbackup and does not run your hook.\n\n| Flag | Effect |\n|---|---|\n| `--force` | Allow the write to a `protected` environment |\n| `--no-backup` | Skip the pre-push remote backup (warns) |\n| `--delete` | Mirror: also delete remote files that are absent locally |\n| `--yes` | Skip the confirmation |\n| `--dry-run` | Print the plan, change nothing, run no hooks |\n\n#### Additive by default, mirror with `--delete`\n\n`cloudron sync push` only ever adds and overwrites. **A file you delete locally\nstays on the remote** — and in a plugin directory it keeps being loaded, so a\nfile you think you removed can still be running. That is the default, because\ndeleting live files as a side effect of a routine deploy is the kind of surprise\nthis tool exists to prevent.\n\nThe default is not silent about it. When the remote holds files your tracked\npath does not, cwp says so:\n\n```\n! 2 file(s) on /app/data/wp-content/plugins/example-site are not in\n  public/wp-content/plugins/example-site — push is additive and left them in\n  place; pass --delete to mirror\n```\n\n`--delete` mirrors instead: after it, the remote matches your local tree exactly.\nIt is destructive, so it sits behind every guard above — protected environments\nstill refuse, the confirmation still runs (and says outright that files will be\ndeleted), and the backup is still taken first. `cwp push --delete --dry-run`\nlists the exact files it would remove and changes nothing.\n\n#### Verification\n\nAfter the transfer, cwp compares an `md5sum` of every remote file against your\nlocal tree. A file that never arrived, or arrived with different contents, fails\nthe push and is named:\n\n```\n✗ the push of public/wp-content/plugins/example-site did not land intact —\n  1 differing (inc/alpha.txt)\n```\n\nThis matters because `cloudron sync` decides what to send by size and mtime. A\nremote file corrupted to the same length with its mtime intact is reported as\n`Already up to date` and never resent — so **re-running the push does not fix\nit**. Force a resend with `touch` on the local file, or delete the remote copy.\nThe error says so.\n\nIf the remote image has no `md5sum`, cwp warns and falls back to comparing file\ncounts, which is weaker but does not fail a good push.\n\n#### The run summary\n\nEvery push and deploy ends with an account of what actually happened:\n\n```\npush summary:\n  target        dev (example.com)\n  mode          mirror — remote files absent locally were deleted\n  backup        taken (example.com)\n  files sent    2\n  files deleted 1\n    public/wp-content/plugins/example-site → /app/data/wp-content/plugins/example-site\n      2 sent, 1 deleted, 7 file(s) now on the remote\n      deleted: inc/gamma.txt\n```\n\nThe counts are read from the Cloudron CLI's own report of what it moved, not\ninferred by cwp.\n\n**What \"show what would move\" is, precisely:** the tracked paths, their local\nfile counts and their remote targets. It is **not** a file-level diff —\n`cloudron sync` offers no dry-run mode, so cwp does not pretend to know which\nindividual files differ. With `--delete` the deletion set *is* listed exactly,\nbecause that one can be computed from a remote listing.\n\n**Example**\n\n```bash\ncwp deploy dev                     # deploy tracked code to dev\ncwp deploy prod --force            # prod is protected; --force is required\ncwp push dev --dry-run             # see what would be sent\ncwp push dev --delete --dry-run    # see what would be sent *and removed*\ncwp deploy dev --delete            # make the remote match local exactly\n```\n\n**Does not:** push the database (use `cwp db push`), push anything outside the\n`tracked` list in `.cwp.yml`, run hooks during a dry run, or delete anything\nremotely unless you pass `--delete`.\n\n---\n\n### `cwp db <subcommand>`\n\nLocal database snapshots on top of `ddev snapshot`, plus dump import/export.\nEntirely local — no `cwp db` subcommand talks to a Cloudron app. This is the half\nof the safety model that has no backup of its own: the remote database has\nCloudron's backups, your local one has whatever you took yourself.\n\n```\ncwp db snapshot [name]          # default name is a timestamp\ncwp db restore  [name] [--yes]  # interactive picker if no name given\ncwp db list\ncwp db rm <name>\ncwp db export [file]\ncwp db import <file> [--yes]\ncwp db push <env>               # 0.4.0 — types env name to confirm; --force if protected\n```\n\n**Snapshots are database only.** Uploads are not included. This is the single\nmost likely source of confusion, so it is repeated in the help of every\nsubcommand: restoring brings back your Bricks layouts, not your media library.\n\n| Subcommand | What it runs | Notes |\n|---|---|---|\n| `snapshot [name]` | `ddev snapshot --name <name>` | Default name is a timestamp, e.g. `20260728-113000` |\n| `restore [name]` | `ddev snapshot restore <name>` | Replaces the local database — confirms, or `--yes` |\n| `list` | reads `.ddev/db_snapshots/` | Newest first, with creation time |\n| `rm <name>` | `ddev snapshot --cleanup --name <name> -y` | Refuses an unknown name and lists the real ones |\n| `export [file]` | `ddev export-db --file=<file>` | Default `<project>-<timestamp>.sql.gz` in the current directory |\n| `import <file>` | `ddev import-db --file=<file>` | Replaces the local database — confirms, or `--yes` |\n\n`restore` and `import` are the two destructive ones, and they are guarded like\n`cwp pull`: they ask before replacing the local database, `--yes` skips the\nprompt, and they refuse with exit 6 when `cwp` cannot prompt (`--json`, no TTY).\nPicking a snapshot in the interactive list *is* the confirmation — you are not\nasked twice about the same decision.\n\nSnapshot names may contain letters, digits, dot, underscore and hyphen. The name\nbecomes a file under `.ddev/db_snapshots/`, so anything else is refused rather\nthan passed on.\n\n**Example**\n\n```bash\ncwp db snapshot before-migration\n# ... experiment ...\ncwp db restore before-migration\n```\n\nRecovering from a pull that overwrote work you wanted to keep:\n\n```bash\ncwp db list                            # find pre-pull-dev-20260728-113000\ncwp db restore pre-pull-dev-20260728-113000\n```\n\n**Does not:** snapshot or restore uploads, touch the remote, or delete anything\nyou did not name — `cwp db rm` always passes the snapshot name to DDEV, because\n`ddev snapshot --cleanup` on its own removes every snapshot of the project.\n\nAn exported dump is an **unscrubbed copy** of the local database. `cwp` says so\nafter every export, and the scaffolded `.gitignore` keeps `*.sql` / `*.sql.gz`\nout of the repository.\n\n---\n\n### `cwp wp <args...>`\n\nThe escape hatch: every WP-CLI command `cwp` does not wrap, without having to\nremember whether this site wants `ddev wp` or\n`cloudron exec --app <app> -- wp`. Local by default, remote with `--env`.\n\n```\ncwp wp [--env <env>] [--verbose] [--dry-run] -- <wp-cli args...>\n```\n\n| Flag | Effect |\n|---|---|\n| `--env <env>` | Run on that Cloudron environment instead of the local site |\n| `--verbose` | Echo the command before running it |\n| `--dry-run` | Print the command and exit without running it |\n\n**The `--` is mandatory.** Everything before it belongs to `cwp`, everything\nafter it goes to WP-CLI untouched — which is what keeps `--env` or `--verbose`\nfrom ever colliding with WP-CLI's own `--path`, `--format=json` or\n`--allow-root`. Leaving it out is a usage error with a fix hint, not a guess:\n\n```\n$ cwp wp plugin list\n✗ cwp wp needs a `--` before the WP-CLI command\n  → write it as `cwp wp -- plugin list`. …\n```\n\n**Example**\n\n```bash\ncwp wp -- plugin list\ncwp wp -- db query 'SELECT ID FROM wp_posts LIMIT 1' --skip-plugins\ncwp wp -- shell                       # interactive: the terminal is handed over\ncwp wp --env dev -- option get siteurl\ncwp wp --env prod --dry-run -- plugin list   # see the exact command first\n```\n\nThree things are deliberately *not* cwp's business here:\n\n- **The terminal belongs to WP-CLI.** stdio is inherited rather than captured,\n  so `wp shell` and `wp db cli` are interactive, output streams as it happens,\n  and colour and progress bars survive.\n- **The exit code is WP-CLI's,** forwarded verbatim, because a pass-through that\n  rewrites exit codes is useless in a script. `cwp`'s own refusals — a missing\n  `--`, an unknown environment, no project — all happen before WP-CLI starts and\n  use the [cwp exit codes](#exit-codes).\n- **There is no `--json`.** stdout is WP-CLI's payload and wrapping it in cwp's\n  envelope would corrupt it. Use WP-CLI's own `--format=json`.\n\n#### Getting JSON out of a site\n\nThis is the common case — pulling categories, posts, users or options out of a\nsite as data — and it needs nothing from `cwp` beyond staying out of the way:\n\n```bash\ncwp wp -- term list category --format=json > kategorien.json\ncwp wp -- post list --format=json --fields=ID,post_title,post_date > posts.json\ncwp wp --env prod -- post list --format=json | jq '.[].post_title'\n```\n\nIt works **because** there is no `cwp --json`, not in spite of it. stdio is\ninherited, so WP-CLI writes straight into your redirect or your pipe, and every\nline `cwp` itself emits goes to stderr. A redirected file therefore contains\nWP-CLI's bytes and nothing else — including on a remote run, where the\n\"running on prod\" notice stays on stderr:\n\n```bash\n$ cwp wp --env prod -- post list --format=json > posts.json\nrunning on prod (example.com) — a protected environment   # ← stderr, not the file\n\n$ cat posts.json\n[{\"ID\":42,\"post_title\":\"Hallo Welt\",\"post_date\":\"2026-07-01 09:00:00\"}]\n```\n\nAdd `2>/dev/null` when that notice is in the way of a pipeline. Had `cwp wp`\noffered a `--json` of its own, you would instead be digging your categories out\nof `{\"ok\":true,\"command\":\"wp\",\"steps\":[…]}` — and `wp shell` would have stopped\nworking, because wrapping the output means buffering it.\n\n**One caveat, and it is WP-CLI's rather than cwp's:** WP-CLI sends its own\nwarnings and errors to stderr, but a plugin or theme that `echo`es or triggers a\nPHP notice while loading writes to *stdout* — and that lands in the middle of\nyour JSON. If a file will not parse, take the site's own code out of the picture:\n\n```bash\ncwp wp -- post list --format=json --skip-plugins --skip-themes > posts.json\n```\n\nRemote invocations run through `cloudron exec` and add `--allow-root` only if\nthe container refuses without it; that is probed once and cached per host. `cwp`\nnever passes `--path` remotely — Cloudron's `wp` wrapper supplies it, and\nWordPress core lives at `/app/code`, not under the `/app/data` docroot.\n\nA remote target is always named on stderr before the command runs, and a\n`protected` environment is called out as such:\n\n```\n$ cwp wp --env prod -- plugin list\nrunning on prod (example.com) — a protected environment\n```\n\n**Does not:** interpret your WP-CLI arguments, and **does not guard them**. It\ncannot know which WP-CLI subcommands are destructive, so it does not pretend to:\n`--env prod` runs on production, and a `--force` on `cwp wp -- plugin list`\nwould be a habit, not a safeguard. Safety rule 2 covers upward *transfers* of\nfiles and databases, which is what `push`, `deploy` and `db push` are for.\n\n---\n\n### `cwp scrub`\n\nRun the [scrub pipeline](#the-scrub-pipeline) against the local database on its\nown, so you can re-run it or test it in isolation.\n\n```\ncwp scrub [--dry-run] [--verbose] [--json]\n```\n\n**Example**\n\n```bash\ncwp scrub\n```\n\nUser anonymisation is the one step that is fatal on failure: a database that is\nreported as scrubbed but is not is worse than an obvious error. Everything after\nit (log tables, transients, `blog_public`) warns and carries on.\n\n**Does not:** touch the remote, and does not run search-replace or import data —\nit only sanitises what is already local.\n\n---\n\n### `cwp shell`\n\nAn interactive shell: `ddev ssh` locally, `cloudron exec` on an environment.\n\n```\ncwp shell [env] [--verbose] [--dry-run]\n```\n\n**Example**\n\n```bash\ncwp shell            # a shell in the local DDEV web container\ncwp shell prod       # a shell inside the Cloudron app\n```\n\nLike [`cwp wp`](#cwp-wp), this hands the terminal over and forwards the shell's\nexit code verbatim, so it has no `--json`. A tty is requested only when `cwp`\nitself has one, so `cwp shell prod < script.sh` behaves in a pipeline. A remote\ntarget is named on stderr first, with `protected` called out:\n\n```\n$ cwp shell prod\nopening a shell on prod (example.com) — a protected environment\n```\n\n**Does not:** guard anything. Opening a shell writes nothing by itself, and cwp\ncannot know what you will type into it — a confirmation prompt here would only\ntrain the reflex that dismisses the prompts that matter.\n\n---\n\n### `cwp logs`\n\nTail the logs: `ddev logs` locally, `cloudron logs` for an environment.\n\n```\ncwp logs [env] [-f] [--lines <n>] [--verbose] [--dry-run]\n```\n\n| Flag | Meaning |\n|---|---|\n| `-f`, `--follow` | Stream new lines until interrupted (Ctrl-C). |\n| `--lines <n>` | Print `n` lines of history. |\n\n**Example**\n\n```bash\ncwp logs                    # the local web container\ncwp logs --lines 50         # the last fifty local lines\ncwp logs prod -f            # follow production\n```\n\n**These two flag names are cwp's own, and that is the point of the command.**\nThe tools underneath disagree, and one spelling means opposite things:\n\n| | follow | line count |\n|---|---|---|\n| `ddev logs` | `-f, --follow` | `--tail <n>` |\n| `cloudron logs` | `-f, --tail` | `-l, --lines <n>` |\n\nA `--tail 50` typed from memory prints fifty lines locally and starts\n**following production** remotely. `cwp logs -f` and `cwp logs --lines <n>` each\nmean one thing, and cwp maps them to whichever spelling each tool wants.\n\nInterrupting a follow exits 130 (the shell's convention for SIGINT), not 1 —\nstopping `-f` is how the command ends, not a failure.\n\n**Does not:** read the server's logs. `cloudron logs --system` is about the\nmachine; this command is about your project, so only the app's own logs are in\nscope. There is no `--json`: the output belongs to the tool underneath, and `-f`\nnever terminates on its own, so there would be no envelope to close.\n\n---\n\n### `cwp open`\n\nOpen the site in a browser.\n\n```\ncwp open [env] [--print] [--verbose] [--dry-run] [--json]\n```\n\n**Example**\n\n```bash\ncwp open                              # the local DDEV URL\ncwp open prod                         # the environment's configured url\nopen \"$(cwp open prod --print)\"       # on a machine cwp has no opener for\n```\n\nThe URL comes from DDEV itself locally (falling back to the conventional\n`<name>.ddev.site` when the project is stopped) and from `environments.<env>.url`\nremotely. `open` is used on macOS and `xdg-open` elsewhere.\n\n**A missing opener is not a failure.** On a headless machine, over SSH, or on\nWindows — where cwp deliberately ships no opener rather than an untested\n`cmd /c start` — it prints the URL and exits 0.\n\n`--print` writes the URL to stdout and opens nothing; that is the scriptable\nform, and the one place in this command where stdout carries a payload. It\ncannot be combined with `--json`, since both claim stdout; cwp refuses rather\nthan corrupting one of them.\n\n**Does not:** start anything. If DDEV is stopped, `cwp open` shows you the URL\nthe project *will* have — run `ddev start` yourself.\n\n---\n\n### `cwp status`\n\nThe \"where am I in this project\" screen, so the answer does not need\n`ddev describe`, `cat .cwp.yml` and `cwp db list` separately.\n\n```\ncwp status [--verbose] [--json]\n```\n\n**Example**\n\n```bash\n$ cwp status\n✓ project — example.com — /Users/you/Code/example/example.com\n✓ docroot — public\n✓ php — 8.3\n✓ environment dev — https://dev.example.com — app dev.example.com — managed\n✓ environment prod — https://example.com — app example.com — developer — protected\n✓ ddev — running — https://example-com.ddev.site\n– last pull with a snapshot (dev) — unknown — no pre-pull snapshot on disk […]\n✓ last pull with a snapshot (prod) — 2026-07-27 09:15 — pre-pull-prod-20260727-091500\n✓ snapshots — 3 — newest baseline (2026-07-28 09:12)\n```\n\n**It makes no remote calls at all.** It reads the config, the DDEV state and the\nsnapshot directory — nothing else. It must be instant and must work on a train,\nwith Docker stopped and no Cloudron login. [`cwp doctor`](#cwp-doctor) is the\ncommand that talks to the remote and takes as long as that costs.\n\n**The last pull is read off the snapshots**, not from a state file cwp would\notherwise have to write, gitignore and keep consistent: since every pull leaves\n`pre-pull-<env>-<timestamp>`, the restore point already records both *when* and\n*from which environment*. That reading has three limits, and the output says\n\"last pull **with a snapshot**\" because of the first:\n\n1. A pull with `--no-snapshot` or `--uploads-only` leaves no snapshot and is\n   therefore invisible here.\n2. `cwp db rm` on a `pre-pull-*` snapshot deletes the record along with the\n   restore point — reclaiming disk space quietly rewrites this history.\n3. The snapshot is taken *before* the import, so it proves a pull got past the\n   confirmation guard, **not** that it finished. A pull that died on the\n   transfer leaves the same trace as one that succeeded.\n\nAn environment with no `pre-pull-*` snapshot is therefore reported as *unknown*\nwith the reason — never as \"never pulled\", which cwp cannot know.\n\n**Does not:** contact Cloudron, check whether the remote is reachable, or verify\nthat the local database matches anything. `--dry-run` is meaningless here and is\nignored, exactly as `cwp doctor` ignores it.\n\n---\n\n### Still to come (0.4.0)\n\n```\ncwp projects           # list registered projects on this machine\ncwp config get <key>\ncwp config set <key> <value>\n```\n\n`cwp config` will read and write both config layers through the typed config\nmodule — it never hand-edits YAML. Use a dotted path, e.g.\n`cwp config set pull.uploads sync`. Note that `pull.uploads` (project) and\n`defaults.uploads` (machine) are different keys in different files.\n\nNeither command exists yet; they are tracked as F-022 and F-026.\n\n---\n\n## Uploads strategies\n\nSet the default in `.cwp.yml` under `pull.uploads`; override per run with\n`cwp pull --uploads <mode>`.\n\n| Mode | Behaviour | When |\n|---|---|---|\n| `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. |\n| `sync` | Full copy of the remote uploads directory. Correct, slow, large. | When you need every asset locally (offline work, media edits). |\n| `skip` | Nothing. Images are broken locally. | DB-only work where you don't care about media. |\n\nThe proxy mu-plugin filters `wp_get_attachment_url`,\n`wp_get_attachment_image_src` and `wp_calculate_image_srcset`: if the file\nexists locally it is served locally, otherwise the URL falls back to the remote\nhost. It is written by `cwp pull` and is gitignored.\n\n`proxy` keeps the local copy coupled to production — a missing image is fetched\nlive from the remote host, so a scrubbed database still loads original\nproduction media on demand. Because of that, `sync` and `skip` **remove** a\nproxy plugin left over from an earlier pull instead of leaving it active.\n\n---\n\n## The scrub pipeline\n\nRuns after every `pull` unless you pass `--no-scrub`. Pulling a production\ndatabase onto a laptop is a GDPR processing operation; scrubbing is the default\nfor that reason, not for convenience.\n\n1. **Neutralise outbound mail** — install `cwp-mail-guard.php` (always, even with\n   scrubbing off).\n2. **Anonymise users — except the site's own staff.** Users holding one of\n   `pull.keep_roles` (default `administrator` and `editor`), super admins, and\n   `pull.keep_admin_email` keep their real data. Everyone else — subscribers,\n   customers, visitors, where the bulk personal data lives — gets\n   `user-<ID>@example.invalid`, a blank display name and a reset password.\n   Written straight to the database rather than through `wp_update_user()`, so\n   plugins that sync users to external services are not triggered.\n\n   Keeping staff accounts is deliberate: they are what you log in with locally.\n   Set `keep_roles: []` if you want no exceptions — but then no local account\n   has a password you know. Sessions are destroyed for everyone, staff included.\n3. **Truncate log and submission tables** — Bricks form submissions by default,\n   configurable via `pull.truncate_tables`; existence is checked first, so a name\n   that does not apply to your site is reported as absent rather than failing.\n4. **Delete log posts** — the SNN-BRX 404, redirect, activity and mail logs,\n   configurable via `pull.truncate_post_types`.\n\n   These are **not tables.** Every SNN-BRX logging feature registers a custom\n   post type and writes entries to `wp_posts`, so truncating tables could never\n   reach them — and the mail log stores message bodies. Deletion goes through\n   `wp_delete_post(..., true)`, which takes the postmeta with it (that is where\n   the IP addresses and user agents live) and forces past the trash.\n\n   Like anonymisation, a failure here **aborts the scrub**: a database still\n   holding production logs while reporting success is the outcome this exists to\n   prevent.\n\n   `snn_301_redirects` is deliberately *not* in the defaults — that is the\n   redirect *rules* post type, your site configuration, not a log.\n5. **Delete transients and expired sessions.**\n6. **Set `blog_public = 0`** so the local copy is never indexed.\n7. **Report** what was scrubbed.\n\nRe-runnable on its own with [`cwp scrub`](#cwp-scrub).\n\n---\n\n## Bricks integration\n\nWhen `bricks.enabled` is true, `cwp pull` runs these best-effort steps (a failure\nwarns, it does not abort the pull):\n\n- **Regenerate code signatures.** Bricks signs code elements per site; imported\n  content stays blocked until re-signed. There is no official WP-CLI command for\n  this yet, so `cwp` checks whether one exists and otherwise tells you to\n  regenerate under **Bricks → Settings → Custom Code → Code signatures**.\n- **Regenerate CSS** via `wp bricks regenerate_assets`, when the site uses\n  external CSS files.\n- **Report license state** — Bricks is licensed per site; your local install may\n  need its own activation. `cwp` reports this; it does not try to fix it.\n\n> **Convention:** SNN-BRX occupies the WordPress child-theme slot and\n> auto-updates from GitHub. WordPress has no grandchild themes, so **project code\n> never goes in the theme** — it belongs in the site plugin\n> (`wp-content/plugins/<name>-site`). Custom Bricks elements are registered from\n> there via `\\Bricks\\Elements::register_element()` on `init`.\n\n`cwp bricks export|import` (round-tripping templates through `bricks/*.json`)\nlands in 0.4.0.\n\n### Custom post types belong in the site plugin\n\nRegister them in code — `plugins/<name>-site/inc/post-types/`, on `init` — not\nwith CPT UI, not with the SNN-BRX post-type manager, not with ACF post types.\n\nThe reason is the same one the whole tool is built on: **the database only ever\nflows downward.** A post type defined in the database lives in `wp_options` or\n`wp_posts`, so the only way to get it to production is `cwp db push` — typed\nenvironment name, mandatory backup, `--force` on a protected environment, and it\ntakes your entire local database with it. That path exists for data. A post type\nis structure, and structure travels with your code, via `cwp deploy`.\n\n`.cwp.yml` is not the place either. It configures `cwp`, not your site. (Its one\npost-type setting, `pull.truncate_post_types`, is about deleting log *entries* —\nsee [The scrub pipeline](#the-scrub-pipeline).)\n\nYou do not need to write the registration boilerplate by hand — WP-CLI has a\ngenerator, and `cwp wp` gets you to it:\n\n```bash\ncwp wp -- scaffold post-type buch --plugin=<your-site-plugin> --label=\"Bücher\"\n```\n\nTo see what is actually registered where — the definitive answer, read from a\nlive install rather than from your code:\n\n```bash\ncwp wp --env prod -- post-type list --format=json\n```\n\nComparing the two sides automatically is planned as a `cwp doctor` check\n(F-025); today it is a manual diff.\n\n---\n\n## Hooks\n\nExecutable shell scripts in `.cwp/hooks/`, run with the project root as the\nworking directory and a documented set of environment variables: `CWP_ENV`,\n`CWP_PROJECT`, `CWP_LOCAL_URL`, `CWP_REMOTE_URL`, `CWP_REMOTE_APP`,\n`CWP_WP_CONTENT`.\n\n| Hook | Runs | On a non-zero exit |\n|---|---|---|\n| `pre-push.sh` | before `cwp push` / `cwp deploy` | **aborts** — exit 5, nothing is transferred |\n| `post-pull.sh` | after `cwp pull` | warns; the pull already happened |\n| `post-deploy.sh` | after `cwp deploy` | warns; the files are already on the remote |\n\nOnly `pre-push` can stop anything, and that is the point of it: it runs *before*\nthe one operation in this tool that can damage a live site. The other two run\nafter the fact, so failing the whole command would misreport what actually\nhappened.\n\n`cwp init` generates them as commented no-op samples. A hook that exists but is\nnot executable is reported with a `chmod +x` hint rather than silently skipped.\n\n---\n\n## Configuration reference\n\nTwo layers, both YAML, both maintained by `cwp` (`init` and `config set`) rather\nthan by hand.\n\n### Project config — `.cwp.yml` (committed)\n\n```yaml\nversion: 1\nname: example\ndocroot: public\nphp: \"8.3\"\nplugin_slug: example-site                    # site plugin dir; default <name>-site\n\nenvironments:\n  dev:\n    cloudron_app: example.com   # the Cloudron app location (= --app)\n    url: https://example.com\n    package: managed                     # managed | developer\n    protected: false\n  prod:\n    cloudron_app: www.example.com\n    url: https://www.example.com\n    package: managed\n    protected: true                      # blocks upward writes without --force\n\ntracked:                                 # paths cwp push/deploy send upward\n  - wp-content/plugins/example-site\n\npull:\n  uploads: proxy                         # sync | skip | proxy\n  scrub: true\n  keep_admin_email: dirk@example.com     # this user survives scrubbing\n  keep_roles:                            # these roles survive scrubbing\n    - administrator\n    - editor\n  truncate_tables:                       # log/submission tables the scrub empties,\n    - bricks_form_submissions            # written without the table prefix; a name\n                                         # that does not exist is simply reported\n                                         # as absent, never an error\n  truncate_post_types:                   # post types whose posts the scrub deletes.\n    - snn_404_logs                       # SNN-BRX logs are posts, not tables — and\n    - snn_redirect_logs                  # note snn_301_redirects is NOT here: that\n    - snn_activity_log                   # is the redirect *rules* type, which is\n    - snn_mail_logs                      # configuration and must not be deleted\n\nbricks:\n  enabled: true\n  child_theme: snn-brx-child-theme\n  export_dir: bricks\n\nhooks_dir: .cwp/hooks\n```\n\nThe remote `wp-content` path is derived from `package`, not stored:\n`managed` → `/app/data/wp-content`, `developer` → `/app/data/public/wp-content`.\n`cwp doctor` verifies the configured value against the real remote path.\n\n### Machine config — `~/.config/cwp/config.yml` (never committed)\n\n```yaml\nversion: 1\ncloudron_host: my.example.com\n\ndefaults:\n  uploads: proxy\n  scrub: true\n  backup_before_push: true\n  snapshot_before_pull: true\n\nprojects:\n  example: ~/Code/example/example.com\n```\n\nThe `projects` registry is maintained by `cwp init` and powers `cwp projects`.\n\n---\n\n## Output, JSON and exit codes\n\n- **Human output → stderr**, one spinner per step.\n- **`--json` → stdout**, a single object:\n  `{ \"ok\": bool, \"command\": str, \"env\": str, \"steps\": [...], \"warnings\": [...], \"error\": {...}|null }`.\n  Because human output is on stderr, `cwp pull --json > result.json` gives you\n  clean JSON and still shows progress in the terminal.\n- **`--verbose`** echoes every subprocess command before it runs.\n- **`--dry-run`** prints the plan and exits 0 without changing anything.\n- **The three pass-throughs are the exception.** `cwp wp`, `cwp shell` and\n  `cwp logs` have no `--json`, because stdout carries the other program's own\n  output and wrapping it would corrupt it — for WP-CLI, use its `--format=json`.\n  They also forward that program's exit code verbatim, so a code from the table\n  below may there mean whatever the program meant by it. cwp's own refusals\n  still use these codes and all happen before the program starts.\n\n### Exit codes\n\n| Code | Meaning |\n|---|---|\n| 0 | success |\n| 1 | generic error |\n| 2 | configuration error |\n| 3 | missing dependency |\n| 4 | remote / Cloudron error |\n| 5 | refused by a safety guard |\n| 6 | aborted by the user |\n\nA pass-through whose program was killed by a signal reports the shell's\n`128 + signum` instead — so interrupting `cwp logs -f` with Ctrl-C exits 130,\nwhich is how that command normally ends rather than a failure.\n\n---\n\n## Troubleshooting\n\n**`cwp doctor` says the package path doesn't match.** The `package` value in\n`.cwp.yml` disagrees with the real remote `wp-content` location. Set it to\n`managed` or `developer` to match what doctor probed — a wrong value sends files\nto the wrong path on push.\n\n**Images are broken locally.** You are on `--uploads skip`, or proxy mode is off.\nRun `cwp pull --uploads proxy` (or `sync` for a full copy).\n\n**Local site sends real email.** It should not — the mail guard is installed on\nevery pull. If you imported a database by hand instead of via `cwp pull`, run\n`cwp scrub` to install the guard.\n\n**`deploy` to production is refused.** Production is `protected`. That is the\nsafety guard working. Pass `--force` only when you mean it.\n\n**A remote `wp` command fails with a root error.** `cwp` retries with\n`--allow-root` and caches the result. If it persists, run\n`cloudron exec --app <app> -- wp --info` to see the raw error.\n\n**A pull aborts on the pre-pull snapshot.** `cwp` refuses to replace the local\ndatabase when it could not create a restore point, and nothing has been imported\nat that point. Usually the DDEV project is not running — `ddev start`, then pull\nagain. `cwp pull --no-snapshot` proceeds without a restore point, which is only\nsensible when you know nothing local is worth keeping.\n\n**`cwp db list` fills up with `pre-pull-…` snapshots.** Every pull leaves one and\nnothing prunes them. Delete the ones you no longer want with `cwp db rm <name>`.\n\n**Cloudron CLI not found.** `npm i -g cloudron`, then `cloudron login <host>`.\n\n---\n\n## Versioning and licence\n\nSemantic versioning, tracked in `VERSION.txt`; changes in `CHANGELOG.md`\n(Keep a Changelog format). See `SPEC.md` for the authoritative design and\n`FEATURES.md` for the roadmap.\n\nMIT — see [`LICENSE`](LICENSE). The licence covers `cwp` itself, not the tools\nit orchestrates: Cloudron, DDEV, WP-CLI and WordPress carry their own, and\nBricks is commercial and licensed per site.\n","readmeFilename":"README.md"}