The doctor command diagnoses installer and runtime configuration and aggregates boot-time warnings.
The implementation lives in packages/devtools/src/doctor/.
node ace adonia:doctor
node ace adonia:doctor --only=shield-xsrf --only=cookie-domainThe command never boots the application. Every finding is derived from the file system and from static analysis (ts-morph) of the app’s own TypeScript — which is the point: most of what it diagnoses is a reason the app fails to boot, or boots wrong and silent.
Exit code is 1 when any check fails, 0 otherwise. Warnings never fail CI: a warn means
“suspicious, or undecidable statically” (a computed cookie.domain, a missing config file), and
a doctor that fails the build on “I could not tell” is a doctor people disable.
Checks
| id | Fails when | Spec |
|---|---|---|
init-hook |
hooks.init in adonisrc.ts has no indexAdoniaResources() (warns when it runs before indexEntities()) |
§3.1, §14 |
stale-codegen |
.adonisjs/adonia/* is missing, older than the newest app/adonia/** file, or byte-different from a fresh generation |
§14 |
panel-access-middleware |
start/kernel.ts’s router.named({ … }) lacks adonia.panel-access |
§3.7, §5.2 |
cookie-domain |
a domain-mounted panel’s host is not covered by config/session.ts’s cookie.domain |
§5.2, §19 |
vite-allowed-hosts |
vite.config.ts’s server.allowedHosts misses a panel domain |
§5.2 |
inertia-layout |
inertia/app.tsx wraps every resolved page — adonia/* included — in the app’s own layout |
§13.4 |
shield-xsrf |
Shield’s csrf.enabled or csrf.enableXsrfCookie is off |
§19 |
authorization-fallback |
authorization.fallback resolves to 'allow' in production |
§4, §12, §20 |
unscoped-resources |
tenancy is configured but a reachable resource declares no static tenantScope |
§5.3 |
action-events-table |
action events are enabled but no migration creates adonia_action_events |
§15.1 |
Notes on the sharper ones
stale-codegen compares timestamps first (cheap), then re-runs the §14 pipeline in memory and
diffs the result against what is committed. The regeneration evaluates resource modules in the
codegen worker — the same isolated evaluation the init hook performs, not an app boot. A byte
difference is always real: §14 guarantees deterministic output.
cookie-domain requires the leading dot when the cookie is scoped to a parent domain. A
panel on admin.acme.com with cookie.domain: 'acme.com' fails; '.acme.com' passes. No
domain at all is fine — a host-only cookie is returned to whatever host served it, including
every tenant host of a :tenant.acme.com pattern.
inertia-layout exists because the symptom has no error attached to it: packaged pages are
self-chroming (they render PanelShell with the panel’s brand, nav, breadcrumbs and toaster), so
an app layout on top of them shows the site header around the panel and flashes every message
twice — once as the app’s toast, once as Adonia’s. The installer applies the withAdoniaLayout
guard on a fresh app (§3 step 6); this check covers apps installed before it, apps whose entry the
installer could not recognize, and apps that grew a layout afterwards. An entry shape the analysis
cannot model warns rather than fails, and always prints the exact edit.
authorization-fallback understands both fallback: 'allow' written literally and
fallback: env.get('KEY', …) resolved against .env.production / .env.production.local.
unscoped-resources is a static preview of the boot error E_ADONIA_UNSCOPED_RESOURCE
(§5.3 fails closed). Tenancy is detected by a panel builder calling .tenant()/.tenantParam();
the manifest’s panel → resource mapping narrows the blame to resources that tenant panel
registers, falling back to every resource module when the manifest cannot answer.
Adding a check
One module per check under src/doctor/, exporting a DoctorCheck:
import type { DoctorCheck } from '@adonia/devtools/doctor'
export const myCheck: DoctorCheck = {
id: 'my-check',
title: 'Human-readable heading',
async run(context) {
const config = await context.parse('config/adonia.ts')
return config ? { status: 'ok', message: '…' } : { status: 'warn', message: '…', fix: '…' }
},
}Then add it to DOCTOR_CHECKS in src/doctor/registry.ts, and add a fixture app state under
tests/fixtures/doctor/<check-id>/{pass,fail}/ holding only the files the check reads —
which is also the proof it reads nothing else. A check that throws is reported as a failing
finding naming itself; it never crashes the command.
DoctorContext gives you the app root, the parsed adonisrc.ts, the §14 manifest (plus
manifestError when the file is present but unusable), and memoized read/parse/list/
newest/mtime accessors. Static-analysis helpers for config literals — defaultExportObject,
propertyAt, stringValue, booleanValue, arrayElements, envReference — live in
src/doctor/source.ts and return undefined for anything computed, which the caller reports as
a warn rather than a guess.