pithy secrets

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

SubcommandFlagMeaning
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-runResolve the declaration and say what would happen. Calls no issuer, writes nothing, needs no credentials
deprovision--keysAlso delete each environment’s master key. Irreversible: every stored secret becomes undecryptable
all--jsonMachine-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.

DeclarationWhat happens
localThe kit produces the value, so it produces another. Minted here, written through the same manager Workflow every other write goes through
providerThe issuer is called and returns the successor. A provider secret with no rotator is refused by name, with both ways out
manualA 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:

  1. Every refusal happens before anything is called. An undeclared rotation, a provider secret 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.
  2. The value is produced once, and never again for any reason.
  3. 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

KeyTypeMeaning
commandstring"secrets create", "secrets update" or "secrets delete" — rm reports the mode it ran, which is delete
namestringThe secret name given on the command line
environmentsstring[]The managed environments the write reached. What landed, never what was planned
interruptedbooleanPresent and true only on a global fan-out that failed after at least one environment was written

rotate

KeyTypeMeaning
commandstring"secrets rotate"
namestringThe secret name given on the command line
rotationsobject[]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[].namestringThe secret’s registry name. Never its value; this payload has no field that could carry one
rotations[].statusstring"rotated", "unchanged", "unrecorded" or "failed". unrecorded is the issuer-rolled-and-not-stored state, and the only one that exits 3
rotations[].rotationstringHow the registry says the secret is replaced: "local", "provider" or "manual"
rotations[].rolledbooleanWhether 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[].rollFailedbooleanPresent 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[].recordedstring[]The environments that took the new value, in the order they took it
rotations[].strandedstring[]The environments the new value never reached. On an unrecorded run these are holding a credential the issuer has retired
rotations[].reasonstring"manual" or "dry-run". Present only when status is unchanged

ls

KeyTypeMeaning
commandstring"secrets ls"
secretsobject[]Every declared name, sorted
secrets[].namestringThe registry key — a secret name, or a keyspace
secrets[].descriptionstringThe 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

KeyTypeMeaning
commandstring"secrets edit"
pathstringThe absolute path of <config>/<project>/secrets.jsonc
changedbooleanWhether the file was written. false for an edit that changed nothing, which is not a failure
secretsnumberHow many secrets the file holds now. A count — the names are not in this payload

provision

KeyTypeMeaning
commandstring"secrets provision"
environmentsobject[]One entry per managed environment, in order
environments[].envstringThe environment
environments[].databaseIdstringThe id of that environment’s secrets D1
environments[].storeIdstringThe Secrets Store id that environment’s master key was written to
wiredobject[]One entry per Worker and environment whose secrets_store_secrets stanza this run wrote
wired[].workerstringThe Worker, as pithy worker list shows it
wired[].envstringThe environment whose stanza was written. Never dev
wired[].bindingsstring[]The binding names written into that stanza
wired[].createdstring[]The binding names whose value this run minted. A subset of bindings, and empty on a re-run
generatedobject[]One entry per d1 secret the registry declares mintable
generated[].namestringThe secret’s registry name. Never its value
generated[].environmentsstring[]Where the secret belongs
generated[].createdstring[]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
interruptedbooleanPresent and true only on a run that failed part-way through creating the d1 secrets

deprovision

KeyTypeMeaning
commandstring"secrets deprovision"
keysDeletedbooleanWhether --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. rotate only. Add a rotation to its registry entry, or replace the value with pithy 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. rotate only, 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 code 3. 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 at pithy dev — this writes to a Cloudflare account, and dev is local.
  • Cloudflare credentials are missing. Raised by every subcommand that reaches Cloudflare — not by ls.
  • The CF Secrets Store id is missing. provision and deprovision only.
  • 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.
  • edit conflicts. 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.

ESC