Declare, write, rotate, list and edit a project’s secrets — and stand up the per-environment infrastructure that stores them. This is the command surface; the secrets capability is what reads them at runtime.
Synopsis
pithy secrets create <name> [--env <env>] [--json]
pithy secrets update <name> [--env <env>] [--json]
pithy secrets rotate <name> [--env <env>] [--dry-run] [--json]
pithy secrets rm <name> [--env <env>] [--json]
pithy secrets ls [--json]
pithy secrets edit [--json]
pithy secrets provision [--json]
pithy secrets deprovision [--keys] [--json]Flags
| Subcommand | Flag | Meaning |
|---|---|---|
create, update, rotate, rm | <name> (positional, required) | The secret’s name — a registry entry |
create, update, rotate, rm | --env <env> | Target environment for an environment-scoped secret. Not dev |
rotate | --dry-run | Resolve the declaration and say what would happen. Calls no issuer, writes nothing, needs no credentials |
deprovision | --keys | Also delete each environment’s master key. Irreversible: every stored secret becomes undecryptable |
| all | --json | Machine-readable output. Default false |
--env here is the managed set — every environment the root pithy.config.ts declares — not the three --env takes elsewhere. dev is local-only, so it is refused with a sentence pointing at pithy dev, and an environment the project does not declare is refused by name with the ones that are.
--env on a global secret is refused, not ignored. A global secret is defined by holding one value everywhere, so naming an environment asks for something the scope does not permit.
email-link-signing-key is global. It holds one value across every environment, so --env cannot narrow it.
Run it again without --env to set it in every environment.The re-run is the confirmation, so there is no prompt and no --yes. It could have printed that sentence and proceeded — but an operator who typed --env staging would then have had prod rewritten by a command they did not intend, with a notice printed after the fact as cover. One extra command is cheaper than that, every time. rm gets the same refusal, and it matters more there.
There is no --worker. Every subcommand reads the project’s registry: each Worker’s, merged by secret name.
What it does
The registry is the definition. pithy secrets never invents a name — a secret must be declared by a Worker’s secrets capability, and an undeclared one is refused before anything is sent.
A value never comes from a flag. create and update read the value from stdin when it is piped, and from a masked prompt otherwise. A flag would leave a live credential in shell history and in every process list on the machine. Nothing here prints a value back, on any subcommand, in either output mode.
create, update and rm dispatch through the environment’s manager Workflow rather than writing storage directly, and each is audited — secrets/set, secrets/rotated, secrets/removed — recording the secret’s name and the environments reached, never its value.
Which environments a write reaches is the registry’s decision rather than the flag’s: an environment-scoped secret reaches exactly the one you named; a global one in D1 fans out across every managed environment; a global one in the Cloudflare Secrets Store is written once, canonically.
A global D1 write is the one fan-out, and it is not a transaction. Each environment is a separate Workflow in a separate Worker; there is no rollback across them, and a compensating write is itself a Workflow that can fail. So the guarantee is narrower than all or nothing, and it is stated rather than implied: no ordinary command can create a split, because a narrowed global write is refused before anything is dispatched. A fault part-way through still can, and when it does the command names the environments it reached before it failed:
email-link-signing-key written to staging, canary before this failed.Under --json that is one line with "interrupted": true, environments naming only what landed, the error line on stderr, and exit code 1. Nothing reports success.
rotate
A registry entry says how its secret is replaced, and rotate branches on that declaration and on nothing else — never on a list of names. Rotating one end to end walks a real entry through it.
| Declaration | What happens |
|---|---|
local | The kit produces the value, so it produces another. Minted here, written through the same manager Workflow every other write goes through |
provider | The issuer is called and returns the successor. A provider secret with no rotator is refused by name, with both ways out |
manual | A human, in somebody else’s console. It prints the console, the page, and the pithy secrets update that records the result, and calls nothing. It exits 0 and never prints Done. — nothing was done |
--dry-run resolves the declaration and stops. It reaches no account, needs no credentials, and rolls nothing — which is what makes it the thing to type first at 2am, when the question is is this one rolled at somebody else’s API, or minted here?
There is no --all, and that is a decision. The case that wants one is real: somebody has left, and every credential they could have seen needs rolling today. Two things argue against a flag. The blast radius is the obvious one — the failure below does not average out over ten secrets, it is ten chances to strand a live credential in one invocation, at the hour an operator is least able to read carefully. The second is decisive: the precedent for a fleet-wide rotation is more than one confirmation plus an audit entry naming the operator, and the CLI cannot honor the second half. A CLI audit resolves its actor from the Cloudflare API token, so the one act most certain to be reviewed afterwards would be recorded as somebody with the token. What the case gets instead is pithy secrets ls and a shell loop, which forces you to see the list before you roll it.
The failure this subcommand is built around
A provider roll succeeds and the store write fails. The old credential is dead at the issuer, the new one exists only in the process that received it, and the environment is holding something that no longer works.
Nothing repairs it by trying harder at the roll — a second roll issues a third credential and loses the second, so the retry meant to save it is what destroys it.
So the ordering is the design:
- Every refusal happens before anything is called. An undeclared rotation, a
providersecret with no rotator, a keyspace, the master key, missing credentials — each is answered with nothing rolled and nothing written. A refusal arriving after a roll would be the worst of both. - The value is produced once, and never again for any reason.
- The store is retried against that value — three attempts, the same string every time. There is no path from a failed store back to a fresh roll.
The report is per secret and never in aggregate, and the word rotated is printed only where a value landed. The failure exits 3:
REPLAY_PROVIDER_TOKEN was rolled at cloudflare and its new value was not stored. prod holds a credential cloudflare has retired.
The new value is gone. It existed only in this process, and printing it would leave a live credential in your shell history. Roll it again at cloudflare, then record it with pithy secrets update REPLAY_PROVIDER_TOKEN --env prod.Exit 3 is its own status, distinct from 1. 1 means the previous credential is still live and the command can simply be run again. 3 means it is not, and nothing automated will fix it. A script cannot tell those apart from a message, and the two need opposite reactions. 2 is left alone: shells use it for usage errors, and a status that might mean you typed it wrong or a production credential is dead is no signal at all.
What happens to the value, said plainly
It is discarded. Not printed, not written to a file, not put in the audit trail, not returned to a caller. The rotation result has no field that could carry one, so this is structural rather than a habit.
That is a real cost, and the alternative is worth stating rather than implying there wasn’t one. Printing it would put a live production credential in shell scrollback, in the CI log, in the terminal-recording buffer, and in whatever ships those elsewhere — permanently, for the most sensitive value this command touches. Writing it to a file is the same leak with a filename. Against that, a rolled-and-unrecorded credential is an outage with a known remedy the declaration can name.
A rotator that throws is reported differently, and the difference is not cosmetic. A rotator that returned and a store that refused means the credential was rolled. A rotator that threw means it may have been — the call reached the issuer and the answer did not. Both exit 3 and both need a human at the issuer, but only one may be described as rolled, and a report that says so of both is wrong half the time about the one fact being acted on. So the second says may have been rolled and its remedy starts with checking rather than with rolling again, because rolling again on an issuer that already rolled produces a second orphan.
Two gaps, named rather than left to be discovered. Nothing here verifies the new value against the issuer before treating it as current. And a crash between the roll returning and the store accepting leaves the same state with no report at all — that window is far smaller than the store’s, and it is not zero.
The rotation is recorded, whichever path ran it
A rotation opens a row in that environment’s history before anything is rolled, and closes it after — so a rotator that never returns still leaves a trace, and a rotation that succeeded advances lastRotatedAt.
A global secret opens one row per environment, and each closes against what that environment actually got: a fan-out that reached staging and stranded prod is a success in one history and an incident in the other. A row records what caused it, so a first write and a replacement stay legible as different events. pithy secrets update is a write, not a rotation, and does not advance the clock.
Bookkeeping never decides whether a credential is replaced. A manager that cannot be reached costs the row and not the rotation.
ls, edit, provision, deprovision
ls lists the declared names with their routing facts, offline. It reads the registry and nothing else — no credentials, no network.
edit is the odd one out, deliberately: it touches nothing but this machine’s dev values at <config>/<project>/secrets.jsonc. It opens a draft beside the real file, validates what comes back, and writes it atomically at 0600. It prints a path and a count, never a name and never a value — ls is what lists names. A draft that will not validate is handed back with the problem printed above it; a draft that is still broken, that the editor abandoned, or that lost a race with another command is kept, and the refusal names its absolute path. Nothing here deletes text it could not write.
provision stands up the per-environment infrastructure for every managed environment in order: the manager’s own least-privilege token first, then per environment a dedicated D1, a minted master key, the migrated schema, and the deployed manager Worker. Every step is idempotent.
It creates every secret the registry says nobody chooses. A registry entry declares whether its value is arbitrary — a session signing key, a link signing key: any random string works, because nothing outside the project validates one. A secret whose value must match something issued elsewhere declares nothing, and stays a question for the person who can answer it.
An existing value is never replaced, on either path. Replacing a session secret signs everyone out, replacing a link key stops verifying links already in inboxes, and replacing a key-encryption key orphans everything sealed under it — so creating a missing secret and replacing a live one are different acts, and only the first happens here.
A global secret has one value in every environment, or the command stops. global is the promise that a link signed in staging verifies wherever the recipient’s click lands, so it is decided across every environment at once, before anything is written. All present, and nothing happens; all absent, and one minted value goes to each. Split — some environments hold it, some do not — and the run fails, naming the secret and both sides.
There is one repair for a split, and it is destructive: remove the secret everywhere with pithy secrets rm <name>, then run this again. The other-sounding option — give the empty environments the value the others hold — cannot be performed by anyone, because a d1 secret is sealed under a master key that never leaves its environment’s manager Worker. So the refusal names that one command and says what it costs.
It also writes your secrets_store_secrets stanza. pithy add secrets cannot: a secret binding needs a store_id and a secret_name that do not exist until an account has been reached. Provisioning is when the store certainly exists and every entry has certainly been written, so it is where the stanza is written or corrected — upserting by binding, never duplicating, and leaving a binding the registry does not declare exactly where you put it.
deprovision reverses it, and keeps the master keys unless --keys says otherwise.
Credentials come from <config>/cloudflare.json, or <config>/cloudflare.<accountName>.json when the root config names an account. provision and deprovision additionally need SECRETS_STORE_ID, which pithy add secrets records.
--json
One line on stdout. A failure is one {"error": …} line on stderr and a non-zero exit.
create, update, rm
| Key | Type | Meaning |
|---|---|---|
command | string | "secrets create", "secrets update" or "secrets delete" — rm reports the mode it ran, which is delete |
name | string | The secret name given on the command line |
environments | string[] | The managed environments the write reached. What landed, never what was planned |
interrupted | boolean | Present and true only on a global fan-out that failed after at least one environment was written |
rotate
| Key | Type | Meaning |
|---|---|---|
command | string | "secrets rotate" |
name | string | The secret name given on the command line |
rotations | object[] | One record per secret. Always one today, because there is no --all — the shape is per secret so no aggregate field can ever be added beside it and disagree |
rotations[].name | string | The secret’s registry name. Never its value; this payload has no field that could carry one |
rotations[].status | string | "rotated", "unchanged", "unrecorded" or "failed". unrecorded is the issuer-rolled-and-not-stored state, and the only one that exits 3 |
rotations[].rotation | string | How the registry says the secret is replaced: "local", "provider" or "manual" |
rotations[].rolled | boolean | Whether a third party’s credential was actually changed. true only for a provider rotation that reached its rotator — and true on the failure path too, which is the point of the field |
rotations[].rollFailed | boolean | Present and true when the rotator itself failed rather than the store after it. Then rolled is a guess: the call reached the issuer and the answer did not come back |
rotations[].recorded | string[] | The environments that took the new value, in the order they took it |
rotations[].stranded | string[] | The environments the new value never reached. On an unrecorded run these are holding a credential the issuer has retired |
rotations[].reason | string | "manual" or "dry-run". Present only when status is unchanged |
ls
| Key | Type | Meaning |
|---|---|---|
command | string | "secrets ls" |
secrets | object[] | Every declared name, sorted |
secrets[].name | string | The registry key — a secret name, or a keyspace |
secrets[].description | string | The entry’s routing facts: backend (d1 or cf-secrets-store), scope (environment or global), then rotatable when it is, then keyspace when the entry is keyed |
A keyspace marker is the one entry you must not try to set: its members are written per key, in-Worker, by the application that mints them.
edit
| Key | Type | Meaning |
|---|---|---|
command | string | "secrets edit" |
path | string | The absolute path of <config>/<project>/secrets.jsonc |
changed | boolean | Whether the file was written. false for an edit that changed nothing, which is not a failure |
secrets | number | How many secrets the file holds now. A count — the names are not in this payload |
provision
| Key | Type | Meaning |
|---|---|---|
command | string | "secrets provision" |
environments | object[] | One entry per managed environment, in order |
environments[].env | string | The environment |
environments[].databaseId | string | The id of that environment’s secrets D1 |
environments[].storeId | string | The Secrets Store id that environment’s master key was written to |
wired | object[] | One entry per Worker and environment whose secrets_store_secrets stanza this run wrote |
wired[].worker | string | The Worker, as pithy worker list shows it |
wired[].env | string | The environment whose stanza was written. Never dev |
wired[].bindings | string[] | The binding names written into that stanza |
wired[].created | string[] | The binding names whose value this run minted. A subset of bindings, and empty on a re-run |
generated | object[] | One entry per d1 secret the registry declares mintable |
generated[].name | string | The secret’s registry name. Never its value |
generated[].environments | string[] | Where the secret belongs |
generated[].created | string[] | The environments this run created it in. Separate from environments, because sent is not made — this is the field that answers did this run generate a production signing key |
interrupted | boolean | Present and true only on a run that failed part-way through creating the d1 secrets |
deprovision
| Key | Type | Meaning |
|---|---|---|
command | string | "secrets deprovision" |
keysDeleted | boolean | Whether --keys was passed, and so whether the master keys were deleted with the rest |
Errors
- No secrets capability. No Worker in the project composes
secrets, so there is no registry to read. Secret '<name>' is not declared in the registry.Add it to the registry first.Secret '<name>' is a keyspace, not a secret.A keyspace has no single value; its members belong to the application that mints them.Secret '<name>' does not declare how it rotates.rotateonly. Add arotationto its registry entry, or replace the value withpithy secrets update.Secret '<name>' rotates by calling <issuer>, and this project supplies no rotator for it.The declaration is right and the code is missing.Secret '<name>' is the key every other secret is read through, so nothing replaces it in place.rotateonly, for the master key. It rotates on its own axis, inside the manager, on the manager’s own cron.<name> was rolled at <issuer> and its new value was not stored.Exit code3. The one failure this command cannot undo.Secret '<name>' is environment-scoped — choose an environment.Secret '<name>' is global. …Run it again without--env. Nothing was dispatched, so nothing was written.--env dev. Refused, and pointed atpithy dev— this writes to a Cloudflare account, anddevis local.Cloudflare credentials are missing.Raised by every subcommand that reaches Cloudflare — not byls.The CF Secrets Store id is missing.provisionanddeprovisiononly.- No project name. A guess would open one checkout’s secrets from another’s worktree, or dispatch this project’s values into another project’s manager.
editconflicts. The file changed while you were editing; the editor exited non-zero on changed text; the text came back invalid twice. In every case your text is kept and named.
Examples
pithy secrets ls --json
printf '%s' "$THE_VALUE" | pithy secrets create STRIPE_SECRET_KEY --env prod --json
pithy secrets update STRIPE_SECRET_KEY --env prod
pithy secrets rotate TURNSTILE_SECRET --env prod --dry-run
pithy secrets rotate SESSION_SIGNING_KEY --env prod
pithy secrets rm OLD_WEBHOOK_SECRET --env staging --json
pithy secrets edit{"command":"secrets create","name":"STRIPE_SECRET_KEY","environments":["prod"]}
{"command":"secrets ls","secrets":[{"name":"SESSION_SIGNING_KEY","description":"d1 · environment · rotatable"},{"name":"TENANT_KEYS","description":"d1 · environment · keyspace"}]}
{"command":"secrets rotate","name":"CLOUDFLARE_API_TOKEN","rotations":[{"name":"CLOUDFLARE_API_TOKEN","status":"unrecorded","rotation":"provider","rolled":true,"recorded":[],"stranded":["prod"]}]}
{"command":"secrets edit","path":"/home/you/.config/pithy/acme/secrets.jsonc","changed":true,"secrets":4}No example above contains a value, and none of these payloads can carry one.