pithy upgrade

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

FlagTypeDefaultMeaning
--env <env>stringdevTarget environment — drives the pending-migration count, and any --migrate run
--worker <name>string—Upgrade only this Worker. Default: every Worker under apps/
--dry-runbooleanfalseShow the plan without writing anything
--migratebooleanfalseRun pending migrations after reconciling
--jsonbooleanfalseMachine-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

KeyTypeMeaning
command"upgrade"The command that produced the line
envstringThe environment the run targeted
dryRunbooleanWhether --dry-run was passed. This is what says which shape workers carries
workersarrayOne 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
manifestFaultsarrayInstalled packages shipping a manifest that is present and unusable. Project-wide. Empty on a healthy install
manifestFaults[].packagestringThe package the manifest was read from
manifestFaults[].reasonstringWhy 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

KeyTypeMeaning
workerstringThe Worker this plan targets, as its apps/<name> directory
deployedAsstringThe same Worker’s deployed script name
envstringThe environment the pending-migration count was computed for
perCapabilityarrayPer installed, non-ejected capability: what an upgrade would add
perCapability[].namestringThe capability’s short name, from its manifest
perCapability[].missingBindingsarrayRequired bindings absent from one or more environments’ stanzas
…missingBindings[].envstringThe environment missing the binding — dev for the top-level stanza
…missingBindings[].namestringThe Worker env binding name the capability requires
…missingBindings[].typestringd1, kv, r2, ai, vectorize, queue, ratelimit, email, secret, workflow, service or durable_object
perCapability[].missingConfigKeysarrayManifest config options not yet present in this capability’s registration
…missingConfigKeys[].keystringThe option name to add
…missingConfigKeys[].defaultJSON valueThe manifest default rendered as the option’s value
…missingConfigKeys[].describestringThe option’s rationale, rendered as the comment above it
perCapability[].missingEntryExportsstring[]Durable Object classes this capability binds that the entry module does not export
ejectedSkippedstring[]Ejected capabilities, by name
declinedBindingsobjectThis 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[].reasonstringThe reason written in pithy.config.ts
declinedBindings.declines[].stillPresentInstring[]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
ledgerobjectWhat 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.pendingintegerUnapplied migrations for env. Present on read alone
ledger.undeclaredarrayMigrations the databases have applied that this Worker no longer declares — the direction a pending count is blind to
ledger.countedobjectThe 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.unreadablearrayEvery database whose ledger could not be read
entitlementsobjectWhether 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.gatesstring[]The gating source files. Present on read alone, so an all-clear and an unread tree cannot be confused
missingVersionMetadatabooleanWhether this Worker’s wrangler.jsonc lacks the version_metadata binding

workers[] when dryRun is false

KeyTypeMeaning
workerstringThe Worker this apply targeted
deployedAsstringThe same Worker’s deployed script name
perCapabilityarrayPer capability that changed. A capability with nothing added does not appear
perCapability[].addedBindingsarrayThe bindings written into wrangler.jsonc, same shape as missingBindings
perCapability[].addedConfigKeysstring[]The config option keys inserted. Keys only, not the rendered values
ejectedSkippedstring[]Ejected capabilities, by name
migratedbooleanWhether the migration step ran. True only with --migrate
migrationsarrayThe per-database migration runs when migrated; empty otherwise
migrations[].databasestringThe database name
migrations[].bindingstringThe D1 binding it resolves to
migrations[].results[].migrationNamestringThe 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[].sharedWithstring[]The other Workers bound to this same physical D1
addedVersionMetadatabooleanWhether this run added the version_metadata binding
addedEntryExportsstring[]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
ESC