AGENTS.md@packages/cli · diff
git:20261007.3a627e4 to git:20261007.a6eb163
7 added, 5 removed. Audit A to A.
# 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,
#1550). PURE `planDevSupervisor({ isBun, argv, noHot,
sourceLocations })` 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 a
- `restartFor` filter on Bun (`bun --hot` reloads in
- place but never a module a `Bun.plugin` serves, so a
- `*.server.*` edit, and with source locations on any
- app module edit, restarts the child), or `inline`
+ `restartFor` filter on Bun (a `*.server.*` edit, with
+ source locations on any app module edit, and the root
+ boot hooks `instrumentation.*` / `env.*`), narrowed to
+ `inPlaceRestartFor` (the boot hooks only) once the
+ child announces over IPC that it reloads in place
+ (#1575, `inPlaceRestarts` in `dev-reload.js`), 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 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`, whose re-run of this CLI is handed to the first run's server (`lib/dev-hot-rerun.js`, #1575) so one server lives for the whole session, and only an `instrumentation.*` / `env.*` edit restarts it. 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