pithy doctor

Report toolchain state and update status, plus — inside a project — each Worker’s config, binding and migration health.

This is the first command to run when your model of the project and the project disagree. It writes nothing.

Synopsis

pithy doctor [--worker <name>] [--offline] [--enable-notifier | --disable-notifier] [--json]

Flags

FlagMeaning
--worker <name>Check only this Worker. Default: every Worker under apps/
--offlineUse no ambient credentials and make no network call. PITHY_OFFLINE=1 says the same thing to every command
--disable-notifierTurn off the update notifier, persisted in the state file
--enable-notifierTurn it back on
--jsonMachine-readable output — one line, one object, the same exit code

What it reports

$ pithy doctor

pithy 1.2.0 (installed via bun)
Update available: 1.3.0
Run: bun update -g @pithy-sh/cli

Shell: zsh (~/.zshrc)
Alias: installed (`p.` → `pithy`)

Config dir: ~/.config/pithy
State file: ~/.config/pithy/state.json
Dev login:  ~/.config/pithy/acme/dev.json — none yet; sign-in stays magic-link only
Secrets:    ~/.config/pithy/acme/secrets.jsonc (run `pithy secrets edit`)
Ports:      ~/.config/pithy/dev-ports.json
            8787–8806  main
            8807–8826  feature/12-auth
            8827–8846  ~/code/other-app — main
            8947–8966  ~/code/old-thing — main  ← not on disk
Notifier:   enabled (PITHY_NO_UPDATE_NOTIFIER to disable)

Project: pithy.config.ts found
Project capabilities:
  @pithy-sh/core         1.2.0 ✓
  @pithy-sh/auth         1.1.8 (1.2.0 available — run `pithy upgrade`)

Project health:
  api:
    prereqs      every composed capability has its peers ✓
    config       parses against every capability schema ✓
    bindings     MEDIA_BUCKET (r2) missing from wrangler.jsonc
                 env: staging, prod
    migrations   2 pending — run: pithy migrate --env dev
    entitlements no gated route without a provider ✓
  collab: healthy ✓

Cloudflare: reachable (token active)

Project name: acme — every resource name matches

OS:      macOS 14.5
Runtime: Bun 1.2.4 (Node 22.10.0 compat)

Project health

This block is pithy upgrade’s manifest-versus-wiring comparison in read-only mode — one engine, two commands: doctor reports drift, upgrade fixes it.

It is reported per Worker, since each Worker under apps/ carries its own config and drifts independently. A healthy Worker collapses to one line, and the whole block is omitted when every Worker is healthy. Doctor exits non-zero when any Worker fails a check, so CI can gate on it — what fails an exit and what merely has not happened yet is the rule this command is the gate for. Nothing else in the CLI tells you a required binding is missing before deploy does. When something is already broken and you do not know what, diagnosing a broken project starts here.

The migrations line asks the question in both directions, and the second one is why it exists. A migration this project declares that the database has not applied is N pending, and pithy migrate is the remedy. A migration the database has applied that the project no longer declares is a different fault with a different remedy — and it is invisible to a pending count.

The bindings line covers both halves of a binding, not only the half that is config. A Durable Object needs a durable_objects.bindings entry and an export of its class from the Worker’s entry module, and wrangler refuses the deploy when the second is missing.

The locations block

Every line between Config dir: and Notifier: reports a location, not a complaint — and each one is a path nothing in your repository mentions.

Secrets: names your dev secret values at <config>/<project>/secrets.jsonc — the file pithy add mints into, pithy seed reads, and every Worker’s .dev.vars is generated from. It is not in your repository, and that is the point: nothing to gitignore, nothing git add -A can reach, nothing npm pack can carry, and nothing an rm -rf on the working copy destroys. Delete the whole clone and the secrets are still there.

The line names the command as well as the path. A path outside the checkout is one no editor’s file tree reaches and no ls in the project finds, so knowing it is not yet a way to open it — pithy secrets edit is.

Ports: names <config>/dev-ports.json, one registry for the whole machine, and lists what is in it. This checkout’s blocks come first and unqualified — which ports am I on, and on which branch. Everything under them is the machine-wide view, each block named by the checkout that holds it, which is what turns 8847, and I don’t know why into because other-app holds 8827–8846.

Ranges, never block indices: a block’s ports are base + index × size, so the index is the whole answer only while every entry is the same width, and a registry written before the width changed keeps its old entries verbatim.

← not on disk is the one line here you can act on. Pruning cannot tell a deleted checkout from a moved one — both are a missing directory — so a relocated repository has its blocks freed by the next allocation any project on the machine makes, and nothing anywhere reports that it happened. This row is taken before the sweep, and it means: you renamed that directory.

Dev login: names dev.json, the one config file that is per project rather than per machine — the opt-in that makes pithy seed mint a real session instead of leaving you a magic link. The path is reported whether or not the file exists, because where it would go is most of what anybody asking needs. No file is not a fault; a file that will not parse is, and so is one naming no user.

None of the locations block can fail the exit. A checkout that is gone from disk is information, not drift.

The Cloudflare: line

It answers can I reach the account — the bootstrap credential pair, verified against Cloudflare rather than merely read. A configured-but-broken credential fails the exit; an absent one does not, because a project that has not been provisioned yet is a legitimate state.

It prints on every run, and names the file it resolved. Which account am I about to deploy to must never require inspection, and once a machine holds cloudflare.leed.json beside cloudflare.other-co.json, the state alone does not answer it.

The line also says where the credentials came from — the file, or the environment. Naming the resolved file is not the same claim, and the difference is the whole of it: CI has no file at all and authenticates from environment variables, so a report reading from ~/.config/pithy/cloudflare.json was naming a path with nothing at it while a token from somewhere else did the work.

PITHY_OFFLINE=1 means no ambient credentials and no network call. Set it, or pass --offline, and Pithy stops reading the credential pair out of the environment and doctor reports not checked instead of probing.

It bites in the one function every command resolves credentials through, and it is inherited by a spawned pithy, wrangler or test runner — which a flag is not. --offline exists on this command because run the diagnostic without touching anything is a thing you say once, at a prompt.

Making PITHY_CONFIG_DIR imply it was considered and rejected: it conflates read config from here with do not use the environment, and CI legitimately means the first without the second. What it does not touch is the credentials file — a value written there was written deliberately; a value in the environment is the one nobody in the room remembers exporting.

Project name:

Every resource this project provisions leads with the root config’s name, and teardown recomputes those names rather than scanning for them — so one edit to name orphans everything while every command keeps exiting 0.

Doctor requires positive evidence before it says so, because <app>-<env>-<resource> is also the ordinary Cloudflare convention and a database you brought with you is not an orphan. Two states fail the exit:

  • drifted — the wiring contradicts the config wholesale: every declared name leads with one and the same other project, and the configured name appears nowhere. That is the only shape a one-string rename can leave.
  • orphaned — a database’s pithy_migrations_owner stamp proves Pithy created it under another project’s name.

Neither state ever advises deleting a resource. A single foreign name, a mix, an unset name and an unreadable wrangler.jsonc all pass — none of them establishes anything.

The settings check

Every other check asks whether something is present — the option key, the binding, the migration. None of them looks at the value, so a project stays green while fromAddress names a domain nobody onboarded and BASE_URL points at a host nothing serves.

A capability declares its own settings check on its Capability object. Two tiers, because they cost different things:

TierAsksCost
localDoes the value parse, is it the right shape, is it right for this environment — validated through the same Zod object the capability’s host Worker validates at bootFree, offline, always run
accountIs the domain a zone here, does the secret have a value, is the database thereOne Cloudflare call, on credentials this report already resolved

Both are faults. A local finding fails the exit. An account finding fails it too — mail that will not send is not a warning — but only when the account was actually reached. When it cannot be, the tier is reported as skipped with the reason and gates nothing.

A capability that declares nothing is skipped in silence, and that is not a fault.

--json

--json mirrors every block, because an agent cannot read aligned columns. One line, one object, and the same exit code.

Three rules hold across the payload.

Paths are absolute here, never tilde-abbreviated — this output is opened by a script, not recognized by a person.

A check with no project to run against is null, not an empty verdict. project, projectName, workerNames, environments, origins, workflows, devPreferences, devSecretsFile, devSecrets, secretBindings, devVarsLocal and devVars all take that shape, so nothing ever reports a name verdict for a directory that has no config.

A check that threw is neither of those. Every probe is guarded, so one that fails costs its own line rather than the report — and it says so on its own value rather than being filed under null. Several report "state":"could-not-check"; cloudflare reports "state":"probe_failed", distinct from not_checked, which is the caller having said not to look. None of these states fails the exit: a check that did not run established nothing.

And every finding carries its own detail sentence beside its fields, so an agent fixing one never has to reproduce the report’s wording from the parts.

KeyTypeMeaning
cliobjectThe installed version, the latest known, the detected installer, the state, and the command that upgrades it
shellstring or nullThe detected shell
aliasobjectstate (installed, not-installed, unknown), rcPath, and reason — the refusal’s own sentence, null on the two known states
configDirstringThe config directory
stateFilestringThe notifier state file
notifierstringenabled or disabled
offlinebooleanWhether this run refused ambient credentials and the network
projectobject or nullpresent, then either loadError or the project’s capabilities and health. null outside a project
cloudflareobjectstate, missing, tokenStatus, credentialSplit, configPath, accountName, accountMismatch, credentialSource, detail
projectNameobject or nullstate, project, misnamed, detail
workerNamesobject or nullstate, and one mismatches entry per stamp that disagrees
environmentsobject or nullstate, declared, and one drift entry per Worker-and-environment that disagrees — kind is undeclared, missing or orphaned
originsobject or nullstate, and one drift entry per Worker-and-environment whose named and served origins disagree — fault is no-origin, unserved-origin or workers-dev-open
workflowsobject or nullstate, and one drift entry per Worker-and-environment whose declaration and stanza are not the same table — declared and bound are the two sides of the comparison
devPreferencesobject or nullstate, path, user, detail — the dev.json this project resolves
devSecretsFileobject or nullpath, present, orphans — reported whether or not the file exists
devSecretsobject or nullThe dev-secrets findings. healthy is the one field to gate on — it is every fault in the block, decided by the same function the text report draws its lines from, so a fault class added later needs no consumer to be updated. mode is octal-formatted (600), because 384 is not a permission anybody recognizes
secretBindingsobject or nullstate, one missing entry per Worker-and-environment-and-binding a declared environment does not bind. null when no Worker composes secrets
settingsobject or nullstate, account (checked or skipped, with the reason), checked, one findings entry per setting that does not work, and unchecked
localDeliveryobject or nulllive — whether a message sent from this machine leaves it — the capability that answers for delivery, and a detail. Never gates the exit: the simulator is a choice
devVarsLocalobject or nullThe root .dev.vars.local classification
devVarsobject or nullThe root .dev.vars classification — names only, never a value
osstringThe operating system and its version
runtimeobjectname, version and nodeCompat — the interpreter that ran
nodestringThe Node version it emulates. Equal to runtime.version on Node, different under Bun

runtime names the interpreter actually executing. Under Bun, process.versions.node is the Node version being emulated, so reporting it alone would name a runtime that is not running — the one thing a diagnostic must not do.

Errors

Nothing here refuses except a contradiction between two flags. Doctor is a report, and what it finds is carried in the exit code rather than in a throw — a diagnostic has to work in the environment it diagnoses, so every read failure it meets is discarded into a line.

ConditionEffect
--disable-notifier and --enable-notifier togetherRefused before anything is read
A pithy.config.ts that will not loadExit 1
A Worker failing a config, binding, migration or entitlement check, or a manifest nothing can readExit 1
A Worker whose plan could not be built, or a database whose ledger could not be readExit 1. A check that did not run established nothing, and a healthy verdict around a hole is the under-report this block exists to prevent
A Cloudflare credential that is configured and broken, or a pinned account the credentials do not matchExit 1. not configured and not checked establish nothing, so neither gates
A project name that is invalid, drifted or orphanedExit 1
A Worker whose three names disagreeExit 1
A dev.json that will not parse, or that names no userExit 1. No file at all is the documented default and never gates
A capability setting that does not work, from either tierExit 1. The account tier contributes findings only when the account was reached
A settings check that could not be run, or an account that could not be reachedNever fails the exit. Reported as skipped or unchecked, never as a pass
Local delivery running on the simulatorNever fails the exit. It is a choice, and offline it is the only one
Toolchain state — the CLI version, the shell, the aliasNever fails the exit

Examples

When everything is up to date, the output is correspondingly terser:

$ pithy doctor

pithy 1.3.0 (installed via brew)
Up to date.

Shell: zsh
Alias: installed

Secrets: ~/.config/pithy/acme/secrets.jsonc (run `pithy secrets edit`)

Project: pithy.config.ts found
Project capabilities: all up to date

Cloudflare: reachable (token active)

OS:      macOS 14.5
Runtime: Bun 1.2.4 (Node 22.10.0 compat)

The Secrets: line survives into the terse report, and it is the only one that does. Every other line reports a fault, and terse is the report saying there is none; this one reports a location, and where is my dev secrets file is a question rather than a complaint — asked most often by the developer whose project is working fine.

Outside a project, the Project: line states the one fact and every other project line is omitted. The exit stays 0 when the toolchain is clean: checking the CLI version, the shell or the alias from anywhere is legitimate, and somebody doing it is asking about their toolchain rather than their project.

$ cd /tmp && pithy doctor

pithy 1.3.0 (installed via brew)
Up to date.

Shell: zsh
Alias: installed

Project: no pithy.config.ts here — run `pithy init`, or change to a project directory

Cloudflare: reachable (token active)

OS:      macOS 14.5
Runtime: Bun 1.2.4 (Node 22.10.0 compat)

The Cloudflare: check still runs there, and it is the one check that never needed a project: the credentials are account-scoped, so are my credentials right has the same answer in every directory on the machine.

ESC