❯ Design document

SPEC.md

How cwp is built

cwp as it is: the design of the released tool.

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 PurposeREADME.md and docs/ARCHITECTURE.md §1
2 Design principlesS-0016
3 Stack§1 below
4 Configurationdocs/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.8docs/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 scaffoldsS-0017
7 Uploads strategiesS-0018
8 Scrub pipelineS-0019
9 Bricks post-import steps§5 below
10 DDEV provider generation§6 below
11 HooksS-0020
12 Output contractS-0021
13 ScopeREADME.md

1. Stack

Decided, and not open for revision during implementation.

  • Runtime: Node.js ≥ 22, ESM only
  • Language: TypeScript, built with tsup to a single ESM bundle
  • CLI framework: commander
  • Prompts: @clack/prompts
  • Config: yaml + zod for 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:

  1. COPYFILE_DISABLE=1 is mandatory. cloudron sync shells out to tar, and macOS tar packs extended attributes as AppleDouble sidecars. An unguarded push of three files landed six on the remote: ._a.txt and friends. With COPYFILE_DISABLE=1 in 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.

  2. Chown after transfer (B-006). cloudron sync push enters 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 of wp-content is www-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 with chown -R www-data:www-data <target>, best-effort: the files are already there, so a failure warns rather than failing the push.

  3. Verify after transfer, never trust the exit code. cwp verifies an upward transfer by digest, not by counting files (B-007). It reads md5sum for 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 without md5sum warns and falls back to the count.

    A count cannot tell a completed transfer from a no-op over an already-populated directory. cloudron sync compares size and mtime, so it reports a remote file corrupted to the same length with its mtime preserved as Already up to date and 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 be touched, 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.
  • --delete mirrors, 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 --delete lists 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 rm for 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_packagewp-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:

  1. active if the locale equals WPLANG,
  2. otherwise installed if it is in WordPress’s installed-translations list,
  3. 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.

  1. 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 (equivalently ddev 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 with AUTH_KEY and AUTH_SALT from wp-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 action bricks_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.php only verifies.

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

  3. 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_SAVE and ..._ON_BUILDER_RERENDER remove the re-signing chore on a local environment. The name is a warning and cwp treats it as one: these belong in a DDEV wp-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:

  1. Template conditions, which decide the post type a template applies to.

  2. Query loops, which decide the post type a loop queries.

  3. 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 postTypes inside the bricks_global_settings option, read off this project’s site where it holds ["page"]. Bricks 2.4 exposes it through bricks/get-global-settings and bricks/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 carries customCss, 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_permission delegates to resolve_and_authorize, which calls Input_Resolver::resolve_post_id. A post outside the allow-list is refused with Error::bricks_not_enabled_on_post_type($post_id, $post_type, $allowed). The allow-list is Input_Resolver::default_allow_list(): bricks_global_settings.postTypes, falling back to page and post where the key was never saved, plus BRICKS_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_permissions carrying 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.md rule 3 keeps the distinction between the two surfaces and lets the counts live in site/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.

  1. 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 wp and cloudron exec -- wp, with nothing installed beyond Bricks itself.
  2. wp ability must not be depended on. The CLI command lives in the separate wp-cli/ability-command package and is absent from WP-CLI 2.12.0 on both the DDEV container and the Cloudron app. cwp invokes abilities through wp eval-file with a generated shim, the transport the scrub already uses, which needs no per-environment install and behaves identically on both sides.
  3. 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):

gatelocationnote
BRICKS_DISABLE_MCPwp-config.phphard off; no force-on constant exists by design
abilitiesApibricks_global_settingsthe admin toggle, Bricks → Settings → AI
enabled + deny-listbricks_mcp_settingsper-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:

grouptypes
styleclasses, variables, theme-styles, color-palettes, custom-fonts, icon-manager
structuretemplates, components, global-queries, breakpoints (singleton)
settingssettings, 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:

  1. bricks.wp_user in cwp.yml, where set. An id, login or email, passed through untouched, because those are exactly what --user accepts.
  2. 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 --rotate to 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.

causewhat the ability call reportsthe actual fix
WordPress < 6.9no wp_get_abilityupdate WordPress
Bricks < 2.4, or inactiveability not registeredupdate or activate the theme
BRICKS_DISABLE_MCP setability not registerednothing, this is intended
AI tab never savedability not registeredBricks → Settings → AI
toggle switched offability not registeredBricks → Settings → AI
ability denied individuallyability not registeredthe 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_settings with get_option() is allowed here and nowhere else. Settings otherwise go through bricks/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:

hostPHPlibzipstatName on an unclosed entrygetFromName on an unclosed entry
DDEV8.5.71.11.3arraythe contents
Cloudron8.5.91.7.3arrayfalse

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:

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

StanzaRole
environment_variablesPlain key: value map, exported into the web container for use by the command stanzas. Not a command block.
auth_commandAuthenticate against the host.
db_pull_commandMust produce /var/www/html/.ddev/.downloads/db.sql.gz.
db_import_commandOptional. Defaults to importing that gzip into the db database.
files_pull_commandMust place user files under /var/www/html/.ddev/.downloads/files.
files_import_commandOptional. Defaults to copying into the upload dirs.
db_push_commandShips /var/www/html/.ddev/.downloads/db.sql.gz upstream.
files_push_commandCopies $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 pull workaround as pullDirectoryTree (§2).
  • Both push stanzas refuse. ddev push has no confirmation, no backup, no protected-environment check and no typed environment name, and a provider cannot add them. They exit 1 naming cwp db push and cwp 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.0openrsync
a new file sent>f+++++++++ a.txt>f+++++++ a.txt
content-only resend>fc........ a.txt>fc...... a.txt
a directory createdcd+++++++++ 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.