sdxc
SearchCtrlK

[ The sdxc command ]

Check a project

Lint a project for release drift, missing secrets, stale Worker types, migrations a new database cannot replay, layout and wiring mistakes, and the rules you add yourself, with sdxc doctor.

Last updated 2026-10-19

sdxc doctor

sdxc doctor runs every check against the project and prints one line per check, each marked pass, warn, fail or skip, with the exact command or edit that fixes what it found:

pass  pins        Every @sdxc package is at release 2026.10.17
fail  installed   node_modules differs from package.json: @sdxc/cli 2026.9.4 is installed, package.json pins 2026.10.17. Fix: bun install
pass  remix       remix 3.0.0 is the peer release 2026.10.17 declares
pass  env         .env.example lists every secret cloudflare.config.ts declares
warn  typegen     The Worker's env types were never generated; .cloudflare/types/index.d.ts is missing. Fix: sdxc run typegen
skip  migrations  The project has no migrations
pass  layout      app/ holds only the layout's folders and nothing imports bootstrap/
warn  routes      Found 1 route no router maps: GET /pricing (routes/web.ts pricing) is mapped by no router. Fix: Map each route in its router initializer as router.map(route, lazy(() => import(…))); sdxc make controller writes and maps one
pass  models      app/models/index.ts registers every model in app/models/
skip  ui          No @sdxc/ui component is copied

A fail is something that breaks an install, a build, CI or a new database. A warn is a convention the project strays from. A skip is a check with nothing to look at, such as a project with no migrations, or one that could not reach the npm registry.

doctor changes nothing itself, and exits 1 when any check fails, so it fits a CI step or a pre-commit hook. Warnings leave the exit code at 0.

The checks

They run in this order:

CheckFails or warns whenFix
pinsfail: an @sdxc package is behind its version at the project's release; warn: one is pinned to no releasesdxc upgrade <release>
installedfail: node_modules holds an @sdxc package or remix at another version than package.json pins, or lacks onethe install command
remixfail: remix differs from the version the project's release declaressdxc upgrade <release>
envfail: .env.example leaves out a secret cloudflare.config.ts declares; warn: it lists a key no binding declaresthe line to add, or the declaration
typegenwarn: .cloudflare/types/index.d.ts is missing, or older than cloudflare.config.ts, .dev.vars or .env.examplesdxc run typegen
migrationsfail: a make.migrations chain does not replay on an empty database with double-quoted strings off; warn: an accepted error no longer occursthe migration to correct, or the entry
layoutwarn: a folder in app/ the layout does not have, or an import of bootstrap/ from app/, config/, database/, resources/ or routes/the file to move
routeswarn: a route no router maps, or a controller or action that loads with the routerthe mapping to write
modelswarn: a file in app/models/ calls createModel and app/models/index.ts does not register itthe registration to add
uiwarn: a copied component is behind the installed release; edited copies are listed for reviewsdxc ui update <names>, sdxc ui diff

The project's release is the newest date among its @sdxc pins, so pins names each package that lags behind the others. pins and remix read the npm registry; without it they report skip and every other check still runs.

env exists because CI builds .dev.vars from .env.example before it generates the Worker's types: a secret only your local .dev.vars holds types fine on your machine and fails there. migrations replays each chain the way a new database or a new tenant gets it, with SQLite's double-quoted string literals off, so a "value" meant as a string, or a rebuild that reads a column by its new name, fails here instead of in production.

routes judges each mapping by what loads with the router. A controller mapped through lazy() loads on the first request that reaches it. A controller with several actions passes when each action sits behind lazy() or is an inline handler that loads its module with a dynamic import(); an action that imports its code up front is named by its key path, as in app/initializers/router.ts maps routes.board action edit without lazy().

Accept a migration that already ran

A migration production applied long ago can fail on an empty database, and rewriting it changes nothing for the databases that ran it. List it under doctor.migrations.accept in .config/sdxc.jsonc, with the file and the column its error names:

.config/sdxc.jsonc
{
	"doctor": {
		"migrations": {
			"accept": [{ "file": "0007_rename_slug.sql", "column": "slug" }],
		},
	},
}

The replay steps past that error alone, so a different error in the same file still fails, and checks the rest of the chain. In a file split by Drizzle's --> statement-breakpoint, it skips only the statement that raised the error and runs the ones after it; any other file is one step. A passing line counts the accepted errors and lists each as a finding, and an entry whose error no longer occurs warns, so the list shrinks once the migration is fixed.

Run some of them

sdxc doctor --only pins,remix
sdxc doctor --skip ui --skip typegen

--only and --skip take check names, repeated or comma-separated, your own checks included. A name doctor does not have stops it with the list it has:

No check is named nope; sdxc doctor runs pins, installed, remix, env, typegen, migrations, layout, routes, models, ui

As JSON

--json prints the same report as one JSON document for an agent: ok is false when any check failed, and each check carries its findings one per entry, and its fix:

{
  "ok": true,
  "checks": [
    {
      "name": "routes",
      "status": "warn",
      "message": "Found 1 route no router maps",
      "findings": ["GET /pricing (routes/web.ts pricing) is mapped by no router"],
      "fix": "Map each route in its router initializer as router.map(route, lazy(() => import(…))); sdxc make controller writes and maps one"
    }
  ]
}

Add your own checks

A rule that belongs to your project, and to no other, is a module in .config/sdxc/checks/, the doctor.checks key of your configuration. Each module default-exports one check, or an array of them, made with defineCheck from @sdxc/kit/doctor:

.config/sdxc/checks/no-env-in-views.ts
import { defineCheck } from "@sdxc/kit/doctor";

export default defineCheck({
	name: "no-env-in-views",
	async run(ctx) {
		let found = [];
		for (let file of await ctx.files("resources")) {
			if ((await ctx.readFile(file))?.includes("cloudflare:workers"))
				found.push(file);
		}
		if (found.length === 0)
			return { status: "pass", message: "Views read no bindings" };
		return {
			status: "warn",
			message: "Views import env",
			findings: found,
			fix: "Pass the value in from the controller",
		};
	},
});

run returns { status, message, findings?, fix? }, with status one of pass, warn, fail or skip, and the line prints like a built-in one. Its context reads the project and changes nothing:

FieldWhat it holds
rootthe project's root folder
manifestthe parsed package.json
configthe sdxc configuration, defaults filled in
routesevery route, as sdxc routes --json lists them
readFilea file's text, or null when it is missing
existswhether a path exists
filesevery file under a folder, leaving out node_modules and dot-files

Every path these take stays inside the project. A check's name is lowercase words joined by dashes, unique among your checks, and none of the built-in names. Checks run with the project's runtime; on Node, a check written in TypeScript needs Node 22.18 or newer.

A check that fails to load, throws, returns something other than a result, shares a name, or runs longer than 30 seconds is reported as fail under its name, and every other check still runs.

In a workflow

doctor is the step after anything that moves the project:

sdxc upgrade
sdxc doctor

A pins or remix failure names the sdxc upgrade <release> that fixes it, sdxc upgrade catches up a lagging package even when no newer release is out, and a ui warning names the copies sdxc ui update brings current. In CI, run it before sdxc deploy so a missing secret or a migration a new database cannot replay stops the pipeline before production.

Where to go next

  • Upgrade — move every package to a newer release.

  • List routes — sdxc routes --unused lists what the routes check warns about, with the controller files nothing loads.

  • Install and pin — why every package in a project shares one release date.

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.