Reconcile every Worker’s wiring with the capability manifests the project has installed.
Upgrading the kit is the walkthrough and versions and upgrades is the policy behind it. Run pithy doctor afterwards.
Synopsis
pithy upgrade [--env <env>] [--worker <name>] [--dry-run] [--migrate] [--json]Flags
| Flag | Type | Default | Meaning |
|---|---|---|---|
--env <env> | string | dev | Target environment — drives the pending-migration count, and any --migrate run |
--worker <name> | string | — | Upgrade only this Worker. Default: every Worker under apps/ |
--dry-run | boolean | false | Show the plan without writing anything |
--migrate | boolean | false | Run pending migrations after reconciling |
--json | boolean | false | Machine-readable output |
What it does
A capability’s manifest grows. A new release declares a binding it did not need before, or a config option it now takes. upgrade is what closes that gap in an existing project: it compares each Worker’s pithy.config.ts and wrangler.jsonc against the manifests installed under node_modules/@pithy-sh, and adds what is missing.
Capabilities are per Worker, so the reconcile engine is too. upgrade fans out over apps/*, building one plan per Worker against that Worker’s own wiring and applying it there.
Per Worker, a plan reports five things.
Missing bindings, per environment. A required binding the manifest declares that a wrangler stanza lacks — checked for every stanza in the file, since env.staging can be behind while the top-level one is current. Applying writes them in, comment-preserving, and appends any Durable Object class migrations.
Missing config keys. A manifest config option not yet present in that capability’s registration call. Applying inserts it with the manifest’s default rendered as its value and the option’s rationale as the comment above it. An existing key is never rewritten.
Ejected capabilities. Named, never touched. A fork no longer tracks its package, so reconciling it would overwrite code you own.
Missing Durable Object exports. A durable_objects.bindings entry is half of a Durable Object; the other half is export { <Class> } from "…"; on the module main names, which is what wrangler resolves class_name against. A class bound in wrangler.jsonc and absent from the entry is drift a config read alone cannot see — the binding is there and the class is nowhere.
Pending migrations, counted for --env. Reported by default; applied only with --migrate.
Two things sit outside that list. entitlements names this Worker’s own source files that gate a route on an entitlement while nothing the Worker composes provides one — report-only, because which capability to compose is your decision rather than the CLI’s. And missingVersionMetadata covers the version_metadata binding: without it a Worker cannot report which build is running, so log records carry no version, audit events carry no build id, and pithy deploy cannot verify the deploy it just made.
The entry is the one file it writes that is source
So both halves of that write are named. A dry run says what is missing, and an apply says what it wrote:
api:
Worker entry: export MultiplayerSession.Over every composed capability, not only the ones with a missing binding — a project wired before the CLI wrote that line has the binding already and the export nowhere, and wrangler deploy refuses it. That project is exactly who runs this command, so it is reported rather than repaired in silence.
Installed is not composed
The manifests are installed once at the project root and shared by every Worker, so they describe every capability installed anywhere in the project — not what this Worker is made of. A plan is scoped to the Worker’s own composed set. A capability another Worker added contributes nothing here; anything else would put a foreign capability’s bindings, and its Durable Object class migrations, on a script that never declared them.
A malformed manifest is named, not skipped in silence. Faults are project-wide — one install, one node_modules — so they are reported once, above the Workers. A capability with a fault appears in no other line of the report: its manifest could not be read, so it contributes no drift and the run reconciles around the hole.
A dry run resolves no project name and proposes none, so nothing can be written. An apply resolves the name from the root config, because a capability wired by upgrade must get the same <project>-<env>-<binding> resource name it would have got from add.
upgrade skips ejected capabilities, touches no Cloudflare account, and writes only config files, the Worker entry’s Durable Object exports, and — with --migrate — the database for --env.
--json
One line, one object. The workers array carries a plan on a dry run and an applied result otherwise, and the two shapes differ.
Every entry leads with state, and only "reconciled" carries either shape — a Worker that could not be read has no plan to report, and no empty one to be mistaken for a clean bill.
$ pithy upgrade --dry-run --json
{"command":"upgrade","env":"dev","dryRun":true,"workers":[{"state":"reconciled","worker":"board","deployedAs":"replay-board","env":"dev","perCapability":[{"name":"auth","missingBindings":[],"missingConfigKeys":[]}],"ejectedSkipped":[],"ledger":{"state":"read","pending":1,"undeclared":[]},"entitlements":{"state":"read","gates":[]},"missingVersionMetadata":false}],"manifestFaults":[]}The envelope
| Key | Type | Meaning |
|---|---|---|
command | "upgrade" | The command that produced the line |
env | string | The environment the run targeted |
dryRun | boolean | Whether --dry-run was passed. This is what says which shape workers carries |
workers | array | One entry per Worker in scope, in discovery order |
workers[].state | "reconciled", "unplanned", "unapplied" or "refused" | Whether this Worker was reconciled, could not be planned at all, was planned and failed partway through the apply, or was refused before the first write |
manifestFaults | array | Installed packages shipping a manifest that is present and unusable. Project-wide. Empty on a healthy install |
manifestFaults[].package | string | The package the manifest was read from |
manifestFaults[].reason | string | Why it could not be used — the schema’s refusal text, or the errno where the file would not open |
The three states that are not reconciled
stateDiagram-v2
direction LR
[*] --> ReadConfig
ReadConfig --> unplanned: would not read
ReadConfig --> BuildPlan
BuildPlan --> refused: a declined binding cannot be honored
BuildPlan --> reconciled: dry run, plan reported
BuildPlan --> Apply
Apply --> reconciled: applied
Apply --> unapplied: failed partway
note right of unplanned
Nothing established.
Nothing written.
end note
note right of refused
Nothing written. A declaration
to fix, not half a wiring.
Refused on a dry run too.
end note
note right of unapplied
Files were opened for writing.
The plan is what the run set out
to do, not what landed.
end note
One entry per Worker, and every other Worker still reports in full.
A Worker whose declinedBindings cannot be honored carries "refused", with the plan, the code, and the operator’s own problem and action lines. Distinct from unapplied, and the distinction is the fact you need: nothing was written, so there is no half-reconciled wiring to inspect — only a declaration to fix. It is refused on --dry-run too, because a dry run’s job is to predict the write and this is the thing that stops it.
A Worker whose plan could not be built carries "unplanned" and nothing else. Its config would not read, so nothing was established about it and nothing was written for it — and the fields a reconciled entry has would each be a claim nobody checked.
A Worker whose apply failed partway carries "unapplied" with its plan. That is a different state on purpose: this Worker’s files have been opened for writing, so its wrangler.jsonc may hold part of the plan and under --migrate its schema may have moved. The plan is what the run set out to do, not what landed — what landed is exactly what an interrupted apply cannot say.
Every other Worker still reports in full. A run that loses four Workers’ reports to a fifth one’s broken config is the report you cannot use on the day you need it. Either state exits 1, on the same standard pithy doctor holds.
workers[] when dryRun is true
| Key | Type | Meaning |
|---|---|---|
worker | string | The Worker this plan targets, as its apps/<name> directory |
deployedAs | string | The same Worker’s deployed script name |
env | string | The environment the pending-migration count was computed for |
perCapability | array | Per installed, non-ejected capability: what an upgrade would add |
perCapability[].name | string | The capability’s short name, from its manifest |
perCapability[].missingBindings | array | Required bindings absent from one or more environments’ stanzas |
…missingBindings[].env | string | The environment missing the binding — dev for the top-level stanza |
…missingBindings[].name | string | The Worker env binding name the capability requires |
…missingBindings[].type | string | d1, kv, r2, ai, vectorize, queue, ratelimit, email, secret, workflow, service or durable_object |
perCapability[].missingConfigKeys | array | Manifest config options not yet present in this capability’s registration |
…missingConfigKeys[].key | string | The option name to add |
…missingConfigKeys[].default | JSON value | The manifest default rendered as the option’s value |
…missingConfigKeys[].describe | string | The option’s rationale, rendered as the comment above it |
perCapability[].missingEntryExports | string[] | Durable Object classes this capability binds that the entry module does not export |
ejectedSkipped | string[] | Ejected capabilities, by name |
declinedBindings | object | This Worker’s declinedBindings, resolved against what it composes. The entries sit behind state, so a declaration that would not parse cannot be read as declining nothing |
declinedBindings.state | "read" or "invalid" | Whether the declaration parsed |
declinedBindings.declines[].state | "honored", "required", "undeclinable" or "unrecognized" | Whether the decline is applied, or refused because the binding is required, refused because its kind cannot be declined, or names nothing this Worker composes |
declinedBindings.declines[].reason | string | The reason written in pithy.config.ts |
declinedBindings.declines[].stillPresentIn | string[] | Environments whose stanza still carries the binding, written by an upgrade that ran before the decline. Declining stops it coming back; it never deletes what is there |
ledger | object | What env’s databases have applied against what this Worker declares. The counts sit behind state, so a sum taken over some of the databases cannot be read as a sum over all of them |
ledger.state | "read", "partial" or "unavailable" | Whether every database in scope answered, some did, or none did |
ledger.pending | integer | Unapplied migrations for env. Present on read alone |
ledger.undeclared | array | Migrations the databases have applied that this Worker no longer declares — the direction a pending count is blind to |
ledger.counted | object | The same two fields over the databases that answered. Present on partial alone, and spelled differently there on purpose: it is a sum with a known hole in it |
ledger.unreadable | array | Every database whose ledger could not be read |
entitlements | object | Whether this Worker gates a route on an entitlement while nothing it composes provides one |
entitlements.state | "read" or "unavailable" | Whether the Worker’s source tree was scanned |
entitlements.gates | string[] | The gating source files. Present on read alone, so an all-clear and an unread tree cannot be confused |
missingVersionMetadata | boolean | Whether this Worker’s wrangler.jsonc lacks the version_metadata binding |
workers[] when dryRun is false
| Key | Type | Meaning |
|---|---|---|
worker | string | The Worker this apply targeted |
deployedAs | string | The same Worker’s deployed script name |
perCapability | array | Per capability that changed. A capability with nothing added does not appear |
perCapability[].addedBindings | array | The bindings written into wrangler.jsonc, same shape as missingBindings |
perCapability[].addedConfigKeys | string[] | The config option keys inserted. Keys only, not the rendered values |
ejectedSkipped | string[] | Ejected capabilities, by name |
migrated | boolean | Whether the migration step ran. True only with --migrate |
migrations | array | The per-database migration runs when migrated; empty otherwise |
migrations[].database | string | The database name |
migrations[].binding | string | The D1 binding it resolves to |
migrations[].results[].migrationName | string | The migration’s namespaced name |
migrations[].results[].direction | "Up" or "Down" | The direction it was executed in |
migrations[].results[].status | "Success", "Error" or "NotExecuted" | NotExecuted means an earlier migration failed |
migrations[].sharedWith | string[] | The other Workers bound to this same physical D1 |
addedVersionMetadata | boolean | Whether this run added the version_metadata binding |
addedEntryExports | string[] | The Durable Object classes this run exported from the Worker’s entry |
The two shapes differ in what a run produced, never in how it names the Worker. worker and deployedAs are in both, carrying the same two strings — a key present on one side and absent on the other would mean a consumer that worked under --dry-run read undefined on the run that actually wrote something.
Errors
An environment that does not exist. Validated at the flag, before anything is read.
$ pithy upgrade --env production
"production" is not an environment name in Pithy.
Use `prod`.A --worker that names nothing. The refusal lists the Workers that do exist.
A Worker config that will not load. A pithy.config.ts that cannot be imported fails the whole run, naming the file and the import that did not resolve. That is the Worker set failing to resolve — the fan-out’s input rather than one of its contributors.
$ pithy upgrade
Could not load apps/board/pithy.config.ts.
Nothing resolves "@pithy-sh/core/src/capability/capability". Install the project's dependencies (bun install), or correct that import.--migrate on a project with no name. Resolved before any Worker is reconciled, so nothing is written.
Examples
$ pithy upgrade --dry-run
deck:
Nothing to upgrade.
tally:
Nothing to upgrade.
Dry run. Nothing written.Every Worker in scope appears, including one with nothing to do — the run covered it, and silence would read as skipped.
pithy upgrade --migrate --env staging
pithy upgrade --worker deck