❯ 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.
The world cwp runs against
Status: Current as of 1.0.0 Owner: BERNSTEINKRAFT n.e.V.
This document records what the external tools do: the Cloudron CLI, WP-CLI, DDEV, WordPress and Bricks. Every claim below was verified against live documentation or a live install, and each one carries the date it was taken. None of it is derived from how cwp is built.
Two places hold the other half. docs/ARCHITECTURE.md is authoritative for cwp’s
own shape: the five layers, the session, the command runtime, the two seams, the
effect model and the guard ladder. docs/specs/ holds cwp’s own contracts, one
per file. The safety invariants live in src/domain/policy/invariants.ts rather
than in any document.
Where the internal half went
This file held cwp’s own contracts until 2026-08-30. They left with F-125, and the section numbers below were compacted after them.
| old § | now |
|---|---|
| 1 Purpose | README.md and docs/ARCHITECTURE.md §1 |
| 2 Design principles | S-0016 |
| 3 Stack | §1 below |
| 4 Configuration | docs/reference/cwp-yml.md, docs/reference/machine-config.md and S-0012; §4.3 is §3 below |
| 5.1, 5.2, 5.3, 5.4a, 5.5, 5.7, 5.8 | docs/commands/, and S-0010 for the surface rule |
5.4 cloudron sync | §2 below; the push guard order is S-0004 and docs/ARCHITECTURE.md §13 and §19.1 |
5.6 cwp wp | §3 below; the pass-through rules are docs/commands/wp.md and S-0021 |
5.6a wp language core | §4 below |
6 What cwp init scaffolds | S-0017 |
| 7 Uploads strategies | S-0018 |
| 8 Scrub pipeline | S-0019 |
| 9 Bricks post-import steps | §5 below |
| 10 DDEV provider generation | §6 below |
| 11 Hooks | S-0020 |
| 12 Output contract | S-0021 |
| 13 Scope | README.md |
1. Stack
Decided, and 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 @xumana/cwp, binary name cwp. Installed globally
with npm i -g @xumana/cwp, not per project.
Explicit non-goals: no Composer or Bedrock integration, no CI runner, no multi-user or team features.
A fourth non-goal stood here, “no support for hosts other than Cloudron”, and
F-041 superseded it on 2026-08-02. Cloudron is the first implementation of a
Provider interface rather than an assumption, alongside ssh for a plain host
over ssh and rsync, and local for a same-machine target. What remains out of
scope is anything needing an API client: a hosting REST or control-panel
integration is a new dependency class and a different reliability model.
2. What cloudron sync does
Source: https://docs.cloudron.io/packaging/cli/. Every measurement below was taken against Cloudron CLI 8.2.6 on a live app, on the date it names.
The 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, comparing size and mtime the
way rsync does. It follows rsync’s trailing-slash semantics: a trailing slash on
the source syncs its contents into the destination, and without one the
source directory itself is nested inside the destination. Both halves of that
sentence were an inference from Cloudron’s own wording until §7, where rsync
itself was measured: the trailing-slash rule and the size-and-mtime quick check
are the same on both.
cloudron sync pull does not work, verified 2026-07-25 against CLI 8.2.6. It
lists every file, prints Syncing N file(s)..., exits 0 and writes nothing
(B-003). That holds with and without a trailing slash, with --force, and under
a pty. cloudron push and cloudron pull move single files correctly, and
cat a directory into a 0-byte file.
cwp therefore transfers directory trees downward as a tarball: remote
tar czf /tmp/… -C <dir> ., then cloudron pull of the archive, then a local
extract, then a delete of both copies. cwp verifies every such transfer against
the remote file count afterwards. A transport that fails by exiting 0 over an
empty destination has cost this project once already.
cloudron sync push works, verified 2026-07-25 against CLI 8.2.6, tested
into the container’s /tmp and never into /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.
The delta claim is confirmed, verified 2026-07-28. Re-pushing an unchanged
tree prints Already up to date. and moves nothing. After touching one file and
adding another, the CLI listed exactly those two and printed
Syncing 2 file(s).... sync push is incremental.
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.
cloudron backup create blocks, verified 2026-07-28. The command returned
after about 15s, and cloudron backup list already listed the finished backup at
that point. A restore point genuinely exists before the transfer starts.
Three rules follow for cwp push and cwp 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 cwp sets the variable for every upward transfer. -
Chown after transfer (B-006).
cloudron sync pushenters the container as root and leaves files owned by the local uid, which is 501 on macOS and does not exist in the container. It leaves directories owned by root, while the rest ofwp-contentiswww-data. The code still runs, since the modes are 644 and 755, and WordPress cannot update or delete it. A pushed path WordPress needs to write to would break. cwp follows every transfer withchown -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. cwp verifies an upward transfer by digest, not by counting files (B-007). It reads
md5sumfor every file below the remote target and compares it against the local tree. A file that never landed, or that 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.
cloudron synccompares size and mtime, so it reports a remote file corrupted to the same length with its mtime preserved asAlready up to dateand never resends it. Verified on a live Cloudron app: 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.
--delete is the CLI’s own. It has a native sync push --delete, documented
as “Delete remote files not present locally”, and cwp exposes it as the opt-in:
- The default stays additive. Deleting remote files as a side effect of a routine deploy is precisely the surprise this tool exists to prevent. The default is no longer silent: where the remote holds files the tracked path does not, cwp reports the count and names the flag.
--deletemirrors, and cwp treats it as the destructive operation it is. It sits behind every existing guard: protected environment, confirmation, remote backup. 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(S-0021) and the plan is truthful.- cwp never issues a remote
rmfor this. Deletion is the CLI’s own, so no cwp-authored destructive command runs inside a live container.
Every run ends with a summary: target, mode, backup, files sent, files
deleted, and each deleted path by name. cwp parses the numbers from the CLI’s own
output, Syncing N file(s), deleting M file(s)... and delete: <path>. What the
tool reports it did beats what cwp can infer from a file count (B-007).
3. The Cloudron WordPress package
--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”, which is www-data. Plain wp therefore works in the web terminal
without --allow-root, and the docs never mention the flag. cloudron exec
itself enters the container as root, and the docs do not say whether the
wrapper still drops privileges in a non-interactive exec invocation. cwp
hardcodes neither behaviour. On first remote use it probes with wp --info, and
it retries with --allow-root only where WP-CLI refuses with its “run as root”
error. cwp caches the result per host in the machine config, and degrades to a
clear message rather than guessing.
The question is 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 resolves to false. The probe
stays. It costs one call, it is cached, and it keeps cwp honest against a package
that behaves differently.
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 a remote wp.
Where wp-content lives. The two Cloudron WordPress packages place it in
different locations, and cwp must resolve the path from
environments.<env>.cloudron_package:
cloudron_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.
4. wp language core: what active does and does not say
Verified 2026-08-18 on WP-CLI 2.12.0, against a DDEV site and a Cloudron one.
wp language core list returns the whole catalogue, one row per locale WordPress
has ever been translated into, and each row carries exactly one status. WP-CLI
decides that status in this order:
activeif the locale equalsWPLANG,- otherwise
installedif it is in WordPress’s installed-translations list, - otherwise
uninstalled.
active is therefore an answer about an option, not about the filesystem. A
row holds one status alone, so the site’s own locale never reads as installed,
whether or not a single .mo file of it exists. Two sites, one with the German
pack and one that has never downloaded it:
missing {"language":"de_DE","status":"active","update":"available"}
present {"language":"de_DE","status":"active","update":"none"}
--status=installed does not separate them either. It filters on the same field,
so the active locale is missing from that list on both sides.
wp language core is-installed <locale> reads the installed-translations list
directly and exits 0 or 1. That call alone answers the question, and cwp makes it
for the active locale alone (B-069). en_US cannot be missing, so cwp
never asks about an English site.
wp site switch-language <locale> writes WPLANG and nothing else. cwp uses it
to put a site into its recorded locale (B-070), and
wp language core install <locale> --activate does both in one call.
5. Bricks post-import steps
Builder.postImport applies these after a pull, unconditionally, as a member of
the interface rather than behind a config flag (B-022). On builder.id: bricks
that means the steps below. On none it means an empty finding list.
-
Regenerate code signatures. Bricks signs code elements per site, and it blocks imported content until somebody re-signs it. 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 that it does not yet exist. cwp therefore capability-checks first: it parses
ddev wp bricks --help(equivalentlyddev wp help bricks) for a signature subcommand and uses it where present. Otherwise it degrades to a clear instruction, which is to regenerate under Bricks → Settings → Custom Code → Code signatures. Never hardcode an unverified command name.The mechanism, read 2026-09-05 in the 2.4-beta3 source (F-150). A signature is
wp_hash( $code )(helpers.php:3386-3391), which WordPress keys withAUTH_KEYandAUTH_SALTfromwp-config.php. Two installs with the same two salts produce the same signatures, and a code element, a component property or a global query pushed between them is valid on arrival; two installs with different salts invalidate every one. The only regeneration path is the admin actionbricks_regenerate_code_signatures(admin.php:3525-3563), which crawls every post with Bricks meta, the global elements, the components and the global queries. No WP-CLI command and no ability regenerates them;abilities/execute-php.phponly verifies. -
Regenerate CSS files where 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 where the subcommand is absent or CSS is inlined. -
Report license state. Bricks is licensed per site, and 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 abilities, verified 2026-07-29 by enumeration (§5.2). 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.php, and 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 all step 3 could ever report on.
5.1 Bricks and custom post types
Bricks does not define post types. It references them, in three places:
-
Template conditions, which decide the post type a template applies to.
-
Query loops, which decide the 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). cwp read-modify-writes that one key and never overwrites the whole option, 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 the value is database state, so it travels downward only. Enabling it locally never reaches production. F-025 reports the gap per environment.
The page abilities enforce it, and the refusal does not survive the trip. Verified 2026-08-25 by reading Bricks 2.4 on a live site.
Elements::read_post_permissiondelegates toresolve_and_authorize, which callsInput_Resolver::resolve_post_id. A post outside the allow-list is refused withError::bricks_not_enabled_on_post_type($post_id, $post_type, $allowed). The allow-list isInput_Resolver::default_allow_list():bricks_global_settings.postTypes, falling back topageandpostwhere the key was never saved, plusBRICKS_DB_TEMPLATE_SLUG. The template post type is opened through Bricks’ own UI and sits in no setting.None of that reaches a caller. The refusal is produced inside a permission callback, and WordPress replaces it with its own
ability_invalid_permissionscarrying no data:{"code":"ability_invalid_permissions","message":"…hat nicht die nötige Berechtigung.","data":null}So the post type must be checked before the call rather than diagnosed after it. cwp reads the allow-list in the readiness probe (§5.5) and asks about no post outside it (B-134).
The first two create an ordering dependency for cwp bricks push (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 (S-0017).
5.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, rather than from the release notes.
Bricks 2.4-beta2 registers 145 abilities in the bricks/ namespace on
WordPress core’s wp_abilities_api_init hook (includes/abilities/manager.php:142).
The count moves with the beta, and the version is part of the fact. Re-measured 2026-08-23 against a live 2.4-beta3 install: 170 in the
bricks/namespace and 197 in the registry. The release grew the surface, with remote components, remote templates and a PHP-execution ability among the additions. Both measurements are correct for the build they were taken on, and neither is a number the public site may pin (B-075).site/CONTENT-RULES.mdrule 3 keeps the distinction between the two surfaces and lets the counts live insite/src/data/abilities.json, which carries the version it captured.What did not move is everything this section specifies about shape: the namespace, the hook, the annotation vocabulary, and the rule that silence is read as a write.
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, and 175 is what a call can
reach. cwp 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, and bricks/set-page-elements is destructive: true. A
general-purpose call command can therefore apply the upward-write guard set
(S-0004) 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. cwp treats an ability that declares nothing 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 stays distinct
from false everywhere cwp reads it: 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, and 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 §5.3. This is the one part of the transport that does not carry over from the scrub.
maintenanceMode lives in the same option and is read on every readiness
probe (F-068, verified against Bricks 2.4). Maintenance::__construct reads it
through Database::get_setting( 'maintenanceMode' ) (maintenance.php:14). With
it set, apply_maintenance_mode swaps the rendered template for the maintenance
one on every front-end request. Four things get past it: the admin and the
builder, the configured login page, a post in maintenanceExcludedPosts, and a
user who can bypass, which by default is any administrator
(bricks_bypass_maintenance, capabilities.php:259). That last exemption is why
the flag is worth reporting at all. The person checking the site is normally the
one it is invisible to.
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. 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), which is 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 (§5.5), and §5.3 is a fourth gate beyond these three.
Ability names and error codes are append-only by Bricks’ own stated policy,
and 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. inspect-transfer-package
accepts a re-zipped tree 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, and 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.
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: S-0017 stands unchanged.
cwp bricks pull and cwp bricks push round-trip templates and components
through bricks/ as JSON. Always mutate exported JSON, never author it from
scratch: Bricks JSON carries generated element IDs and references to global class
IDs.
5.3 The ability transport runs as a WordPress user
Verified 2026-07-29, and it corrects the transport §5.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 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 the ability transport puts no file on either
filesystem. The scrub writes to cwp/tmp and deletes afterwards; this one does
not write at all. That matters more remotely than locally. A generated PHP file
inside a live Cloudron container is a thing an interrupted run can leave behind.
A sentinel line frames the output, rather than a scan 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_userincwp.yml, where 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 rather than in any one command.
5.4 Application passwords cannot be re-read
cwp 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, and it cannot let a second run emit a working
config. So:
- where no password named for this project and environment exists, cwp creates one and prints the config;
- where 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 mcp says “invalidate my other
machines”.
The secret never enters the repository. cwp prints it for the user to place, or
hands it to the client’s own CLI. Both cwp.yml and bricks/ are committed, so
neither may ever hold it.
5.5 The readiness probe calls no ability
Added 2026-07-29 after B-014. Diagnosing the chain in §5.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. 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(§5.1). The diagnostic path cannot go through the thing whose absence it is diagnosing.
cwp calls an ability afterwards, once the facts say one should answer. It confirms. It is no longer the evidence.
5.6 What a builder save leaves behind
Verified 2026-08-28 against Bricks 2.4-beta3, by reading the theme source and
by measuring two live installs: a controlled two-save session on a demo site, and
the accumulated history of a production site with real editorial work. Line
numbers are themes/bricks/includes/ajax.php and wp-includes/revision.php
unless stated otherwise.
Editorial activity is the only signal that says when a pull is due, and every obvious way to read it is wrong in a way that looks right.
A builder save always writes a WordPress revision. Bricks filters
wp_save_post_revision_check_for_changes off and then calls
wp_save_post_revision( $post ) (ajax.php:2189-2204). Without that filter there
would be no revision at all: Bricks keeps the element tree in postmeta, so
post_content never changes and core’s diff check declines.
The element tree is copied onto the revision. update_metadata( 'post', $revision_id, … ) for content, header and footer (ajax.php:2977, 2999, 3021,
3082). A revision is a complete state, not a marker.
post_modified moves with it, through wp_update_post (ajax.php:3140-3142).
The cap is 100, and Bricks is what imposes it. wp_revisions_to_keep is
filtered to BRICKS_MAX_REVISIONS_TO_KEEP for every Bricks-enabled post type and
for templates (revisions.php:186-192, functions.php:138). Measured on a live
install: wp_revisions_to_keep( $page ) returns 100, and WP_POST_REVISIONS is
undefined, so core alone would keep every one.
Autosaves do not survive. Bricks writes one through wp_create_post_autosave
(ajax.php:3872-3926) and deletes it as soon as a real save lands
(ajax.php:2195-2201). Measured: zero autosave rows across both installs, and two
row ids consumed and gone over the two-save session.
Four findings change how the count may be read.
One save produces exactly one revision. Two saves produced two revisions and
nothing else. The wp_update_post that follows does not add a second.
A revision is written even when nothing changed. In the controlled session
the second save’s revision carried a _bricks_page_content_2 byte-identical to
the parent’s, and post_modified still moved. So a revision count measures save
clicks, and so does post_modified. Neither is a change detector. The hash
comparison against base (S-0001) remains the only one.
A revision dates the state it preserves, not the edit that produced it.
revision.php:92-93 sets the revision’s post_date from the parent’s
post_modified, which is the value from before the update. Measured: the
revision created by the first save carried the timestamp of the previous write,
two days earlier. Two consequences for anyone counting edits since a point in
time: the dates are offset by one, and the most recent edit has no revision at
all, because it is still the parent’s post_modified. Reading n revisions plus
the parent yields n events.
A revision’s post_author is whoever saved it. Core excludes post_author
from the versioned fields (revision.php:31-45), so wp_insert_post fills it with
the current user. This is the only place WordPress records who edited a post. The
parent’s post_author is its author and does not move. Measured on the
production install: 158 of 387 revisions carry an author different from their
parent’s. A post_author of 0 marks a write with no user context, such as
WP-CLI or an import.
A builder save is distinguishable from a machine write. cwp writes builder
data with update_post_meta (bricks-patch.php) and never enters the revision
path. The wp_update_post in content.php produces a revision only where title,
content or excerpt changed, and that revision carries no Bricks meta. So a
revision holding _bricks_page_content_2 came from a person in the builder.
Measured on the production install: 153 of 175 page revisions and 36 of 61
template revisions carry it, against 0 of 149 for an event post type filled by
import.
Components and global classes leave their history nowhere. Both are stored
with update_option (ajax.php:2166), and both set $bricks_data_changed
(ajax.php:2134-2141). Editing a component therefore writes a revision against
whichever post happened to be open, while the change itself lands in an option
with no history. Menus, terms and settings have the same gap without the
misleading revision.
5.8 What the transfer package and the settings screen leave out
Read 2026-09-05 in the 2.4-beta3 source (F-150), four facts the earlier sections did not hold.
customCss is written to a file by Bricks itself when the CSS loads from
files. assets/global-custom-css.php:9-16 hooks
update_option_bricks_global_settings and regenerates
global-custom-css.min.css whenever customCss or cssLoading changed. A
wp option patch on the key triggers it; no regenerate_assets is needed for
that key. This answers the question F-130 left open.
The package strips templateConditions on export and deletes them on
replace. unified-global-transfer.php:5912-5914 removes the conditions from
the exported template settings, and the importer rewrites
_bricks_template_settings from the export or deletes it (:7490-7506), so
a template replaced through the package arrives without its conditions. cwp
moves templates as content (F-050) for this reason and for the element ids;
templates must not return to the package push.
Six settings keys are in no transfer tab and no ability.
builderInterfaceProfileAssignments and the five role lists
bypassMaintenanceCapabilities, formSubmissionAccessCapabilities,
executeCodeCapabilities, uploadSvgCapabilities and customCapabilities.
The admin save preserves the first and writes the five into role capabilities
(capabilities.php:742-748); none of them reaches a target through the
package or through bricks/set-global-settings. The rule per key in
settings-policy.ts never sees them, which is correct.
The tab lists hold 174 keys in beta3, counted over
unified-global-transfer.php:18-268; F-130 counted 156 on 2026-09-03. The
number moves with the beta and is not a contract.
5.7 The export reads its own archive before closing it
Verified 2026-09-03 against Bricks 2.4-beta3 on two hosts, by reading the theme source and by running one PHP snippet on each side.
Unified_Global_Transfer::export_package writes every selected type into a
ZipArchive, then calls add_dependency_types_to_manifest
(unified-global-transfer.php:1320), which reads each component back out of
the still open archive through ZipArchive::getFromName (:4514, :6494)
to mark the classes and variables the component needs, and to add a class the
selection did not name. Whether that read answers is the host’s decision:
| host | PHP | libzip | statName on an unclosed entry | getFromName on an unclosed entry |
|---|---|---|---|---|
| DDEV | 8.5.7 | 1.11.3 | array | the contents |
| Cloudron | 8.5.9 | 1.7.3 | array | false |
Bricks does not check for false, so on the second host the manifest carries
no dependency, dependencySources or dependencyRequiredBy on any item,
and a class a component needs is added to the package only where the
selection names it. The same design system therefore exports differently from
two hosts with the same Bricks version.
Two consequences for cwp. The three fields describe the export and no import
path reads them (the importer takes a component’s dependencies from the
component’s own dependencies block, :2467), so canonicalisePackage drops
them with createdAt and site (B-173). And cwp names every class in its
selection, so the second effect never reaches a cwp pull; it reaches whoever
exports a component alone from the Bricks admin on such a host.
5a. Shared hosting: what the tools and the host do
Verified 2026-09-01 against a live shared-hosting account (FTPS on 21, SFTP
on 22, no shell) and rclone v1.75.0. Recorded here because F-127’s transport
rests on every line of it, and because none of it is derivable.
The host. ssh user@host 'echo ok' authenticates and answers
exec request failed on channel 0: the account is an SFTP jail. The SFTP
subsystem works, the FTPS one works, and neither runs anything. PHP as the web
server executes it is 7.4.33, ZipArchive is present, memory_limit is 128M
and max_execution_time is 60. A file uploaded over FTP is owned by the account
the web server also runs as, so there is nothing to repair after a push (B-006’s
question, answered not-applicable for this transport). A file whose name
begins with a dot is served: .cwp-agent-<hex>.php answers 200.
rclone sync|copy --use-json-log --log-level INFO writes one JSON object per
file to stderr:
| Case | Fields |
|---|---|
| copied | "msg": "Copied (new)", "object": "<path>" |
| deleted | "msg": "Deleted", "object": "<path>" |
| dry run | "skipped": "copy" or "skipped": "delete", "object": "<path>" |
| nothing to do | "msg": "There was nothing to transfer", no object |
A second sync of an unchanged tree transfers nothing, so modification times
survive the round trip over FTP and a repeated push is a no-op.
Passwords. rclone reads one only in its own obscured form.
printf %s "$secret" | rclone obscure - takes it on stdin, and the obscured
value goes to the transfer in RCLONE_<BACKEND>_PASS, never in argv, which the
journal prints.
Flags that are not optional. RCLONE_CONFIG=/dev/null silences a NOTICE
about a missing config file on every call. On the sftp backend,
shell_type, md5sum_command and sha1sum_command must be none, or rclone
probes for a shell that is not there; its known_hosts_file parser also refuses
a file OpenSSH accepts (key type mismatch), so the host key is pinned with
pin_host_key instead. On the ftp backend, explicit_tls is what a panel
means by “FTPS on port 21”.
6. 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 the
scrub and the 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, which defaults to web.
The script runs inside the named container, and the standard preamble is
set -eu -o pipefail. $DDEV_FILES_DIRS, a comma-separated list of 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 to /var/www/html. The version of this
section written on 2026-07-24 was verified against the schema and never executed.
So 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(§2). - 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. The invariant is that the upward path is hard to take by accident (S-0016, principle 1), 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.
7. rsync: what it sends, and what it never sends again
Verified 2026-09-03 on this machine, against rsync 3.5.0 (Homebrew) and
openrsync, protocol 29, “rsync 2.6.9 compatible” (/usr/bin/rsync on macOS
15). Every claim below held identically on both. Recorded because §2 called
cloudron sync’s behaviour “rsync-style”, which was an inference, and because
F-100’s transport rests on the difference.
Which binary runs is the operator’s PATH. macOS ships openrsync and
Homebrew’s rsync shadows it. cwp names the tool and nothing else, which is
right, and it means the two must agree about everything cwp reads. They do.
A trailing slash on the source means “the contents of”. rsync -a src/ dst/
puts a.txt at dst/a.txt; rsync -a src dst/ puts it at dst/src/a.txt.
Same as cloudron sync (§2), and pushTree appends the slash for that reason.
The quick check is size and modification time. A second run of an unchanged tree prints nothing and sends nothing. A repeated push is cheap for that reason alone.
A file that differs in content alone is never resent. With the same length and the same mtime, both implementations report nothing at all and leave the old bytes on the far side:
$ printf 'one' > src/a.txt && rsync -az --itemize-changes src/ dst/
>f+++++++++ a.txt
$ printf 'ONE' > src/a.txt && touch -t <the old time> src/a.txt
$ rsync -az --itemize-changes src/ dst/
$ cat dst/a.txt
one
--checksum is what changes it, and it says which rule fired:
$ rsync -az --checksum --itemize-changes src/ dst/
>fc........ a.txt
This is the same failure §2 records for cloudron sync, so the sentence “a
re-run does not repair it” is true of every transport cwp drives. What differs
is the remedy: rsync and rclone can be told to stop trusting the two
(--checksum, --ignore-times), cloudron sync cannot. ADR-030 is what cwp
does with that, and repairTransfer is where a provider answers it.
--itemize-changes is compatible across the two, and the field width is
not. rsync 3.5.0 prints an eleven-character flag field, openrsync nine:
| rsync 3.5.0 | openrsync | |
|---|---|---|
| a new file sent | >f+++++++++ a.txt | >f+++++++ a.txt |
| content-only resend | >fc........ a.txt | >fc...... a.txt |
| a directory created | cd+++++++++ inc/ | cd+++++++ inc/ |
removed by --delete | *deleting gone.txt | *deleting gone.txt |
parseRsyncItemised reads a decision character, a type character, up to nine
attribute characters and then the path, so both parse. A parser that counted
eleven would read openrsync’s paths off by two.
--dry-run writes the same itemised lines and transfers nothing. That is
what transferDryRun: true rests on.
A missing source is exit 23, not a distinct code, on both.