pithy provision

Create an environment’s own Cloudflare resources, wire them into every Worker, and migrate. It is the middle step of putting a project on the internet: resources first, then pithy deploy.

Synopsis

pithy provision --env <environment> [--yes] [--confirm <phrase>] [--seed] [--json]
pithy provision --feature [--json]

One job, two spellings. --env provisions an environment the root pithy.config.ts declares; --feature provisions the one this branch gets. Exactly one of them is required, and passing both is refused at the flag, before a config is loaded or a Cloudflare client is built.

--env runs from the project root. --feature runs from inside the feature worktree and takes no name: the branch says which feature it is.

Flags

FlagApplies toDefaultPurpose
--env <environment>——The declared environment to provision. Refused unless the root pithy.config.ts lists it
--feature—falseProvision this branch’s own environment instead. Run from inside the worktree
--yes--envfalseConfirm that this creates real Cloudflare resources. Required for every declared environment
--confirm <phrase>--env—Unlock a production environment non-interactively: yes, i really want to provision <env>
--seed--envfalseAlso load seed fixtures once the schema is up
--jsonbothfalseOne line of machine-readable output

--yes, --confirm and --seed say nothing a feature environment does not already do. It is created per pull request and destroyed on merge, so there is nothing to confirm — a gate every pipeline has to pass is a gate that has stopped meaning anything — and it is created empty, so it always seeds.

Why one command

Both modes create one Cloudflare resource per binding name across every Worker, write the ids into each Worker’s config, and migrate. Every name they compose is held to the naming rules and their limits, which is why a project name is capped well under Cloudflare’s own ceiling. They differ only in how the target environment is named — declared in pithy.config.ts, or derived from the branch. That is a flag, not a different verb.

The safety is in the scope rather than in the spelling. A ProvisionScope carries the resource naming and the env.<name> stanza the ids are written into, as one value — so a feature-named resource landing in a declared environment’s stanza of a checked-in config is unexpressible rather than merely refused.

The one real difference: what happens to the ids

WritesStatus
--env <name>env.<name> in the Worker’s tracked wrangler.jsoncSource — long-lived ids you review in a pull request
--featureapps/<worker>/.wrangler/pithy/wrangler.feature.jsoncBuild artifact — git-ignored, rebuilt every run, never committed

A single flag that flips whether output is committed will eventually surprise someone, so every run states the file it wrote and what happens to it:

Wrote 3 ids into apps/board/wrangler.jsonc. Commit them.
Wrote 3 ids into apps/board/.wrangler/pithy/wrangler.feature.jsonc. Ignored, and rebuilt on the next run.

--json carries the same answer as configs and committed, so a pipeline reads it rather than inferring it. This is also what keeps the standing rule true: a CI build process never commits back to the repository. A pipeline runs --feature and has nothing to commit; a person runs --env and commits ids in a pull request.

--feature is explicit, never inferred

It is not derived from the branch looks like a feature branch. An implicit mode switch on branch shape is how somebody on feature/… provisions the wrong thing while reading a command line that says nothing about it. The flag is the declaration; the branch is only where a feature’s name comes from once you have made it.

The two modes cannot reach each other’s environment either. --feature writes the feature stanza and nothing else. --env accepts only what the project declares, and feature can never be declared — it is a legal wrangler stanza key and an illegal declaration, because a feature’s config is generated rather than committed and one stanza cannot have two owners.

Why it exists at all

A project scaffolded, wired and migrated by Pithy could not be deployed, and nothing said why.

CommandWhat it does about resources
pithy add <cap>Runs the Worker’s dev migrations, against local D1 through Miniflare. Creates nothing remote
pithy migrate --env prodQueries the target database. Assumes it exists
pithy deployProvisions nothing. Spawns wrangler

So apps/<worker>/wrangler.jsonc declared "database_name": "<project>-staging-db" with no database_id, in every environment, and stayed that way — and the first deploy failed inside wrangler, on a field nobody knew they were meant to fill in. The provisioner was never missing; only the command and the naming were.

What it does

  1. Resolves the scope. The naming and the stanza come from one object rather than two arguments: <project>-<env>-<thing> into env.<env> for a declared environment, <project>-f<issue>-<slug>-<thing> into env.feature for a branch’s.
  2. Provisions one resource per binding name, across the union of every Worker’s capabilities. Two Workers that both declare DB share one database; a Worker that wants its own declares a different binding.
  3. Adopts rather than duplicates. Every resource is matched by name before it is created, so a re-run is a no-op and a database you made by hand under the right name is taken up rather than shadowed by a second one.
  4. Writes the ids into each Worker’s config, under env.<name> — and a Worker receives only the bindings its own config declares. The D1 entry gets its database_name alongside its database_id, because pithy add proposes the name offline and this is the step that makes the proposal true. The stanza is created when it is absent, so an environment declared after the project was scaffolded needs no hand-editing.
  5. Writes the secrets_store_secrets stanza for every cf-secrets-store secret the Worker’s own registry declares, when a Secrets Store id is in hand. pithy add deliberately could not write it — the entry needs a store_id and a secret_name that do not exist until an account has been reached. A declared secret whose entry has not been created is reported rather than bound: wrangler refuses a config naming an absent entry, so binding it would turn one missing value into a failed deploy of the whole Worker.
  6. Creates the secrets that have no decision in them. A registry entry declares whether its value is arbitrary — a signing key, an ingest secret: any random string works, because nothing outside the project has to agree with it. Provisioning mints those and binds them in the same pass. It still stops for a supplied secret — an OAuth client secret, a payment rail’s key — because a random string there authenticates against nothing. Absence is checked first, always: an existing value is never replaced, and replacing one is rotation, which is a separate and deliberate act. No minted value is printed, logged, or put in an audit event.
  7. Retargets service bindings at this environment’s copy of the callee, resolved through each Worker’s real deploy name rather than its apps/<name> directory.
  8. Migrates, and seeds when asked. A feature also mints its own master key and records a manifest so pithy feature destroy deletes exactly what was created.

The secrets it cannot create

A d1 secret — the auth session secret, the email link-signing key — is sealed under a master key that lives inside an environment’s secrets manager Worker. Only that manager can write one, and this command runs before the managers are necessarily deployed. So it creates none of them, and it says which, rather than reporting Provisioned prod. Migrated. for an environment that cannot serve a request.

Who can create them is not the same answer in both modes.

--env names the command:

auth-session-secret, email-link-signing-key: not created here — they need a deployed manager.
Run pithy secrets provision to create them.

--feature names none, because there is none:

auth-session-secret, email-link-signing-key: not created here — they need a deployed manager.
A branch gets no manager, and no command creates these for one. This environment comes up without them.

pithy secrets provision spans the environments the project declares, deploying a manager into each. A branch is not declared and gets no manager, deliberately: a manager is a Worker with its own D1 and its own rotation cron, and one per open pull request is not a thing anybody wants.

So this is a stated shortfall rather than an invented remedy. A feature environment comes up without its d1 secrets, and every capability that reads one fails at its first request. The run warns and does not refuse — --feature runs per pull request in CI, and failing every one of them would not close the gap. --json carries the distinction as pendingSecretsRemedy: the command, or null.

Production

--yes is not enough for production, and never becomes enough. A production environment — the built-in prod and production, plus anything the project declares in seed.productionEnvironments — additionally requires the exact phrase:

pithy provision --env prod --yes --confirm "yes, i really want to provision prod"

The phrase names its environment, so one typed for staging cannot be pasted into a command targeting prod. Interactively the CLI asks for it; under --json it must arrive by flag, so a headless production provision happens only when a person wrote the phrase into the pipeline.

Deploy refuses, it does not provision

pithy deploy --env staging checks first, and refuses with the command to run when a binding has no resource behind it:

staging declares bindings with no Cloudflare resource behind them: board.DB (d1).
Run pithy provision --env staging --yes, then deploy.

A deploy that silently created account resources would be hard to review, and these are the resources worth reviewing. pithy doctor reports the same state without being asked.

Teardown

pithy feature destroy reverses a branch’s environment, because a branch’s environment is disposable.

For a declared environment there is none, deliberately. Staging and production are not disposable, and the one-word difference between the two is not a difference a flag should carry. Delete them in Cloudflare, by hand, on purpose.

--json

$ pithy provision --env staging --yes --json
{"command":"provision","env":"staging","resources":[{"kind":"d1","binding":"DB","name":"replay-staging-db","id":"9f0…","created":true}],"workers":[{"worker":"replay-board","name":"replay-board-staging"}],"services":[],"secretBindings":[],"configs":[{"worker":"replay-board","path":"apps/board/wrangler.jsonc","ids":3}],"committed":true,"pendingSecrets":["auth-session-secret"],"pendingSecretsRemedy":"pithy secrets provision"}
KeyTypeMeaning
command"provision"The command that produced the line. The same for both modes
envstringThe environment provisioned — a declared name, or feature
resourcesobject[]Every resource, in provision order
resources[].kind"d1", "kv" or "r2"The Cloudflare resource type
resources[].bindingstringThe Worker binding this resource backs, e.g. DB
resources[].namestringThe full Cloudflare resource name
resources[].idstringThe Cloudflare-assigned id — a D1 uuid, a KV namespace id, or the bucket name for R2
resources[].createdbooleanTrue when this run created it; false when a resource of that name already existed and was adopted
workersobject[]Each Worker and the script name it deploys under in this environment
workers[].workerstringThe Worker’s own deploy name — its wrangler.jsonc name
workers[].namestringThe scoped script name written into env.<name>
servicesobject[]Each service binding and the Worker it now targets in this environment
services[].bindingstringThe binding name
services[].servicestringThe script the binding was retargeted at
secretBindingsobject[]Every cf-secrets-store secret this environment declares
secretBindings[].bindingstringThe Worker binding name, which is the registry key
secretBindings[].entrystringThe Secrets Store entry it resolves to in this environment
secretBindings[].boundbooleanTrue when the entry exists and the binding was written. False when the secret is declared and its entry has never been created — binding it anyway would make wrangler refuse the whole config
secretBindings[].mintedbooleanTrue when this run created the value, because the registry declared it may be minted. False on a re-run, which leaves an existing value alone
configsobject[]Where the ids were written, one entry per Worker
configs[].workerstringThe Worker’s own deploy name
configs[].pathstringThe file written, relative to the project root
configs[].idsnumberHow many binding ids landed in it
committedbooleanWhether those files are committed. true for --env, false for --feature — the one field a pipeline reads to know it has nothing to commit
pendingSecretsstring[]The d1 secrets this run declares and cannot create. The same set in both modes — it is a fact about the registry. Empty when the project declares none
pendingSecretsRemedystring or nullThe command that does create them, or null when no command does. "pithy secrets provision" for --env; null for --feature. A pipeline branches on this rather than on the mode

Errors

Exit 0 on success. Non-zero with a problem line and an action for: no mode or both modes, a missing declaration, missing credentials, a branch that is not a feature branch, an unconfirmed production run, or a service binding naming a Worker this project does not have — which is refused before a single resource is created.

Examples

pithy provision --env staging --yes
pithy provision --env prod --yes --confirm "yes, i really want to provision prod"
pithy provision --feature --json
ESC