AGENTS.md@packages/cli · git:20261007.8e5ba3c · 2026-10-07 · sha256 2ed9b9098fa34f2e

AGENTS.md@packages/cli git:20261007.8e5ba3cA

Immutable. This exact content never changes and is served at /api/v1/blob/2ed9b9098fa34f2e.

# AGENTS.md for @webjsdev/cli

The webjs **command-line interface** (`webjs dev` / `start` / `test`
/ `check` / `create` / `db`) plus the scaffold templates shipped
into every new app.

Framework-wide rules (workflow, JSDoc-in-`packages/`, no-build,
commit conventions, autonomous-mode behaviour, scaffold rules) live
in the **framework root [`../../AGENTS.md`](../../AGENTS.md)** and
apply here. Read that first.

This file only covers what's specific to `@webjsdev/cli`.

## Module map

```
bin/
  webjs.js               CLI entry: argv dispatch for every command.
                         The exact-three template list (full-stack/api/saas)
                         is enforced here; --template validation rejects
                         anything else with a guidance message.
lib/
  node-preflight.js      Inline, DEPENDENCY-FREE Node-version guard (#238).
                         `checkNodeInline(current, engines)` + `nodeInlineMessage`.
                         The bin runs it BEFORE any `import @webjsdev/server`,
                         because importing the server links dev.js (Node 24+
                         builtins) and would itself link-fail on old Node. So the
                         primary guard depends only on `process.versions.node`.
                         Tests: `test/node-preflight/`.
  (MCP moved out)        The MCP server (`webjs mcp` + the `check --json` JSON
                         projector) was EXTRACTED into the standalone
                         `@webjsdev/mcp` package (#415). `webjs mcp` now
                         delegates to its `runMcpServer`, and `check --json`
                         imports `projectCheck` from `@webjsdev/mcp/check-report`
                         (so the CLI flag and the MCP `check` tool stay
                         byte-identical). `@webjsdev/mcp` is a `dependencies`
                         entry. See `packages/mcp/AGENTS.md` for the full surface
                         (tools, resources, prompts, the source tool, the docs
                         bundling). Agents register the server via
                         `npx @webjsdev/mcp`.
  doctor.js              `webjs doctor` project-health checks (#266).
                         `runDoctorChecks(appDir, opts?)` is PURE (reads files +
                         optionally the network, never exits / prints) so each
                         check is unit-testable. `opts.nodeVersion` overrides the
                         running Node, `opts.vendor` injects the pin-freshness
                         `{ hasVendorPin, findOutdated }` pair (offline tests).
                         `readDoctorPolicy(appDir)` + `applyDoctorPolicy(results,
                         gate)` are the PURE severity-gate pair (#1257, over the
                         `DOCTOR_SEVERITIES` scale); the checks stay unaware of
                         policy and the bin composes them. The bin renders +
                         owns the non-zero exit: a hard fail, or a check the app
                         gated `error`.
                         Tests: `test/cli/doctor.test.mjs`.
  port.js                Port resolution for `webjs dev` / `start` (#447).
                         Also `failFastOnPortInUse` + `PORT_IN_USE_EXIT_CODE`
                         (98, #1527): a server start that fails on a taken port
                         prints the server's message (it names the holder's PID)
                         and exits 98.
                         `loadAppEnv(appDir)` loads `<appDir>/.env` into
                         `process.env` (same guard + shell-wins semantics as the
                         server's own load); `resolvePort(portFlag, env?)` is the
                         PURE resolver with precedence `--port` > `PORT` (real env
                         or `.env`) > 8080. The bin calls `loadAppEnv` BEFORE
                         `resolvePort` so a `.env` PORT is in `process.env` at
                         resolution time; the server loads `.env` too but too late
                         to affect the port the CLI computes. Tests: `test/port/`.
  dev-supervisor.js      `webjs dev` reload-supervisor planner (#514, #1521).
                         PURE `planDevSupervisor({ isBun, argv, noHot })`
                         returns the spawn decision: `supervise` with
                         `restartOnChange: true` on Node (restart the child on a
                         change under `WATCH_DIRS` / the root `WATCH_FILES`),
                         `supervise` with `bun --hot` and `restartOnChange:
                         false` on Bun (Bun ignores the dev `?t=` cache-bust and
                         reloads in place; the parent only revives a crashed
                         child), or `inline` (run in-process) for `--no-hot`.
                         Node no longer runs under `node --watch`, which crashed
                         on an unhandled watcher error and never came back.
                         Tests: `test/dev-supervisor/`
  dev-reload.js          The supervisor runtime (#1521). `watchRestartPaths`
                         (every watcher error handled, a watched dir created or
                         removed later followed, `webjs.dev.regenerate` outputs
                         and build noise ignored), `createSupervisor` (the
                         restart state machine: 50ms debounce, SIGTERM then
                         SIGKILL after 2s, the replacement spawned on the old
                         child's exit, crash restart on the next change or after
                         a 0.5s to 10s backoff; a child exiting 98, the taken
                         port of #1527, is final: no restart, the supervisor
                         exits 98), and `superviseDevServer` (real
                         spawn with an IPC channel so an orphaned child exits,
                         signals, an uncaughtException guard that swallows only
                         `syscall: 'watch'` errors). The bin owns the
                         `__WEBJS_DEV_CHILD` re-entry. Tests:
                         `test/dev-supervisor/dev-reload.test.js`
                         (unit) + `test/bun/dev-hot-reload.mjs` (cross-runtime).
  watch-recursive.js     `watchRecursive(dir, listener, { ignore })` (#1529), the
                         recursive watch under `watchRestartPaths` AND the
                         server's `watchTree` (a byte-identical copy in
                         `@webjsdev/server` `src/dev/`, drift-tested). On Linux
                         under Node it watches each DIRECTORY non-recursively,
                         following subdirs as they appear and go: Node 24's
                         recursive watcher keeps one watch per FILE and goes
                         deaf to a file once it is replaced (`sed -i`, an
                         editor's atomic save), so a second edit never
                         restarted the server. Native recursive elsewhere (macOS,
                         Windows, Bun). Tests:
                         `test/dev-supervisor/watch-recursive.test.js`,
                         `test/bun/dev-watch-replaced.mjs` (cross-runtime).
  resolve-bin.js         Resolve a dependency's bin from the app's node_modules
                         (#570). `resolveBin(cwd, pkgName, binName)` so `webjs db`
                         / `webjs test --browser` spawn the tool with
                         `process.execPath` (the current runtime) instead of
                         `npx` (absent in a pure oven/bun image). drizzle-kit /
                         @web/test-runner block `./bin` + `./package.json` in
                         `exports`, so it resolves the `.` main entry, walks to
                         the package root, and reads the `bin` field. Tests:
                         `test/resolve-bin/`.
  app-name.js            PURE app-name validator for `webjs create <name>` (#1066).
                         `checkAppName(name)` returns `{ ok }` or `{ ok: false,
                         reason }`, `appNameErrorMessage()` renders the CLI's
                         multi-line guidance, `assertValidAppName()` throws the
                         single-line form. The rule is npm's package-name
                         rules minus the lowercase-only clause (uppercase breaks
                         nothing and the scaffold manifest is private), because
                         the name is BOTH the generated
                         package.json `name` AND a value interpolated into
                         generated source as a template literal (app/page.ts
                         `metadata.title`, the api root route handler, the
                         `{{APP_NAME}}` substitution), so a quote / backtick /
                         `${` emitted a file that failed to parse on the fresh
                         app's first boot. Validating once at the boundary beats
                         escaping at each interpolation site, since that list
                         grows every time the scaffold emits a new file. Shared
                         by `bin/webjs.js`, `scaffoldApp()`, and the
                         `create-webjs` wrapper (`npm create webjs` /
                         `bun create webjs`), so all three state one rule.
                         `toDatabaseName(name)` derives the Postgres database
                         name the scaffold writes into `.env.example`
                         (lowercased, every non-`[a-z0-9_]` character folded to
                         `_`, a leading digit `_`-prefixed, capped at
                         `DB_NAME_MAX_LENGTH` = 63): the app name itself is
                         never normalized, only that one URL segment, so the
                         emitted name is quoting-invariant and names the same
                         database through `createdb` and through an unquoted
                         `CREATE DATABASE` (#1255).
                         Tests: `test/app-name/` (the derivation table, the 63
                         cap, idempotence, the shape property over the valid
                         corpus) + `test/scaffolds/
                         scaffold-template-validation.test.js` +
                         `test/scaffolds/scaffold-integration.test.js` (the
                         emitted `.env.example` line).
  ci-config.js           PURE reader for the `webjs.ci` step list (#1471): normalizes
                         the string shorthand, validates every shape with its JSON path
                         (the boot validator never follows the schema's $ref), and
                         resolves `--only` titles. Nested-in-parallel `parallel` is a
                         problem, not honoured.
  ci-runner.js           The `webjs ci` runner (#1471): sequential steps inherit stdio,
                         parallel-group steps are captured (detached, stdin ignored,
                         resolved on `close` with a bounded grace) and replayed whole,
                         a TTY-only progress line, Rails-shaped result lines, GitHub
                         Actions log groups + annotations, the step-summary table. Pure
                         of process.exit / console / the clock (spawn, write, now,
                         timers injectable).
  check-target.js        PURE invocation-target guard for `webjs check` (#1301).
                         `findCheckTarget(cwd)` returns `{ isApp, workspaceApps }`,
                         `notAnAppMessage()` renders the stderr refusal and
                         `notAnAppJson()` the `--json` one. The predicate is the
                         presence of an `app/` DIRECTORY and nothing else, the
                         same test `check.js` already applies per-rule; a
                         `workspaces` key is not part of it, it only enriches the
                         message with the member directories that ARE apps.
                         Tests: `test/cli/check-target.test.mjs`.
  create.js              `webjs create <name>` scaffold logic. Copies
                         `templates/` into the new app, writes
                         package.json + tsconfig + Drizzle db layer,
                         template-specific app/ files, prints the
                         post-scaffold guidance for AI agents. Resolves
                         the `--runtime node|bun` axis (#541, default node,
                         bun auto-detected from the invoking PM) and
                         branches the package.json scripts /
                         lockfile + applies the
                         runtime-rewrite transforms to the copied deploy +
                         agent-config files when bun.
  api-gallery.js         Backend-features showcase for --template api:
                         JSON/HTTP endpoints under app/api/features/ (the
                         api counterpart of the UI gallery). Auth is a
                         full-stack GALLERY card now (gallery/
                         {app/features/auth,modules/auth}), not a template.
  runtime-rewrite.js     Pure transforms (#541) that DERIVE the bun-mode
                         variant of each canonical node template:
                         `bunifyProse` (npm->bun command forms in markdown),
                         `bunifyDockerfile` (a pure `oven/bun:1` base + bun
                         install + `bun --bun run start` CMD + bun -e
                         healthcheck, #595; safe since cli@0.10.20's npx-free
                         `webjs db migrate` (#570) needs no Node in the image),
                         `bunifyCompose` (compose healthcheck -> bun -e), and
                         `bunifyCi` (adds setup-bun
                         next to setup-node, bun install, plain `bun run`). Only
                         the dev/start SCRIPTS force `--bun`; the test/db/check
                         tooling stays on Node (webjs test spawns `node --test`).
                         No parallel bun template, so no drift. compose.yaml is
                         not transformed (it inherits the Dockerfile CMD). Tests:
                         `test/runtime-rewrite/`.
templates/               Verbatim files copied into every new app.
                         {{APP_NAME}} placeholder is substituted at
                         copy time. The AGENTS.md / CLAUDE.md /
                         CONVENTIONS.md / .cursorrules / .agents/rules/workflow.md
                         / .github/copilot-instructions.md / .editorconfig
                         / .env.example / .claude.json / .claude/hooks/
                         all live here. AGENTS.md ALSO gets a template-specific
                         {{PLAYBOOK}} injection: create.js substitutes the
                         full-stack or api build playbook from
                         templates/partials/agents-playbook-{fullstack,api}.md
                         (the shared meta-rules stay in AGENTS.md, so the two
                         stay single-sourced). The partials are NOT copied as
                         app files.
README.md                npm-facing package readme.
```

## Public commands (see USAGE in `bin/webjs.js`)

| Command | Implementation |
|---|---|
| `webjs dev` | Runs `startServer({ dev: true })` in a child under WebJs's own reload supervisor (#514, #1521, `lib/dev-supervisor.js` plans, `lib/dev-reload.js` runs): on Node it restarts the child on a change (fresh ESM cache, plus the dev re-import's `?t=` query); on Bun the child runs under `bun --hot` (in-place module invalidation, since Bun keys its cache by path and ignores `?t=`). On both, a watcher error (EACCES / ENOENT on one path) is a warning, never a crash, and a crashed child is restarted on the next change or after a backoff. `--no-hot` opts out and runs the server in-process on either runtime. In the parent (pre-spawn) it runs the configured dev orchestration (#550, `lib/run-tasks.js`): the `webjs.dev.before` steps (one-shot) to completion, then the `webjs.dev.parallel` watchers (e.g. the Tailwind CLI) alongside the server, torn down on exit. So a bare `webjs dev` runs the same before-steps and watchers as `npm run dev`. The scaffold ships `webjs db migrate` as both a `dev.before` and a `start.before` step (#725), so a `db:generate`'d migration is applied on the next boot in dev and prod alike with no manual `db:migrate`; `.env` is loaded before the dev before-steps (same as start), so a Postgres dev migrate sees `DATABASE_URL`. Local binaries (`drizzle-kit`, `tailwindcss`) resolve because the spawn PATH is prepended with the ancestor `node_modules/.bin` dirs, npm-style. **Before dispatching either dev or start**, a directory-relative resolve probe (`checkFrameworkResolves` from `lib/doctor.js`) checks that `@webjsdev/core` resolves from `process.cwd()`; if not (the fresh-git-worktree-without-node_modules trap, #954), it prints the cause + remedy and exits 1 instead of letting a raw `ERR_MODULE_NOT_FOUND` bubble from deep in SSR. A no-op single resolve on the happy path. |
| `webjs start` | `startServer({ dev: false })`, plain HTTP/1.1 (front a reverse proxy for TLS + HTTP/2). Shares the dev framework-resolve preflight above (#954). Both `dev` and `start` first refuse with exit 1 when the cwd has no `app/` (#1526, `refuseOutsideApp` in the bin, `findServeTarget` / `notAnAppServeMessage` in `lib/check-target.js`, the same predicate as `webjs check`'s #1301 guard): at a workspace root the message names the member apps, inside an app's subdirectory the app root. It runs before the resolve probe and every before-step, and the dev child skips it (its parent checked the same cwd). Without it a launch from a workspace root booted, said it was ready, and answered 404 for every route. Tests: `test/cli/check-target.test.mjs`, `test/e2e/dev-workspace-member.test.mjs` (hoisted bin under Node and Bun). |
| `webjs test [--server\|--browser]` | Runtime-native test runner (#570): server tests run under `node --test` on Node and `bun test` on Bun (`bun --test` is invalid), dispatched on `process.versions.bun`; browser tests run the app's resolved `@web/test-runner` (`wtr`) bin via `process.execPath` (no `npx`). |
| `webjs ci [-f\|--fail-fast] [--only <title>]... [--json] [--signoff]` | Local CI (#1471), the Rails `bin/ci` posture: runs the step list `package.json` declares under `webjs.ci.steps` (`readCiConfig` in `lib/ci-config.js`, `runCi` in `lib/ci-runner.js`). Each step is timed with a `✅` / `❌` result line, the run ends with a failure list and one total line, and the exit is 1 on any failure (130 on interrupt). A group with `parallel: N` runs N steps at once with each step's output captured and replayed whole; a group nested in it takes one slot and runs sequentially. Every child gets `CI=true`, `node_modules/.bin` on PATH, and the step's `env`; `.env` is loaded first like dev / start. The predicate is the CONFIG, not an `app/` directory, so a workspace root (this monorepo) is a legitimate target; no block is exit 1 naming the workspace members that declare one (`--json`: `{ error: { code: 'NO_CI_CONFIG' } }`), a malformed block is `INVALID_CI_CONFIG` with every problem's JSON path, an unknown `--only` title is `UNKNOWN_ONLY`. `--json` puts one document on stdout (`{ ok, seconds, interrupted, steps[] }`, failed steps carrying their captured output) and the human report on stderr. Under `GITHUB_ACTIONS` each step is a `::group::` and a failure an `::error::` annotation, and a `$GITHUB_STEP_SUMMARY` table is appended, so one cloud job running the whole list still names the layer that broke. `--signoff` runs `gh signoff` after a green run (a red run prints the do-not-merge heading). Tests: `test/cli/ci.test.mjs`, `test/bun/ci-runner.mjs` (both runtimes), `test/ci-config/`, `test/ci-runner/` |
| `webjs check [--rules] [--json]` | `checkConventions()` from `@webjsdev/server/check`. `--rules` lists the checks. `--json` emits the structured violations + a summary count as JSON (via `projectCheck` from `@webjsdev/mcp/check-report`, the same projector the MCP `check` tool uses, #415), so an agent in a loop consumes structured data instead of regex-scraping stdout; the non-zero exit on violations is preserved. Report-only: each violation carries a prose `fix` hint, but there is no `--fix` autofix flag (the rules either rewrite code or rename files, so an automatic codemod is not safe). Refuses with exit 1 in a directory that has no `app/`, naming the workspace member apps to run it in when the directory declares `workspaces` (#1301): every rule assumes ONE application, so a workspace root reports collisions no single runtime ever sees (67 false findings at this repo's root). The predicate is the `app/` directory alone, which both scaffold templates create, and `--rules` is exempt and works anywhere. Under `--json` the refusal is the SECOND shape this flag can emit, `{ error: { code: 'NOT_AN_APP', message, cwd, apps } }`, and it deliberately carries neither `violations` nor `summary`, so a consumer that ignores the exit code and reads `report.violations.length` throws instead of being told a workspace is clean. Branch on `error` before reading `violations`. Guard in `lib/check-target.js` |
| `webjs routes [--json\|--table] [--no-headers]` | Prints the route table to stdout (#975): every page (path, owner file, dynamic params) and every `route.{js,ts}` handler (path, owner file, HTTP methods). Reuses `buildRouteTable` from `@webjsdev/server` (the ONE walker, shared with `webjs types` + the dev server) and the shared `projectRoutes` projector from `@webjsdev/mcp/routes-report`, so `--json` is byte-identical to the MCP `list_routes` tool (the same split as `check --json` / `check-report.js`). Default is a grouped tree; `--table` is aligned KIND/PATH/METHODS/FILE columns and `--no-headers` drops the header row for piping. Read-only. Tests: `test/cli/routes.test.mjs` |
| `webjs elision [--json] [--verify] [--routes <paths>]` | `analyzeAppElision()` from `@webjsdev/server` (#1308). Prints the display-only elision verdict: every component module as elided or shipped (a shipped one naming the EVIDENCE that forced it, `own` / `observed` / `closure` / `render` / `import` / `unreadable`, and the module that did the forcing), every page/layout as inert / import-only / ships-whole, and every ORPHAN class, one with no registration call or a computed tag, which the scanner cannot see either way. `--json` is byte-identical to the MCP `list_elision` tool (drift-tested). `--verify` boots two `createRequestHandler` instances with `WEBJS_ELIDE` flipped, renders the app's static page corpus through both, and diffs the masked SSR bytes via the shared `maskJsSet` / `staticPageRoutes` leaf: the framework's own differential guard pointed at an arbitrary app. It FORCES the ON side on (the override wins over `webjs.elide`, so an opted-out app still gets a real pair), skips dynamic routes by name (`--routes` adds real paths), skips a nondeterministic route rather than failing it, and exits non-zero on a divergence OR on a corpus where nothing was compared. It reports how many modules elision dropped, so a pass over a corpus with nothing elidable is visibly trivial rather than mistaken for proof |
| `webjs mcp` | Delegates to `runMcpServer()` from the standalone `@webjsdev/mcp` package (#415; full surface in `packages/mcp/AGENTS.md`). A read-only MCP stdio server: INTROSPECTION (`list_routes` / `list_actions` / `list_components` / `list_elision` / `check`), KNOWLEDGE (`init` primer, `docs`, `resources`, `prompts`), and a `source` tool. The scaffold's `.claude.json` registers the server directly as `{ "command": "npx", "args": ["@webjsdev/mcp"] }` (mountable in any MCP host, e.g. Cursor `.cursor/mcp.json`); `webjs mcp` stays as a back-compat alias. STDOUT is the JSON-RPC channel (diagnostics go to stderr) |
| `webjs doctor` | `runDoctorChecks()` from `lib/doctor.js` (delegating to `lib/doctor/runner.js`). TWO elision checks share ONE `analyzeAppElision()` call so the module graph is built once per run: `ELISION_CARRIERS` (#646, the page/layout carrier advisory) and `ELISION_COMPONENTS` (#1308, the other direction, which PASSES with the elided inventory and warns ONLY on an orphan, the one shape that gets no verdict and has no escape hatch; gateable like any other code via `webjs.doctor.gate`). A project-health checklist over existing signals (Node major, tsconfig `erasableSyntaxOnly`, `.env` drift vs `.env.example`, vendor-pin freshness, the `.gitignore` keeping `.webjs/vendor/` committable (`vendor-gitignore`, moved here from `webjs check` in #461 as a warn since it is a project-config concern, not source correctness), `@webjsdev/*` version coherence (`WEBJS_VERSIONS`, which resolves each declared dep's INSTALLED version through Node's own resolver anchored at the app dir rather than reading `<appDir>/node_modules/<dep>/package.json`, so a workspace-hoisted install resolves and the check agrees with `FRAMEWORK_RESOLVE` instead of contradicting it, #1300; the resolve tries `<dep>/package.json` FIRST because a bin-only package like `@webjsdev/cli` has no main entry, and falls back through the main entry plus a bounded walk to the package root on `ERR_PACKAGE_PATH_NOT_EXPORTED` because `@webjsdev/server` locks its manifest out of its `exports` map, so neither half alone resolves all four packages. Local to `lib/doctor.js` rather than `getPackageVersion` from `@webjsdev/server`, since doctor must run when the framework does not resolve at all (#954) and that helper returns null for a bin-only package. Before the fix the check warned on every healthy workspace install, which made it ungatable), a framework-resolve probe (#954: `checkFrameworkResolves` + the exported `frameworkResolves` helper WARN when `@webjsdev/core` cannot be resolved FROM the app dir via a directory-relative `createRequire` probe, naming the fresh-git-worktree-without-node_modules cause and the fix; silent PASS when it resolves, so a healthy app is untouched), importmap coherence, git pre-commit hook, and a page/layout elision advisory (#646: `checkElisionCarriers` runs `@webjsdev/server`'s `analyzeAppElision` and WARNS, naming the first client-effecting blocker, for each page/layout that ships whole instead of being elided as a carrier; advisory-only, skipped when elision is off or there is no `app/`), a framework-link integrity check (#1442: `checkFrameworkLinks` + the exported `inspectFrameworkLink` helper WARN when the `@webjsdev/core` entry in node_modules is a symlink that DANGLES or resolves OUTSIDE the tree that PHYSICALLY owns that `node_modules`, which is what an install run inside a linked worktree leaves behind. `FRAMEWORK_RESOLVE` cannot see it, because a link into a live foreign checkout resolves perfectly and silently runs another branch's framework source. The owner rule is what keeps it correct in a linked worktree, where `node_modules` is itself a symlink at the primary's, so the owning tree is the PRIMARY and a target inside it is right rather than foreign; the link target is resolved against the directory the link physically sits in, since resolving `../../packages/core` lexically through a symlinked `node_modules` lands under the worktree and would report every healthy linked worktree as corrupted), and an unmarked-stylesheet-link advisory (#1095: `checkUnmarkedAssetLinks` scans every `app/**` route module that renders markup (`page` / `layout`, plus the always-shipped `error` / `not-found` / `forbidden` / `unauthorized` / `loading` boundaries and `global-error`, which writes its own `<head>`), skipping `_private` folders the router never routes, for a `<link rel="stylesheet">` whose href is a STATIC, quoted, root-absolute `/public/...` literal, i.e. one not wrapped in `asset()`, and WARNs with `file:line`, since that url is un-versioned and a deploy cannot bust a CDN's copy. Scoped to `rel="stylesheet"`: a cross-origin sheet must keep its exact url, `rel="icon"` is a legitimate deliberate non-mark (the website leaves its favicons bare so the SEO repo-health tests parse the hrefs literally), `rel="preload"` MUST stay unversioned or its hint could never match the request the CSS `url()` makes, and an `href=${expr}` hole is undecidable from source and is exactly the marked shape. It reads the author's SOURCE and rewrites nothing, deliberately: the automatic form was built and rejected in #1196, and this is the same authoring-time posture Rails and Remix take)). PURE checks render with a `[pass]` / `[off]` / `[warn]` / `[fail]` marker; the exit is non-zero when a HARD check fails (Node below the floor, or `erasableSyntaxOnly` missing in an existing tsconfig) OR when a check the app gated `error` reports something (#1257, see below); an ungated warn (drift / staleness) never fails the exit. Each `DoctorResult` carries a stable SCREAMING_SNAKE `code` (#975, the `DOCTOR_CODES` map; e.g. `NODE_VERSION`, `TSCONFIG_ERASABLE`, `IMPORTMAP_COHERENCE`) so an agent branches on the failure KIND, not the message text. `--json` emits `{ results, summary }` where `results` is the raw `DoctorResult[]` (each carrying a `code` + its effective `severity`) and `summary` is `{ pass, warn, fail, off, strict, ok }` (the same array-under-a-key convention as `check --json`). A REJECTED `webjs.doctor` config is the one path that adds a third key, `configErrors`, an array of `{ kind }` entries (`malformed` / `unknown-key` / `unknown-code` / `bad-severity`) with `results` empty because no check ran; `--strict` additionally fails the exit on every REMAINING warning (on top of hard failures and gated errors), so doctor can gate a fully-clean fix loop. The only network touch (pin freshness, plus the importmap-coherence live resolve) is best-effort: a fetch failure is a warn, never a hard fail. The importmap-coherence check (#450) runs `@webjsdev/server`'s `checkImportmapCoherence` IDENTICALLY over the live importmap AND the vendored `.webjs/vendor/importmap.json`, warning when a pinned package needs a newer version of another pinned package than is pinned (the #446 skew class); it reads dependency metadata from the already-installed node_modules manifests (no network of its own) and degrades to "could not verify" when a manifest is unavailable. Tests: `test/cli/doctor.test.mjs` |
| `webjs types` | `generateRouteTypes()` from `@webjsdev/server`, writes `.webjs/routes.d.ts` (typed `Route` union + per-route params, #258). Also auto-emitted at `webjs dev` startup |
| `webjs typecheck [tsc args]` | Resolves the project's own `typescript/bin/tsc` (via `createRequire` from the app cwd) and spawns it with `--noEmit`, passing extra args through. Exits non-zero on a type error (a CI gate). A clear message + non-zero exit when typescript is not installed (#265). The framework runs the standard compiler, it does not embed one |
| `webjs create <name> [--template …] [--db …] [--runtime node\|bun]` | `scaffoldApp()` from `lib/create.js`. `<name>` is validated by `lib/app-name.js` BEFORE any file is written (#1066; npm package-name rules minus the lowercase-only clause, which never protected anything and which `webjs create MyApp` relied on), at all three entries (this bin, `scaffoldApp()`, and the `create-webjs` wrapper). `--runtime bun` (or `bun create webjs`, auto-detected) emits a Bun-flavored app (#541): `dev`/`start` scripts force `bun --bun`, `bun.lock`, a pure `oven/bun:1` Dockerfile + bun-install CI, and bun-command agent docs. Orthogonal to `--template` (invariant 1 stays exactly 3 templates). |
| `webjs db <generate\|migrate\|push\|studio>` | Runs the app's resolved `drizzle-kit` bin via `process.execPath` (no codegen step; `generate` is schema-to-SQL). Resolves the bin from the app's node_modules + spawns it with the current runtime (no `npx`, #570), so it works on Node and Bun, including a Node-less `oven/bun` image. `webjs db seed` runs the app's `db/seed.server.ts` directly. |
| `webjs ui <init\|add\|list\|view\|diff\|info\|lint>` | Proxies to `@webjsdev/ui` (see "UI subcommand" below) |
| `webjs version` | Prints the installed `@webjsdev/cli` version (#975, `readCliVersion()` reads the package's own package.json). Also reachable as the top-level `webjs --version` / `-v` flag, handled at the top of `main()` before the Node preflight so it works on an old Node. Tests: `test/cli/help.test.mjs` |
| `webjs help [command]` | Bare: the full USAGE banner. `webjs help <command>` prints that command's usage line, a one-line summary, an **Options** table (each flag + a universal `-h, --help` row, matching the Remix CLI's per-command Options section), and an Examples block from the `HELP` map in `bin/webjs.js` (#975), so an agent reads the exact invocation instead of guessing flags. An unknown help topic prints an error + the banner and **exits 1** (`printCommandHelp` returns false). The `--help` / `-h` FLAG forms are equivalent and handled at the top of `main()` (before the Node preflight, so they work on an old Node): `webjs --help` / `-h` prints the banner; `webjs <command> --help` / `-h` prints that command's help and short-circuits the body. Commands that forward args to an external CLI (`HELP_FLAG_PASSTHROUGH` = `typecheck` to tsc, `db` to drizzle-kit, `ui` to `@webjsdev/ui`) are excluded so the wrapped tool's own `--help` reaches it; an unrecognised command is not intercepted either, so it hits the Unknown-command error (exit 1). Tests: `test/cli/help.test.mjs` |

**Per-check severity is CONFIG, not a flag (#1257), which is what lets CI gate doctor.** `--strict` is unusable in CI on its own, because four checks are environment-shaped and fatal under it (`GIT_HOOK` wants a local pre-commit hook a runner has no reason to have, `ENV_DRIFT` compares against a `.env` CI does not carry, `VENDOR_PIN` fetches the network, `FRAMEWORK_RESOLVE` and `FRAMEWORK_LINKS` are environment-dependent). So an app declares its policy in `package.json` under `"webjs": { "doctor": { "gate": { "<CODE>": "off" | "warn" | "error" } } }`, the same three-level scale ESLint uses and `eslint-plugin-next` ships as a rule-id-keyed map. `readDoctorPolicy(appDir)` reads it (PURE, returns `{ gate, unknownCodes, badSeverities, malformed, unknownKeys }`) and `applyDoctorPolicy(results, gate)` folds it over the results (PURE, returns a new array), both in `lib/doctor/policy.js` (re-exported by `lib/doctor.js`); the CHECKS stay policy-unaware and the bin composes the two. Four rules make it safe. **A code with no entry keeps its default** (`error` for a hard fail, `warn` otherwise), so an app with no `webjs.doctor` block produces byte-identical counts and the `failing = fail > 0 || (strict && warn > 0)` formula is untouched. **`severity` on a result is the EFFECTIVE level, not the declared one**, so a PASSING check reports `pass` even when its code is gated `error` and `results.some((r) => r.severity === 'error')` has no false positive. **A `bestEffort` result is CAPPED at `warn`**, so the four could-not-check branches (the two vendor-pin ones, the two importmap-coherence ones) can never be escalated and a jspm or npm outage cannot red the required job. **A malformed gate exits 1 naming the offender, without running the checks**, covering the wrong SHAPE (a non-object `doctor` or `gate`, or a misspelled sibling like `gates`) as well as an unknown code or severity, because any of them silently ignored would leave CI un-gated while looking gated, which is strictly worse than no gate since nobody goes looking. The JSON Schema catches these in an editor, and since #1300 it also runs at BOOT (`webjs-config-validate.js` in `@webjsdev/server`, called from `createRequestHandler`), but neither reach can be the enforcement here, and the boot one does not even apply. That check validates TOP-LEVEL keys only and never descends, so it says nothing at all about anything under `webjs.doctor`, whose schema type is `object`. Even if it did descend it would be the wrong tool: it warns where this gate must fail CLOSED, it runs in the server, which this CLI must work without (#954), and the schema can only express `propertyNames: { pattern: "^[A-Z][A-Z0-9_]*$" }`, so it cannot catch a well-shaped but wrong code such as `NODE_VERSIONS`, which `readDoctorPolicy` catches by checking the real `DOCTOR_CODES` set. Do not collapse the two. `off` is uniform and silences any code, the two hard-fail checks included, matching ESLint, where any rule can be turned off. The repo's own `conventions` CI job runs doctor over both in-repo apps, and `website` + `examples/blog` gate `UNMARKED_ASSET_LINKS` to `error`; the scaffold emits the same gate and its `ci.yml` runs `npm run doctor`. The config key rides the three-surface lockstep in `packages/server/AGENTS.md` (JSON Schema + `WebjsConfig` type + reader, plus the `KNOWN_KEYS` drift test), like `dev` / `start`. That file holds the one canonical reader inventory; do not restate it here.

## UI subcommand: proxies to `@webjsdev/ui`

`@webjsdev/ui` is a **hard dependency** of `@webjsdev/cli` (listed in
`package.json` `dependencies`), so a global `webjs` install always ships
with the AI-first component CLI out of the box, no separate install.

`webjs ui <subcmd> [args...]` is a thin dispatch into `@webjsdev/ui`'s
`run({ argv })` entry. The implementation lives in
[`bin/webjs.js`](./bin/webjs.js) under `case 'ui':`. It resolves
`@webjsdev/ui` via `createRequire` rooted at the CLI's own location first
(the hard-dep path), with a fallback to the user's `cwd` for the rare case
where the user installed `@webjsdev/ui` directly without going through
`@webjsdev/cli`.

The subcommands (`init`, `add`, `list`, `view`, `diff`, `info`, `lint`) are owned
and documented by `@webjsdev/ui`. See
[`../ui/AGENTS.md`](../ui/AGENTS.md) for the surface. The CLI does not
wrap or transform args; everything after `webjs ui` is forwarded
verbatim.

## Package-specific invariants

1. **Exactly three templates.** `full-stack` (default), `api`, `saas`.
   The `TEMPLATES` array in `bin/webjs.js` is the single source of
   truth, and `scaffoldApp()` re-validates programmatically. Hallucinated
   templates (`blog`, `todo`, `ecommerce`, …) are rejected at the CLI
   with a guidance message. The `--runtime node|bun` axis (#541) is
   ORTHOGONAL to this: it does NOT add a fourth template, it re-flavors
   any of the three. Adding a runtime is a `VALID_RUNTIMES` entry +
   transforms, never a new template.
2. **Scaffold is reference, not the final product.** The scaffold
   output ships an example `app/page.ts` ("Hello from …"), an example
   `User` Drizzle model, an example `theme-toggle` component. AI agents
   asked to build a real app MUST replace those with the actual app.
   The post-scaffold success message in `lib/create.js` prints this
   rule verbatim.
3. **Drizzle + SQLite is wired up for ALL templates.** The `db/` folder
   (`schema.server.ts`, `columns.server.ts`, `connection.server.ts`),
   `npm run db:generate` + `npm run db:migrate`, and the `webjs.dev.before` +
   `webjs.start.before` steps (`webjs db migrate`, run inside `webjs dev` /
   `webjs start`, #550/#725), so a `db:generate`'d migration applies on the
   next boot with no manual `db:migrate`.
   Apps must NEVER use JSON files for persistence. This is a project
   convention (documented in the scaffold's CONVENTIONS.md).
4. **Template files are verbatim copies** with `{{APP_NAME}}` substitution.
   When editing `templates/AGENTS.md`, `templates/CONVENTIONS.md`, or `.agents/skills/webjs/`, remember they ship
   into every scaffolded app. Write for the audience of an AI agent
   working inside a freshly-scaffolded WebJs project. `templates/AGENTS.md`
   is the one exception to "verbatim": it carries a `{{PLAYBOOK}}` marker
   that `create.js` replaces with the full-stack or api build playbook from
   `templates/partials/agents-playbook-{fullstack,api}.md`, so the api app
   never ships UI-only guidance. Keep the shared meta-rules (required
   context-gathering, strict typing, data) in `AGENTS.md` itself and only
   the template-divergent build steps in the partials.
5. **`templates/CLAUDE.md` uses Claude Code's `@import` syntax**
   (`@AGENTS.md`, `@CONVENTIONS.md`). See https://code.claude.com/docs/en/claude-md.md#import-additional-files

## Tests

Scaffold-template tests for this package live in cross-package
folders under the repo root (they need to boot scaffolded apps):
`test/scaffolds/scaffold-template-validation.test.js`,
`test/scaffolds/scaffold-integration.test.js`,
`test/scaffolds/scaffold-ui-integration.test.js`.

Convention-rule tests live in
`packages/server/test/check/check.test.js` (the rules themselves
ship from `@webjsdev/server`).

End-to-end testing of the actual scaffold output is currently
manual (`webjs create demo && cd demo && webjs dev`, since `webjs create` auto-installs).
See [`references/testing.md`](../../.agents/skills/webjs/references/testing.md)
for the overall layout.

Run `npm test` from the repo root.

---

Framework-wide rules and full API reference:

@../../AGENTS.md