❯ Design document
SPEC.md
How cwp is built
This is the design document, not the manual. It describes mechanisms and the reasoning behind them; it is not a guide to using the commands, and where it discusses behaviour the shipped binary is the authority.
Rendered verbatim from SPEC.md on the main branch. Nothing on this page is edited, summarised or reordered — if the file changes, this page changes with it.
cwp — Specification
Status: Current as of 0.4.0
Owner: BERNSTEINKRAFT n.e.V.
Successor: docs/ARCHITECTURE.md — the v1.0 target architecture
(F-040 – F-043). This document is authoritative for cwp as it is; that one is
authoritative for cwp as it is meant to become. Where they disagree, this one
wins until the corresponding phase lands, at which point this one is updated. The
safety invariants are identical in both, deliberately.
1. Purpose
cwp (Controlled WordPress) is a CLI that manages the lifecycle of WordPress
projects which are
- hosted on Cloudron (Hetzner server, Docker-based apps), and
- developed locally with DDEV.
It is middleware, not a framework. It orchestrates two existing tools — the Cloudron CLI and DDEV — and adds the WordPress- and Bricks-specific glue that neither of them provides. It never reimplements what those tools already do well.
On the name. The C is controlled, not Cloudron. Moving a WordPress site
between environments is normally an unpredictable sequence of half-remembered
commands, and what this tool contributes is not transport — the tools above
already do transport — but control over the movement: a stated direction, a
guard that refuses, a restore point taken first, a verification afterwards, and
a test suite that asserts each of those still happens. Cloudron is the host it
happens to run against today; docs/ARCHITECTURE.md §9 makes it one provider of
three, at which point a name that meant Cloudron would be actively wrong.
The primary human user is a solo developer managing 4–6 parallel projects.
The primary machine user is Claude Code, which drives cwp from within a
project repository.
2. Design principles
-
Asymmetric data flow. Database and uploads flow down (remote → local). Code flows up (local → remote). Every command that violates this direction requires an explicit flag and an interactive confirmation.
Sharpened 2026-07-31 (F-031). The extent is what this principle is about, not the table. It guards against a database write whose blast radius is “everything” — where nobody can enumerate beforehand what will differ afterwards. That is
cwp db push(§5.6), and it is why that command is deliberately awkward. A write of individually named items is a different operation and always has been:cwp bricks importhas replaced global classes on a protected environment since 0.4.0 under the full §5.4 guard set, and nobody read it as a breach. So, stated as it should have been from the start:Bulk database state flows downward. Individually named items may flow upward, under the full guard set, with the affected items listed before the write.
-
Delegate, do not reimplement. DDEV owns the local container. Cloudron CLI owns remote transport. WP-CLI owns WordPress.
cwpowns sequencing, safety and project conventions.Where the line runs — stated 2026-07-31, from field feedback. This principle forbids reimplementing what those tools already do. It does not forbid
cwpfrom looping.- Logic is deciding what the target state should be: which class is obsolete, what value a token takes. That belongs to a person.
- Transport is establishing a state that has already been declared: diffing, ordering, calling N abilities in sequence, collecting the failures.
The rule that keeps the second one glue:
cwpinvents no values. It reads a file, compares, calls. The momentcwpsets a default or generates a name, the line has been crossed. F-020 and F-021 already run on that side of it, and F-033 is the case the sentence was written for. -
Leave no dependency behind.
cwp initgenerates a working DDEV setup — including a native DDEV provider — so the project remains usable by someone who does not havecwpinstalled. -
Idempotent. Running any command twice produces the same result.
initon an existing project repairs rather than overwrites. -
Machine-readable. Every command supports
--json. Exit codes are stable and documented. Human output goes to stderr; JSON goes to stdout. -
Explicit over clever. No hidden magic, no implicit environment selection. The environment is always named or defaults to
dev. -
KISS / LEAN. Minimal dependency surface. No plugin system — shell hooks cover extensibility.
3. Stack
Decided — not open for revision during implementation.
- Runtime: Node.js ≥ 22, ESM only
- Language: TypeScript, built with
tsupto a single ESM bundle - CLI framework:
commander - Prompts:
@clack/prompts - Config:
yaml+zodfor schema validation - Subprocess:
execa - Tests:
vitest
Distribution: npm, scoped as @bernsteinkraft/cwp, binary name cwp.
Installed globally (npm i -g @bernsteinkraft/cwp), not per project.
Explicit non-goals: no Composer/Bedrock integration, no CI runner, no multi-user/team features, no support for hosts other than Cloudron.
The last of those is superseded by F-041 when it lands: Cloudron becomes the first implementation of a provider interface rather than an assumption. It was the right non-goal to write — an interface with one implementation is speculative generality — and it stops being right once a second one exists to keep it honest. Until then, this section stands.
4. Configuration
Two layers. Both are YAML. Neither is ever written by hand in normal use —
cwp init and cwp config set maintain them.
4.1 Project config — .cwp.yml (committed to Git)
version: 1
name: example
docroot: public
php: "8.3"
plugin_slug: example-site # site-plugin directory; defaults to <name>-site
environments:
dev:
cloudron_app: example.com # Cloudron app location (= --app value)
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 all upward writes without --force
# Paths (relative to project root, so including the docroot) that `cwp deploy`
# pushes upward.
tracked:
- public/wp-content/plugins/example-site
pull:
uploads: proxy # sync | skip | proxy
scrub: true
keep_admin_email: dirk@example.com # this user survives scrubbing
keep_roles: # these roles survive scrubbing
- administrator
- editor
bricks:
enabled: true
child_theme: snn-brx-child-theme # detected; any child of Bricks (F-029)
export_dir: bricks
wp_user: 1 # optional — who the ability transport runs as (§9.3)
hooks_dir: .cwp/hooks
4.2 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 automatically by cwp init and enables
cwp projects and future cross-project commands.
4.3 Remote path resolution
The two Cloudron WordPress packages place wp-content in different locations.
This must be resolved from environments.<env>.package:
| package | wp-content path |
|---|---|
managed |
/app/data/wp-content |
developer |
/app/data/public/wp-content |
cwp doctor verifies the configured value by probing the remote filesystem and
reports a mismatch rather than guessing silently.
5. Commands
5.1 cwp init
Interactive. Safe to re-run.
- Ask for project name (default: current directory name).
- Ask for Cloudron app / domain for the
devenvironment (default: current directory name if it looks like a domain). - Ask for the site-plugin directory (default:
<name>-site). It is asked separately because the project name is often the domain while the plugin keeps a short slug —example→example-site. - Ask for Cloudron host if the machine config has none.
- Detect the package type (
managed/developer) by probing the remote. - Detect the remote PHP version and offer it as the DDEV PHP version.
- Run the
doctorchecks; abort with actionable hints if a hard dependency is missing. - Scaffold (see §6). On an existing project this restores missing files and
repairs
.cwp.ymlin place, reporting each change (see §6.1). - Register the project in the machine config.
- Print next steps.
Flags: --name, --app, --plugin-slug, --env, --non-interactive, --json.
Defaults on a re-run (B-008). Steps 1–3 describe a fresh project. When the
current directory already holds a valid .cwp.yml, that file is the source of
the defaults — flag > existing config > derivation from the directory name. Not
doing this made a re-run offer back a name and plugin slug the project never had:
in example.com/, name became the domain and plugin_slug became
example.com-site, which does not validate. “Safe to re-run” has to
mean the tool offers back what it wrote.
Inherited: name, plugin_slug, docroot, php, bricks.enabled,
pull.keep_admin_email, and the environment’s cloudron_app and url (the
recorded URL is kept when the app is unchanged, since it may legitimately differ
from https://<app>). The remote probes in steps 4–5 degrade to what the config
records rather than to a generic guess, so an offline re-run does not contradict
the file.
Not inherited: the app for an environment the config does not define. Adding
prod to a project that only has dev must not silently reuse the dev app.
The config is read from the current directory only — never by walking upward —
so running init one directory too deep cannot inherit a parent project’s
settings.
Deriving the plugin slug (B-009). Naming the project directory after the
domain is the convention here, so <name>-site is not a usable default on its
own: it yields example.com-site. The derivation takes the first domain
label and sanitises it — example.com → example-site. An
explicit --plugin-slug still wins; a slug like shop-site for a project
named example.com is derivable from nothing.
5.2 cwp doctor
Read-only diagnostics. Each check reports ok | warn | fail plus, on failure, a
copy-pasteable fix. Checks:
| Check | Fix hint on failure |
|---|---|
| Node ≥ 22 | brew install node |
| Docker runtime reachable | brew install --cask orbstack, then start OrbStack |
| DDEV installed | brew install ddev/ddev/ddev |
| mkcert CA installed | mkcert -install |
| Cloudron CLI installed | npm i -g cloudron |
| Cloudron logged in | cloudron login <host> |
| Machine config present & valid | cwp init |
| Project config present & valid | cwp init |
Remote app reachable (cloudron list contains app) |
check cloudron_app value |
Remote wp responds |
cloudron exec --app <app> -- wp --info |
| PHP parity local vs remote | adjust .ddev/config.yaml |
package value matches probed remote path |
correct .cwp.yml |
| WordPress core present in the docroot | ddev wp core download |
| DDEV project running | ddev start |
| Uploads fallback installed (proxy mode, §7.1) | cwp pull --uploads-only, then ddev restart |
| Uploads fallback serves a missing file (one request, expecting 200) | ddev restart; check the remote serves it unauthenticated |
Exit code 0 only if no fail.
5.3 cwp pull [env] (default dev)
Downward sync. The workhorse.
- Guard: confirm the local database will be replaced.
- Snapshot the local database (
pre-pull-<env>-<timestamp>) unless disabled. Placed after the guard so a refused pull has no side effects, and before any destructive step so the restore point genuinely exists. A failed snapshot aborts the pull — unlike the best-effort steps below, this one is the safety net itself. Skipped with a reason when there is no local database yet or the scope excludes it (--uploads-only). - Remote:
wp db exportto a temp path inside the app container. - Transfer down via
cloudron pull, then delete the remote temp file. ddev import-dbddev wp search-replace <remote_url> <local_url> --all-tables --skip-columns=guid(WP-CLI handles PHP serialization correctly — never use raw SQL here, Bricks stores element trees as serialized JSON in postmeta.)- Uploads according to
pull.uploads(§7). - Scrub according to
pull.scrub(§8). - Bricks post-import steps (§9).
ddev wp cache flush- Run
post-pullhook if present.
Flags: --uploads <mode> (sync|skip|proxy — skip replaces the former
--no-uploads, which is dropped as redundant), --no-scrub, --no-snapshot,
--db-only, --uploads-only, --yes, --json. --db-only and
--uploads-only are the two scope switches; they are mutually exclusive.
Why the snapshot is not optional by default (F-017). Every other guard in
cwp protects the direction that already has a backup: the remote database is
covered by cloudron backup create, remote code by the same. The local database
is covered by nothing, and on a Bricks site it is the only copy of the design
work — layouts, templates, global classes and theme styles are all database
state that the repository deliberately does not carry (§6). --no-snapshot and
defaults.snapshot_before_pull: false exist for the fast path; the default is
true because the expensive case is common and the loss is permanent.
5.4 cwp push [env] and cwp deploy [env]
push transfers tracked paths upward. deploy = push + remote post-steps
(wp cache flush, Bricks CSS regeneration, post-deploy hook).
Safety, in order:
- If
environments.<env>.protectedis true → refuse unless--force. - Resolve
trackedinto concrete transfers. An empty list, a path that does not exist, one outside the docroot, or one escaping the project is refused — “push everything” must not be reachable by omission. - Show what would move and require confirmation unless
--yes. - Run the
pre-pushhook; a non-zero exit aborts. - If
defaults.backup_before_push→cloudron backup create --app <app>. - Transfer with
cloudron sync push, then verify against the remote file count.
Revised 2026-07-25. The original order had the backup at step 2 and the
confirmation at step 3. In practice that meant a refused push had already run the
user’s hook and taken a real remote backup — and since a non-interactive caller
without --yes is always refused, every such call left a backup behind. The
invariant that matters (“a restore point exists before any upward write”) holds
either way, so confirmation now comes first and a refused push has no effects at
all.
Step 3 is not a diff. cloudron sync has no dry-run mode, so what is shown
is the tracked paths, their local file counts and their remote targets. cwp does
not claim to know which individual files differ.
cloudron backup create blocks — verified 2026-07-28 against a live Cloudron app
(CLI 8.2.6). The command returned after ~15s and the finished backup was already
listed by cloudron backup list at that point, so the restore point genuinely
exists before the transfer starts. The assumption the ordering rests on holds.
Cloudron sync syntax — verified 2026-07-24 against the Cloudron CLI docs.
The subcommand comes first, then --app, then source and destination:
cloudron sync push --app <app> <local_src> <remote_dst>
cloudron sync pull --app <app> <remote_src> <local_dst>
sync is documented to transfer only changed files (size + mtime, rsync-style),
with rsync’s trailing-slash semantics: a trailing slash on the source syncs its
contents into the destination; without it, the source directory itself is
nested inside the destination.
Correction, verified 2026-07-25 against CLI 8.2.6: cloudron sync pull does
not work. It lists every file, prints Syncing N file(s)..., exits 0 and
writes nothing — with and without a trailing slash, with --force, and under a
pty (B-003). cloudron push|pull moves single files correctly but cats a
directory into a 0-byte file.
cwp therefore transfers directory trees downward as a tarball: remote
tar czf /tmp/… -C <dir> . → cloudron pull the archive → extract locally →
delete both copies. Every such transfer is verified against the remote file
count afterwards, because a transport that fails by exiting 0 over an empty
destination is exactly what this cost us once already.
cloudron sync push works — verified 2026-07-25 against CLI 8.2.6 (tested
into the container’s /tmp, never /app/data). The upward direction completes
and prints Sync complete., which the broken pull direction never does: that
message is the difference between the two. Trailing-slash semantics hold as
documented — with a slash the contents are placed in the destination, without it
the directory is nested inside it. Single-file cloudron push works too.
Two rules follow for push/deploy:
-
COPYFILE_DISABLE=1is mandatory.cloudron syncshells out totar, and macOStarpacks extended attributes as AppleDouble sidecars: an unguarded push of three files landed six on the remote (._a.txtand friends). WithCOPYFILE_DISABLE=1in the environment, exactly three land. Pushing._*files into a WordPress plugin directory on a live site is not acceptable, so the environment variable is set for every upward transfer. -
Chown after transfer (B-006).
cloudron sync pushenters the container as root and leaves files owned by the local uid — 501 on macOS, which does not exist in the container — and directories owned by root, while the rest ofwp-contentiswww-data. The code still runs (modes are 644/755) but WordPress cannot update or delete it, and a pushed path it needs to write to would break. Every transfer is followed bychown -R www-data:www-data <target>, best-effort: the files are already there, so a failure warns rather than failing the push. -
Verify after transfer, never trust the exit code. Upward transfers are verified by digest, not by counting files (B-007). cwp reads
md5sumfor every file below the remote target and compares it against the local tree: a file that never landed, or landed with different contents, fails the push and is named. Extra remote files are expected when adding and fail under--delete. A remote withoutmd5sumwarns and falls back to the count.A count cannot tell a completed transfer from a no-op over an already-populated directory. Worse,
cloudron synccompares size and mtime, so a remote file corrupted to the same length with its mtime preserved is reported asAlready up to dateand never resent — verified on a live Cloudron app, where the count check passed and the digest check caught it. For the same reason, re-running a failed push does not repair it: the local file must betouched, or the remote copy deleted.
Source: https://docs.cloudron.io/packaging/cli/.
The delta claim is confirmed — verified 2026-07-28 against a live Cloudron app
(CLI 8.2.6). Re-pushing an unchanged tree prints Already up to date. and moves
nothing; after touching one file and adding another, it listed exactly those two
and printed Syncing 2 file(s).... So sync push is incremental.
But it is additive, not a mirror (B-005). A file deleted locally is never
removed from the remote — it stays and, in a plugin directory, keeps being
loaded. cwp push therefore unions state rather than deploying it, and the
remote file count can exceed the local one while cwp still reports success.
Resolved with --delete. The CLI has a native sync push --delete
(“Delete remote files not present locally”), and cwp exposes it as the opt-in:
- Default stays additive. Deleting remote files as a side effect of a routine deploy is precisely the surprise this tool exists to prevent. But the default is no longer silent: when the remote holds files the tracked path does not, cwp reports the count and names the flag.
--deletemirrors, and is treated as the destructive operation it is. It sits behind every existing guard — protected environment, confirmation, remote backup — and the interactive prompt states that files will be deleted rather than leaving it implied by a flag typed moments earlier.--dry-run --deletelists the files it would remove. The remote listing is a read-only probe, so it runs under--dry-run(§12) and the plan is truthful.- cwp never issues a remote
rmfor this. Deletion is the CLI’s own, so there is no cwp-authored destructive command running inside a live container.
Every run ends with a summary — target, mode, backup, files sent, files
deleted, and each deleted path by name. The numbers are parsed from the CLI’s own
output (Syncing N file(s), deleting M file(s)..., delete: <path>), because
what the tool reports it did beats what cwp can infer from a file count (B-007).
A database push is a separate, deliberately awkward command:
cwp db push <env> — requires typing the environment name to confirm and always
takes a backup first. The protected-environment override is the same --force
used by push/deploy, not a second bespoke flag: there is one way to say “yes,
this environment is protected, proceed”, and the database-specific extra hurdle
is the typed-name confirmation. (The earlier design used a distinct
--i-know-what-i-am-doing flag; that was dropped during the README design review
as gratuitous inconsistency.)
5.4a cwp fetch [env]
Downward code fetch: third-party themes and plugins. They are gitignored and
cwp otherwise moves code upward only, so a freshly initialised project cannot
render the site without them.
Explicit and opt-in — routine pull moves data, not code. It never writes
upward, and it must never overwrite the code you write:
- List the remote
themes/pluginsdirectories. - Skip every directory that is inside a
trackedpath. - Confirm before overwriting local directories that already exist
(unless
--yes; refused under--jsonwithout it). - Transfer each remaining directory individually with
cloudron sync pull— individually becausecloudron synchas no exclude option, so a whole-directory sync would clobber the site plugin.
Flags: --themes, --plugins (mutually exclusive scope switches; neither means
both), --yes, --dry-run, --json, --verbose.
A declarative inventory of what should be installed is F-021 (plugins snapshot|apply, 0.4.0); fetch is the transport that makes the rest usable.
5.5 cwp db <subcommand>
snapshot [name]— wrapsddev snapshot. Default name is a timestamp.restore [name]— wrapsddev snapshot restore. Interactive picker if no name. This is the documented recovery path when acwp pullreplaced local work worth keeping: restore thepre-pull-<env>-<timestamp>snapshot the pull took on its way in (§5.3, F-017).list— list snapshotsrm <name>export [file]/import <file>push <env>— see above
Snapshots are database only — uploads are not included. Document this prominently; it is the most likely source of confusion.
5.6 cwp wp <args...>
Pass-through to WP-CLI. Local by default, remote with --env <env>.
The -- separator is mandatory. Everything after it is forwarded to WP-CLI
verbatim; everything before it belongs to cwp. This is what keeps cwp’s own
flags (--env, --json, --verbose) from ever colliding with WP-CLI flags such
as --path, --format=json or --allow-root. Implemented with commander’s
enablePositionalOptions() / passThroughOptions() so no WP-CLI argument is
parsed by cwp. Invoking cwp wp without a -- is a usage error with a fix
hint, not a best-effort guess.
cwp wp -- plugin list
cwp wp --env dev -- plugin list
Remote invocations run through cloudron exec --app <app> -- wp <args>.
This command is a conduit, and that has three consequences (F-010). stdio is
inherited, not captured, so wp shell and wp db cli stay interactive and
output streams as it happens. WP-CLI’s exit code is forwarded verbatim, even
where it collides with the §12 table — a pass-through that rewrites exit codes
is useless in a script, and cwp’s own refusals all happen before WP-CLI starts.
And there is no --json: stdout is WP-CLI’s payload, so the envelope would
corrupt it, and --format=json is WP-CLI’s own answer. This is the single
documented exception to §12.
cwp wp --env <protected> is not refused. Safety rule 2 governs upward
transfers of files and databases; cwp cannot classify WP-CLI subcommands, and a
--force on cwp wp -- plugin list would be a reflex rather than a guard. The
remote target is named on stderr before the command runs instead.
--allow-root — verified 2026-07-24 against the Cloudron WordPress package
docs. Cloudron ships a wp wrapper that is “pre-setup to run as the correct
user” (www-data), so plain wp works in the web terminal without
--allow-root, and the docs never mention the flag. However, cloudron exec
itself enters the container as root, and whether the wrapper still drops
privileges in a non-interactive exec invocation is not documented. We
therefore do not hardcode either behaviour: on first remote use cwp probes
with wp --info, and only if WP-CLI refuses with its “run as root” error does it
retry (and thereafter cache) with --allow-root. The result is stored per host
in the machine config. This is the capability check the design calls for — it
degrades to a clear message rather than guessing.
The open question is now answered — verified 2026-07-28 on a live Cloudron app
(WordPress 3.18.2, CLI 8.2.6). The wrapper at /usr/bin/wp is literally
sudo -u www-data -i -- /app/pkg/wp --path=/app/code/ "$@", and it drops
privileges non-interactively too: cloudron exec -- id reports uid=0(root)
while cloudron exec -- wp eval 'echo exec("id");' reports uid=33(www-data).
So --allow-root is not needed, and the probe correctly resolves to false.
The probe stays — it costs one call, it is cached, and it keeps the tool honest
against packages that behave differently. Note also that the wrapper supplies
--path=/app/code/ itself: WordPress core lives at /app/code, not at the
/app/data docroot used for transfer targets, so cwp must keep not passing
--path to remote wp.
Remote wp-content lives at /app/data/wp-content (managed) or
/app/data/public/wp-content (developer); see §4.3.
5.7 Convenience
cwp shell [env]—ddev sshlocally,cloudron execremotelycwp logs [env] [-f]cwp open [env]— open the local or remote URLcwp status— one-screen overview: env URLs, DDEV state, last pull, snapshot countcwp projects— list registered projects on this machinecwp config get|set— read/write project or machine config
5.8 cwp bricks <subcommand>
Bricks-specific operations, all of them driving Bricks 2.4’s own abilities
through the wp eval-file transport described in §9.2, as the user §9.3
resolves. Every subcommand capability-checks first and degrades to an
instruction, never to a silent failure.
[env] omitted means the local DDEV site, as it does for cwp open — the
environment map in .cwp.yml describes the remotes, and the local site is the
one an AI client is normally pointed at.
-
export [env]/import [env]— the global transfer round-trip (F-020).importagainst a remote is an upward write and carries the full §5.4 guard set: protected refuses without--force, confirmation, remote backup. -
The design-system snapshot (F-035). Element trees on posts are revisioned and recoverable through
bricks/restore-revision; global classes, variables, colours and theme styles are not, so a delete is immediate and final. Before a destructive write against the local site,cwp bricks exportruns into.cwp/bricks-snapshots/<timestamp>/— after the confirmation, so a refused command has no side effects, and before the destructive step. A failed snapshot aborts the operation: this is F-017’s rule one level down, and a safety net that is silently not spanned is worse than none.Two callers:
import --replace, andabilitywhen the ability declaresdestructiveand its category is one an export can restore. The general -purpose door is how a global class gets deleted today, and a net the plainest route walks around is not a net. An unrecognisedbricks-*category snapshots; a non-Bricks namespace never does, because a Bricks export cannot restore it.A remote takes no snapshot — the Cloudron backup in the §5.4 guard set already covers it, the same distribution §5.3 uses for the database. Opt out with
--no-snapshotordefaults.snapshot_before_bricks_write: false. Snapshots accumulate and are never pruned, ascwp dbsnapshots do (§5.5). -
audit [env] [--scope <scope>] [--severity <level>...] [--fast]— a read-only surface overbricks/audit-design-system(F-034): orphan references aserror, unconditioned theme styles aswarning, unused classes, variables and colours asinfo. No guards of any kind, because nothing is written, and the same command against a remote as against the local site.It reports and never acts, and the exit code stays 0 even with errors among the findings: this is a diagnosis of the site, not a verdict on the command. The scan is one pass and does not page (verified 2026-07-31 against 2.4-beta2: the ability takes
scopeandskipPostScanand nothing else) — a truncated answer would still be reported rather than silently kept, which is B-017.--fastpassesskipPostScan, and cwp warns on every such run: “unused” is computed from the post scan, so skipping it makes those findings absent, not zero. Reporting a clean audit of a site nobody looked at is the failure class §7.1 and B-019 are about, one command over. -
mcp [env] [--client <name>] [--print] [--rotate]— generate the MCP client config for one of the 19 clients Bricks supports, minting the application password throughwp user application-password(F-027, §9.4). The secret never enters the repository. Forclaude-codethe emitted form is a command line, so cwp offers to run it behind a confirmation; it edits no client config file itself. -
post-types list|enable|disable <slug> [env]— read and writebricks_global_settings.postTypes, the setting that decides whether the builder opens for a custom post type (§9.1, F-027).
cwp bricks is not a pass-through like cwp wp. Each subcommand has an
opinion about guards and output, so it keeps the §12 contract in full — including
--json — unlike the three conduit commands (§5.6).
6. What cwp init scaffolds
<project>/
.cwp.yml
.cwp/
hooks/ # post-pull.sh, pre-push.sh, post-deploy.sh (samples, +x)
tmp/ # gitignored
bricks-snapshots/ # design-system restore points (F-035), gitignored
.ddev/
config.yaml # php 8.3, type wordpress, docroot from config
providers/cloudron.yaml # native DDEV provider (see §10)
nginx/
cwp-uploads-fallback.conf # uploads proxy fallback (see §7.1), committed
public/ # docroot; WP core is gitignored
wp-content/
plugins/<slug>/ # the site plugin — YOUR code lives here
<slug>.php # <slug> = plugin_slug, default <name>-site
inc/
elements/ # custom Bricks elements
hooks/
assets/
README.md
mu-plugins/
cwp-mail-guard.php # generated by scrub, gitignored
bricks/ # exported Bricks templates/components as JSON
.gitignore
CLAUDE.md
README.md
FEATURES.md
BUGS.md
CHANGELOG.md
VERSION.txt
Critical convention to encode in CLAUDE.md and the site-plugin README:
WordPress has no grandchild themes, so a child theme’s own updates overwrite
anything written into it. Therefore no project code is ever written into the
theme directory — it belongs in the site plugin. Custom Bricks elements are
registered from the plugin via \Bricks\Elements::register_element() on the
init hook.
That rule holds for every child theme and is stated without naming one (F-029).
cwp init detects the installed child of Bricks — by Template: bricks in its
style.css, which is WordPress’s own definition and so matches SNN-BRX,
Bricksable or a hand-rolled theme equally — records it as bricks.child_theme,
and names it in the generated documents. SNN-BRX additionally auto-updates from
GitHub, and that clause appears only for SNN-BRX, because it is the reason
editing that theme is unusually costly rather than merely unwise.
.gitignore excludes: WordPress core, third-party plugins and themes,
public/wp-content/uploads, .cwp/tmp, .cwp/bricks-snapshots, .ddev/.*,
generated mu-plugins. Not .cwp/ wholesale — hooks and project scripts live
there and are committed, which is why the snapshot directory also ignores itself
(§5.8, F-035) rather than relying on a rule an older project never got.
6.1 Re-running init: what “repairs” means
Every file above is written only when absent, so a re-run restores what is
missing and never touches what exists. .cwp.yml is the one exception: it is the
file cwp itself has opinions about, so it is brought up to date in place.
Repairs currently applied:
| key | repair |
|---|---|
pull.truncate_tables |
remove snn_301_logs, snn_404_logs, snn_search_logs — tables that never existed (B-001) |
pull.truncate_post_types |
add the SNN-BRX log post types when the key is absent and that child theme is installed (F-029); append snn_search_logs and snn-agent-history when the list is verbatim what cwp wrote (B-011) |
pull.truncate_taxonomies |
add the SNN-BRX log taxonomies under the same condition (B-011, F-029) |
The phantom-table cleanup applies to every project, because it corrects a mistake cwp made. The two additions apply only where SNN-BRX is present, because they describe one stack’s post types — and an unknown answer adds nothing, which is the repair path’s standing rule.
The truncate_post_types append is the only repair that adds to a list the
file already has, and it is narrowly guarded because of it. cwp init writes
that key out explicitly, so every project scaffolded before B-011 has the old
four-name list pinned and a corrected schema default can never reach it — the
pinned-default trap B-001 identified, hitting B-001’s own fix. The append
therefore fires only when the existing list contains all four of the
previously shipped defaults, which is verbatim what cwp wrote; a list anyone has
curated fails that test and is left exactly as it is.
Rules the repair path holds to:
- Report every change. Each repair is its own step in the output. A config rewritten silently would be worse than one left stale.
- Never change a setting the user chose. It only removes entries that cannot match anything and adds keys that were absent. An unrecognised table name is kept — it may be the user’s own.
- Never pin defaults. The repaired input is written back, not the parsed config. Serialising the parsed result would materialise every schema default into the file, and a pinned default no longer follows a corrected one — which is exactly how the phantom table names survived. The repair must not reintroduce the mechanism it exists to clean up.
- Decline rather than half-fix. A config that cannot be read, cannot be
parsed, or would not validate after the repair is left exactly as it is and
reported as nothing to repair.
initis not the place to fail on a broken config;doctorand the normal load path say so properly, with a fix hint.
Note that the repair is about the file, not behaviour: the schema’s per-field
defaults already fill truncate_post_types in at load time, so a config without
the key scrubs log posts correctly. What was wrong was a file listing tables that
can do nothing while giving no hint that the post-type setting exists.
6.2 Where custom post types are defined
Decided 2026-07-28. Custom post types are registered in code, in the site
plugin — inc/post-types/, on the init hook. Not in .cwp.yml, and not in
the database.
This is not a style preference; it follows from the data-flow axis in §5. Database and uploads move downward only, code moves upward. So every “where does this live” question reduces to one test: must it be authored locally and reach production?
- Defined in the database — CPT UI, the SNN-BRX post-type manager, ACF post
types — the definition sits in
wp_optionsorwp_posts. The only route upward iscwp db push(§13, F-023): typed environment-name confirmation, mandatory backup,--forceon aprotectedenvironment, and it carries the whole local database along with it. That path is deliberately awkward because it is for data. A structural change must not need it. - Defined in the site plugin, it travels with
cwp push/cwp deploy, it is in git, it is reviewable, and it reproduces on a fresh environment.
The general rule underneath: a post type is structure, not content. Anything defined in the database is content by this tool’s definition and therefore downward-only. Defining structure there puts it in the content channel and erodes the separation the whole tool rests on.
.cwp.yml is likewise the wrong home. It configures cwp, not the site. A
post_types: block there would mean cwp generates PHP or re-registers types at
deploy time — which makes it a half-framework rather than middleware (see
CLAUDE.md). The only post-type key in .cwp.yml is pull.truncate_post_types,
and that describes log data, not structure.
Consequences for the tool:
- Generating the registration code is out of scope. WP-CLI already does it
(
wp scaffold post-type <slug> --plugin=<slug>), reachable throughcwp wp(§5.6). - Reading post types from the database is for diagnosis only, never as a
source of truth. That is the F-025 drift check in
cwp doctor, and the ordering check in §9.
7. Uploads strategies
| Mode | Behaviour |
|---|---|
sync |
Full copy of the remote uploads directory (tarball transport, see §5.4). Correct, slow, large. |
skip |
Nothing. Broken images locally. |
proxy |
Default. No transfer. Any file below /wp-content/uploads/ that is absent locally is served from the remote. |
Rationale: on an image-heavy Bricks site this turns a twenty-minute first-time
setup into a two-minute one, and keeps every subsequent cwp pull cheap.
7.1 Where the fallback runs — the nginx snippet (F-032)
proxy is a web-server fallback, not a WordPress filter.
.ddev/nginx/cwp-uploads-fallback.conf matches location ^~ /wp-content/uploads/ with try_files $uri @cwp-uploads-remote, and that named
location proxies to the environment’s URL. It is written by cwp init and
rewritten by cwp pull, is committed rather than gitignored — a clone must serve
uploads the same way without cwp installed (§2, principle 3) — and takes effect
only after ddev restart.
^~ is load-bearing: a regex location wins over a plain prefix match, and DDEV’s
WordPress configuration matches static assets by extension, so without it the
fallback would apply to everything except the image and font requests it exists
for. The proxied request carries the remote Host header, sends no cookies
upward and drops any Set-Cookie coming back: it fetches files, it does not
share a session.
Why not a mu-plugin. That was the original design and it was wrong (B-019). A
PHP filter can only rewrite URLs WordPress itself emits — the attachment API. A
.woff2 requested from an @font-face rule, Bricks’ generated CSS below
uploads/bricks/, a background image stored as a URL string in element settings
and a literal <img src> in post content all reach the browser without passing
through it, so they 404 and the symptom reads as a styling defect. nginx has done
fallback proxying for twenty years; the mu-plugin was cwp rebuilding it inside
WordPress, where it cannot reach a request PHP never sees.
The mu-plugin remains, as the narrower fallback, for webserver_type: apache-fpm and for a project whose .ddev/ cwp did not scaffold. It is chosen
only when .ddev/config.yaml does not state nginx-fpm — DDEV’s own default is
nginx, but a config that does not say so is one cwp cannot vouch for. Whenever it
is chosen, cwp pull says so and names the coverage gap: a silent downgrade to
partial coverage is B-019 with a new mechanism.
cwp doctor checks the mechanism, not an inventory. One request for an
uploads path the database knows and the filesystem does not, expecting 200.
“There are attachments whose files are missing” is the right question under sync
and skip and pure noise under proxy, where almost every file is missing by
design — which is why the gap stayed invisible. Under the mu-plugin no request is
made at all: it rewrites URLs in rendered output, so a direct request for the file
404s even when it is working as designed.
A remote behind HTTP authentication breaks the fallback. So did the mu-plugin, so this is no regression — but it is invisible either way, which is what the check is for.
7.2 Privacy caveat (relates to §8)
proxy keeps the local copy coupled to production, and since F-032 it is
coupled more widely than before: the mu-plugin fetched production files for
images WordPress rendered, while the snippet serves everything below
uploads/ on demand — including files no WordPress code path would ever have
requested, such as a PDF whose URL is guessed or an orphaned export somebody left
in the media library.
This is normally fine — uploaded media is public, and it is the same server
serving the same file to anyone who asks. But “scrubbed” does not mean
“disconnected from production” while proxy is active, and the local site is now
a complete read-through of the remote uploads directory. Use sync (or skip)
when full separation is required; both remove the fallback rather than leaving it
in place.
8. Scrub pipeline
Runs after every pull unless disabled. This is a GDPR requirement, not a
convenience: production databases contain personal data, and pulling one onto a
laptop is a processing operation.
-
Neutralise outbound mail. Write
mu-plugins/cwp-mail-guard.phpwhich short-circuitspre_wp_mailand logs instead of sending. This must run even ifscrub: false, because SNN-BRX ships its own SMTP feature that bypasses DDEV’s Mailpit. -
Anonymise users, except the site’s own staff: users holding one of
pull.keep_roles(defaultadministrator,editor), super admins, and the address inpull.keep_admin_email. Everyone else — subscribers, customers, visitors — getsuser_emailreplaced withuser-<ID>@example.invalid, blank display names and a reset password. Preserve uniqueness constraints.Keeping staff accounts is a deliberate trade-off: they are what you log in with locally, and they are not the bulk personal data the scrub exists to remove. Narrow
pull.keep_rolesto[]when even that is too much. Sessions are destroyed for every user, staff included. -
Truncate log and submission tables —
pull.truncate_tables, defaulting to Bricks form submissions. Detect table existence first; never fail on absence. -
Delete log posts —
pull.truncate_post_types, defaulting to the SNN-BRX 404, redirect, activity and mail logs.These are not tables — corrected 2026-07-28 (B-001). Every SNN-BRX logging feature registers a custom post type and writes entries to
wp_posts; the earlier defaults (snn_301_logs,snn_404_logs,snn_search_logs) were table names that never matched anything, so the scrub left IP addresses, user agents and mail bodies in place while reporting success. The real types aresnn_404_logs,snn_redirect_logs,snn_activity_logandsnn_mail_logs.snn_301_redirectsis the redirect rules type — site configuration, not a log — and must never be deleted, despite sitting one word away fromsnn_redirect_logsin the same feature file.Implemented as a generated script run through
wp eval-file, like the user anonymisation: a busy site accumulates tens of thousands of 404 rows, which rules out passing IDs as arguments. Deletion useswp_delete_post($id, true)so postmeta and term relationships go too, and so nothing merely lands in the trash. Rows are found by queryingwp_postsrather thanpost_type_exists(), because the feature may be switched off while its rows remain.Two more types added 2026-07-29 (B-011):
snn_search_logsandsnn-agent-history. The first is the tail of B-001 — it was correctly removed as a phantom table name and never re-added in its real form as a post type, so the one name that changed category fell through that fix. The second stores AI chat transcripts and postdates B-001 entirely.Failure is fatal, like step 2 and unlike the cosmetic steps that follow.
-
Delete the terms of
pull.truncate_taxonomies— defaulting tosnn_ip_addressandsnn_user_agent.Deleting the posts is not sufficient, and assuming otherwise cost a whole release (B-011). SNN-BRX’s search logger stores the visitor IP and user agent as taxonomy terms, not postmeta.
wp_delete_post($id, true)removes term relationships and leaves the terms inwp_terms, so the scrub deleted every search log, reported success, and left a browsable list of every visitor’s IP address behind. Verified by experiment on a live install rather than reasoned about: post gone, postmeta gone, term still there.The whole taxonomy is emptied, not only the terms that just lost a relationship — these taxonomies exist solely to index the logs, so a term surviving from an earlier purge is the same IP address either way. Terms are found by querying
term_taxonomyand deleted withwp_delete_term(), which takes the term relationships and termmeta with them; the taxonomy is registered as a private placeholder first when the feature that owns it is switched off, sincewp_delete_term()needs it registered and the rows are there regardless.A declared list, not one inferred from the scrubbed post types: a switched-off feature registers no taxonomies, so there would be nothing to discover them from — the same reason step 4 queries the database instead of trusting
post_type_exists().snn_search_countis deliberately excluded. It hangs off the same posts, but it is a count rather than personal data — the same linesnn_301_redirectssits on the other side of.Failure is fatal, for the same reason as step 4.
-
Delete transients and expired sessions.
-
Set
blog_public = 0. -
Report what was scrubbed in the command output.
The scrub step is implemented as a discrete, separately invocable command
(cwp scrub) so it can be re-run and tested in isolation.
What the scrub does not disconnect (§7.2). Under the default pull.uploads: proxy the local site reads through to the remote for every file below
uploads/ — not only the images WordPress renders, since F-032 moved the
fallback to the web server. A scrubbed database and a proxied uploads directory
together are a local copy with production personal data removed from the
database and production files still one request away. That is a deliberate
trade and usually the right one, because uploaded media is served publicly by
the same host anyway; where it is not, pull.uploads: sync or skip is the
setting that severs the connection, and both remove the fallback rather than
leaving it in place.
9. Bricks post-import steps
Applied after a pull when bricks.enabled:
- Regenerate code signatures. Bricks signs code elements per site; imported
content is blocked until re-signed. Verified 2026-07-24: the Bricks CLI
docs (Bricks 1.8.1+) document no WP-CLI command for code signatures, and
an open forum feature request confirms it does not yet exist.
cwptherefore capability-checks first — parseddev wp bricks --help(equivalentlyddev wp help bricks) for a signature subcommand and use it if present — and otherwise degrades to a clear instruction: regenerate under Bricks → Settings → Custom Code → Code signatures. Never hardcode an unverified command name. - Regenerate CSS files if the site uses external CSS file generation.
Verified 2026-07-24: the command is
wp bricks regenerate_assets(Bricks 1.8.1+, requires the CSS loading method set to external files). Guard it behind the same capability check and skip with a warning when the subcommand is absent or CSS is inlined. - Report license state — Bricks is licensed per site; the local install may need its own activation. Report, do not attempt to fix.
Each step is best-effort: a failure produces a warning, not an aborted pull. Sources: https://academy.bricksbuilder.io/article/bricks-cli/ and the Bricks community forum feature request for code signing via WP-CLI.
Bricks 2.4 does not change these three steps — there is still no code signature command among its 145 abilities (verified 2026-07-29 by enumeration, see §9.2) — but it adds two constants that address step 1 and step 3 better than a post-import step can:
BRICKS_DANGEROUSLY_AUTO_SIGN_CODE_ON_BUILDER_SAVEand..._ON_BUILDER_RERENDERremove the re-signing chore on a local environment. The name is a warning and cwp treats it as one: these belong in a DDEVwp-config.phpand cwp must never write them to a production one.define( 'BRICKS_LICENSE_KEY', … )makes licensing a deployment concern rather than a per-install click, which is what step 3 could only ever report on.
9.2 Bricks 2.4 abilities — the integration surface
Verified 2026-07-29 against a running Bricks 2.4-beta2 on WordPress 7.0.2 with WP-CLI 2.12.0, by enumerating the live registry and reading the theme source shipped beside it — not from the release notes.
Bricks 2.4 registers 145 abilities in the bricks/ namespace on WordPress
core’s wp_abilities_api_init hook (includes/abilities/manager.php:142). The
Abilities API is core’s, present since WordPress 6.9. Two consequences decide how
cwp integrates:
The registry holds more than Bricks’ 145 — re-counted 2026-07-31 on the same
site: snn/* from the child theme (24), core/* (3) and mcp-adapter/* (3),
for 175 in total. Both numbers matter and neither replaces the other: 145 is the
Bricks integration surface this section specifies, 175 is what a call can reach.
cwp bricks ability (F-030) therefore does not filter by namespace.
Every ability declares its own disposition. meta.annotations carries
readonly, destructive and idempotent — bricks/get-page-elements is
readonly: true, bricks/set-page-elements is destructive: true. That is what
lets a general-purpose call command apply the §5.4 guard set per call instead of
guarding everything or nothing.
The annotations are a claim, not a guarantee, and 46 of the 175 make none at
all (every snn/* among them). An ability that declares nothing is treated as
a write: guarding a read costs a confirmation, and letting an undeclared write
through to production costs what this tool exists to prevent. Null is therefore
kept distinct from false everywhere it is read — Bricks writes readonly: null
on a setter rather than false, so collapsing the two would make silence look
like a declared read.
- The MCP Adapter plugin is not required. It exposes abilities over HTTP for
AI clients; the abilities exist and are callable without it. cwp therefore
reaches the entire Bricks surface through
ddev wpandcloudron exec -- wp, with nothing installed beyond Bricks itself. wp abilitymust not be depended on. The CLI command lives in the separatewp-cli/ability-commandpackage and is absent from WP-CLI 2.12.0 on both the DDEV container and the Cloudron app. cwp invokes abilities throughwp eval-filewith a generated shim — the transport the scrub already uses — which needs no per-environment install and behaves identically on both sides.- The shim runs as a user, not as nobody. See §9.3; this is the one part of the transport that does not carry over from the scrub.
The enable chain, in evaluation order (manager.php:349):
| gate | location | note |
|---|---|---|
BRICKS_DISABLE_MCP |
wp-config.php |
hard off; no force-on constant exists by design |
abilitiesApi |
bricks_global_settings |
the admin toggle, Bricks → Settings → AI |
enabled + deny-list |
bricks_mcp_settings |
per-ability, absent option reads as enabled |
bricks/start-here, bricks/get-mcp-version and bricks/list-ability-status are
always on regardless of the third gate (Manager::ALWAYS_ON_ABILITIES).
They are not always on regardless of the first two, and an earlier draft of
this paragraph said they were. Manager::register() returns before it hooks
wp_abilities_api_init when is_enabled() is false (manager.php:136), and
returns earlier still when the abilitiesApi key is merely absent from
bricks_global_settings (:121) — the state of any site whose AI tab has never
been saved. So with the constant set, or the toggle off, or the tab untouched,
nothing is registered at all, the always-on three included.
ALWAYS_ON_ABILITIES exempts an ability from the per-ability deny-list, which is
evaluated after registration. It says nothing about whether registration
happened. Reading it as “a capability check can always get an answer” produced
B-014: a Bricks 2.4 site was told to update Bricks, because “the ability is
missing” collapses four causes into one wrong one.
The diagnostic path therefore does not go through an ability at all — see §9.5 — and §9.3 is a fourth gate beyond these three.
Ability names and error codes are append-only by Bricks’ own stated policy;
renames go through deprecation aliases for at least one minor version. That is a
strong enough contract to build on, and cwp still capability-checks rather than
assuming, exactly as it does for wp bricks regenerate_assets.
The transfer package
Four abilities carry the global import/export: bricks/list-transfer-items,
bricks/export-transfer-package, bricks/inspect-transfer-package,
bricks/import-transfer-package. Twelve transfer types:
| group | types |
|---|---|
style |
classes, variables, theme-styles, color-palettes, custom-fonts, icon-manager |
structure |
templates, components, global-queries, breakpoints (singleton) |
settings |
settings, custom-capabilities |
The exported ZIP is already a tree of JSON files (styles/classes/classes.json,
structure/templates/template-*.json, manifest.json, …), so cwp extracts it
into bricks.export_dir and needs no format of its own. A re-zipped tree is
accepted by inspect-transfer-package even though its bytes differ, because
expectedZipHash is a consistency check between inspect and import rather than a
signature — verified by round-tripping this project’s site. The extracted tree
is the source of truth; the ZIP is transient.
Constraints that belong in the argument builders, not in a comment: the package is
capped at 32 MB (MCP_MAX_ZIP_BYTES), the manifest schema is
bricks/unified-global-transfer version 1, replace requires allowOverwrite,
and the api-keys and custom-code settings tabs require allowSensitive.
Note that bricks/list-cms-sources concerns custom field sources (ACF,
JetEngine, Meta Box, CMB2, Pods, Toolset, WooCommerce), not post-type
registration. The name reads the other way, so it is recorded here: §6.2 stands
unchanged.
cwp bricks export / cwp bricks import round-trip templates and
components through bricks/ as JSON. Design note for later: always mutate
exported JSON, never author it from scratch — Bricks JSON carries generated
element IDs and references to global class IDs.
9.3 The ability transport runs as a WordPress user
Verified 2026-07-29, and it corrects the transport §9.2 first specified.
Every bricks/* ability carries a permission callback that tests the current
user. WP-CLI has no logged-in user, so wp eval-file executes as user 0 and
every ability refuses:
WP_ERROR: ability_invalid_permissions
Notice: Current user lacks the "read" builder permission
| data: {"code":"bricks_forbidden_builder_permission","permission":"read"}
That includes bricks/list-ability-status. ALWAYS_ON_ABILITIES exempts an
ability from the deny-list, not from the permission callback — the two are
independent gates, and reading the first as covering the second is what made the
capability check look free. It is still cheap; it is not unauthenticated.
The fix is WP-CLI’s own global flag: wp --user=<id|login> eval-file …, which
sets the current user before WordPress dispatches. With it, all three always-on
probes return clean JSON on both conduits.
The shim is piped, not written. wp eval-file - reads the script from STDIN
(verified 2026-07-29), so unlike the scrub — which writes to .cwp/tmp and
deletes afterwards — the ability transport puts no file on either filesystem.
That matters more remotely than locally: a generated PHP file inside a live
Cloudron container is a thing that can be left behind by an interrupted run.
Its output is framed by a sentinel line rather than found by scanning for a
leading {. WordPress emits _doing_it_wrong notices as HTML on stdout, and the
scrub’s reverse-scan heuristic (parsePostTypeScrubReport) would be reading a
brace out of markup sooner or later.
Which user is resolved, in order:
bricks.wp_userin.cwp.yml, when set — an id, login or email, passed through untouched, because those are exactly what--useraccepts.- Otherwise the first administrator, read at runtime with
wp user list --role=administrator --field=ID --number=1.
Auto-detection is the default because a one-admin site is the common case, and because requiring configuration before a read-only check will run is a poor trade. The override exists because “the first administrator” is an arbitrary choice on a site with several, and because a dedicated service account is the right answer on a production environment.
A site with no administrator is a failure with its own fix hint, not a silent fallback to user 0: running as nobody produces the permission error above, which names a builder permission and says nothing about the actual cause.
The user is never cached. capabilities.wp_allow_root is cached because it
is a property of the Cloudron image, which does not change between runs. Which
users exist on a site is database state, and cwp pull replaces it wholesale.
This applies to every ability call — F-020, F-025, F-027 and F-028 alike — so it lives in the transport, not in any one command.
9.4 Application passwords cannot be re-read
cwp bricks mcp (F-027) mints its own credential through
wp user application-password create <user> <name> --porcelain, which is present
in WP-CLI 2.12.0 on both sides. The plaintext exists exactly once: core stores
wp_hash_password() output (class-wp-application-passwords.php:105), so
application-password list returns the hash and no command can hand the secret
back.
That makes the idempotence F-027 originally assumed impossible. exists can
report that a password is present; it cannot let a second run emit a working
config. So:
- when no password named for this project and environment exists, cwp creates one and prints the config;
- when one exists, cwp refuses, with the fix: pass
--rotateto delete and recreate it.
Refusing rather than rotating is the safe default because rotation is invisibly
destructive — every other client already configured against that password stops
working, and nothing about a repeated cwp bricks mcp says “invalidate my other
machines”.
The secret never enters the repository: it is printed for the user to place, or
handed to the client’s own CLI. Both .cwp.yml and bricks/ are committed, so
neither may ever hold it.
9.5 The readiness probe calls no ability
Added 2026-07-29 after B-014. Diagnosing the chain in §9.2 by calling an ability is circular: the abilities are exactly what is missing in four of the cases the diagnosis has to tell apart.
| cause | what the ability call reports | the actual fix |
|---|---|---|
| WordPress < 6.9 | no wp_get_ability |
update WordPress |
| Bricks < 2.4, or inactive | ability not registered | update or activate the theme |
BRICKS_DISABLE_MCP set |
ability not registered | nothing — this is intended |
| AI tab never saved | ability not registered | Bricks → Settings → AI |
| toggle switched off | ability not registered | Bricks → Settings → AI |
| ability denied individually | ability not registered | the deny-list |
Three of those rows produce the identical symptom and have different fixes, and the constant’s row must not be reported as a fault at all.
So cwp doctor reads the facts directly, through a shim
(templates/bricks-state.php) that touches nothing but the theme, core and the
options table: the Bricks parent-theme version and whether it is active, the
WordPress version, whether wp_get_ability exists, abilitiesApi as three
states (true / false / never saved), bricks_mcp_settings.enabled,
BRICKS_DISABLE_MCP, and postTypes.
Two consequences:
- It needs no user, because it calls no ability — and the flag must be
absent, not
--user=0, which WP-CLI rejects with “Invalid user ID, email or login: ‘0’”. Verified against WP-CLI 2.12.0. - Reading
bricks_global_settingswithget_option()is allowed here and nowhere else. Settings otherwise go throughbricks/get-global-settings(§9.1); the diagnostic path cannot go through the thing whose absence it is diagnosing.
An ability is called afterwards, once the facts say one should answer. It confirms; it is no longer the evidence.
9.1 Bricks and custom post types
Bricks does not define post types; it references them, in three places:
-
Template conditions — which post type a template applies to.
-
Query loops — which post type a loop queries.
-
The builder-enabled post types — a Bricks global setting, stored in the database and therefore pulled downward with everything else. That is correct here: it is a per-site setting, not structure.
Verified 2026-07-29: the key is
postTypesinside thebricks_global_settingsoption, read off this project’s site where it holds["page"]. Bricks 2.4 exposes it throughbricks/get-global-settingsandbricks/set-global-settings, which is how cwp reads and writes it (F-027) — a read-modify-write of that one key, never a whole-option overwrite, because the same option carriescustomCss, maintenance mode and the builder configuration.This setting is the reason registering a custom post type is not enough to work on one. The builder will not open for a post type absent from that list, and because the value is database state it travels downward only: enabling it locally never reaches production. F-025 reports the gap per environment.
The first two create an ordering dependency for cwp bricks import (F-020): a
template whose condition names a post type the site plugin has not registered
imports cleanly and then matches nothing. Silent failure is the one outcome this
tool does not accept, so import compares the post-type slugs referenced by the
JSON against wp post-type list on the target and warns per unknown slug, with
the fix — deploy the site plugin first, then re-import. A warning, not an abort:
a template may legitimately be inactive.
Registration itself stays in the site plugin, per §6.2.
10. DDEV provider generation
DDEV supports custom providers at .ddev/providers/<name>.yaml. cwp init
generates .ddev/providers/cloudron.yaml so that ddev pull cloudron works
natively. The provider is a convenience and a portability guarantee, not the
primary path — cwp pull remains the richer command because it also runs scrub
and Bricks steps.
Verified 2026-07-24 against the DDEV provider docs and the shipped provider examples. The schema is a set of top-level stanzas:
| Stanza | Role |
|---|---|
environment_variables |
Plain key: value map, exported into the web container for use by the command stanzas. Not a command block. |
auth_command |
Authenticate against the host. |
db_pull_command |
Must produce /var/www/html/.ddev/.downloads/db.sql.gz. |
db_import_command |
Optional. Defaults to importing that gzip into the db database. |
files_pull_command |
Must place user files under /var/www/html/.ddev/.downloads/files. |
files_import_command |
Optional. Defaults to copying into the upload dirs. |
db_push_command |
Ships /var/www/html/.ddev/.downloads/db.sql.gz upstream. |
files_push_command |
Copies $DDEV_FILES_DIRS upstream. |
Each command stanza is a mapping with a command: key holding a literal
block scalar (|) and an optional service: key (defaults to web). The
script runs inside the named container; the standard preamble is
set -eu -o pipefail. $DDEV_FILES_DIRS (comma-separated upload dirs) and the
environment_variables entries are available inside the scripts.
service: host is mandatory here, and the default is what broke it —
corrected 2026-07-29 (B-015). The Cloudron CLI is installed on the developer’s
machine, not in the web container:
$ ddev exec "which cloudron || echo NOT PRESENT"
NOT PRESENT
So every stanza in cwp’s provider pins service: host, and the paths are
relative to the project root rather than /var/www/html. The version of this
section written on 2026-07-24 was verified against the schema and never
executed, which is why every stanza name was correct and the provider could not
run at all.
environment_variables:
app: example.com # Cloudron --app value
db_pull_command:
service: host
command: |
set -eu -o pipefail
mkdir -p .ddev/.downloads
cloudron exec --app "${app}" -- wp db export --allow-root - \
| gzip > .ddev/.downloads/db.sql.gz
Two further rules the generated provider follows:
- The files pull does not use
cloudron sync pull, which is a silent no-op (B-003). It uses the same remote-tar-then-cloudron pullworkaround aspullDirectoryTree. - Both push stanzas refuse.
ddev pushhas no confirmation, no backup, no protected-environment check and no typed environment name, and a provider cannot add them. They exit 1 namingcwp db pushandcwp deploy. Deleting the stanzas instead would produce DDEV’s own error, which explains nothing — and §2’s invariant is that the upward path is hard to take by accident, not that it is undefined.
ddev pull is not cwp pull. It brings production data down unscrubbed,
installs no mail guard, and takes no pre-pull snapshot. The provider says so in
its header; it is a portability guarantee, not a substitute.
Source: https://docs.ddev.com/en/stable/users/providers/ and the provider
examples DDEV ships into .ddev/providers/ (git.yaml.example,
localfile.yaml.example, rsync.yaml.example), read on 2026-07-29 — the
service: host usage came from those rather than from the prose.
11. Hooks
Executable shell scripts in .cwp/hooks/, run with the project root as CWD and
a documented set of environment variables (CWP_ENV, CWP_PROJECT,
CWP_LOCAL_URL, CWP_REMOTE_URL, CWP_REMOTE_APP, CWP_WP_CONTENT).
post-pull.shpre-push.sh(non-zero exit aborts the push)post-deploy.sh
Generated as commented no-op samples.
12. Output contract
- Human output → stderr, with a spinner per step.
--jsonoutput → stdout, a single JSON object:{ "ok": bool, "command": str, "env": str, "steps": [...], "warnings": [...], "error": {...}|null }--verboseechoes every subprocess command before running it.--dry-runon every mutating command prints the plan and exits 0.cwp wpis the one documented exception to this contract — see §5.6.--jsonimplies non-interactive. A command that would otherwise prompt — e.g. thecwp pull“local database will be replaced” guard — must be given--yesup front. Without it, the guard refuses with exit code 6 (aborted) rather than blocking on a prompt that no machine caller can answer. This keepscwpsafe to drive from Claude Code and CI.
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 user |
13. Scope
Shipped through 0.3.0: init, doctor, pull, fetch, push, deploy,
db snapshot|restore|list|rm|export|import, wp, shell, logs, open,
status, scrub, hooks, JSON output, README + docs.
Shipped in 0.4.0: bricks export|import, bricks mcp, bricks post-types,
bricks abilities|ability, plugins snapshot|apply, projects,
config get|set, content list|status|pull|push, DDEV provider hardening, plus
the custom-post-type drift and Bricks 2.4 readiness checks in doctor.
Later: db push — deferred, since the content transfer answers the case it
was really being asked for; multi-env matrix operations, remote template sync
between environments, shell completion.