From dd82fdf0ee97e8821de98fdc4c99bae5ff571fb2 Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 14 Jul 2026 10:53:03 +0530 Subject: [PATCH 1/9] refactor: extract shared routes projector into @webjsdev/mcp Pull the list_routes projection into a leaf routes-report.js module, mirroring check-report.js. The MCP list_routes tool now delegates to projectRoutes(), and the same projector is exported as `@webjsdev/mcp/routes-report` so the upcoming `webjs routes --json` command can reuse it and stay byte-identical to the MCP tool. No behaviour change: list_routes output is unchanged. --- packages/mcp/package.json | 3 ++ packages/mcp/src/mcp.js | 49 ++--------------- packages/mcp/src/routes-report.js | 90 +++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 44 deletions(-) create mode 100644 packages/mcp/src/routes-report.js diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 56017a26d..abfe71042 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -13,6 +13,9 @@ "./check-report": { "default": "./src/check-report.js" }, + "./routes-report": { + "default": "./src/routes-report.js" + }, "./package.json": "./package.json" }, "files": [ diff --git a/packages/mcp/src/mcp.js b/packages/mcp/src/mcp.js index 42481f4ad..bcf9f6515 100644 --- a/packages/mcp/src/mcp.js +++ b/packages/mcp/src/mcp.js @@ -33,6 +33,7 @@ import { getPrompt, } from './mcp-docs.js'; import { resolveFrameworkRoots, runSourceTool } from './mcp-source.js'; +import { projectRoutes } from './routes-report.js'; const PROTOCOL_VERSION = '2024-11-05'; @@ -274,22 +275,6 @@ export function extractActionConfig(src) { }; } -/** - * The literal URL path for a page/api directory: `blog/[slug]` -> `/blog/[slug]`, - * the root `.` -> `/`. Route groups `(group)` and `_private` segments drop, the - * same normalization `buildRouteTable` uses for matching. - * - * @param {string} routeDir POSIX-style, `.` for the app root. - * @returns {string} - */ -function routePathFromDir(routeDir) { - if (!routeDir || routeDir === '.') return '/'; - const segs = routeDir - .split('/') - .filter((s) => !(s.startsWith('(') && s.endsWith(')')) && !s.startsWith('_')); - return segs.length ? '/' + segs.join('/') : '/'; -} - /** * The tool runners. Each is async, takes `(appDir)`, and returns a plain * JSON-serialisable projection of an existing server data function. All are @@ -311,34 +296,10 @@ export function makeToolRunners(deps) { return { async list_routes(appDir) { const table = await buildRouteTable(appDir); - const pages = await Promise.all( - table.pages.map(async (r) => { - /** @type {{ path: string, file: string, dynamic?: boolean, params?: string[] }} */ - const out = { - path: routePathFromDir(r.routeDir), - file: relative(appDir, r.file), - }; - if (r.paramNames && r.paramNames.length) { - out.dynamic = true; - out.params = r.paramNames; - } - return out; - }), - ); - const apis = await Promise.all( - table.apis.map(async (r) => { - let methods = []; - try { - methods = extractRouteMethods(await readFile(r.file, 'utf8')); - } catch {} - return { - path: routePathFromDir(r.routeDir), - file: relative(appDir, r.file), - methods, - }; - }), - ); - return { pages, apis }; + // The projection lives in `routes-report.js` so `list_routes` and the CLI + // `webjs routes --json` stay byte-identical (#975), the same split as + // `check-report.js` for the `check` tool. + return projectRoutes(table, { appDir, readFile, extractRouteMethods }); }, async list_actions(appDir) { diff --git a/packages/mcp/src/routes-report.js b/packages/mcp/src/routes-report.js new file mode 100644 index 000000000..4bbbf2540 --- /dev/null +++ b/packages/mcp/src/routes-report.js @@ -0,0 +1,90 @@ +/** + * Shared JSON projector for the app route table (#975). + * + * `webjs routes --json` and the `webjs mcp` server's `list_routes` tool BOTH + * return the identical shape, so the projection lives here once (the same + * pattern as `check-report.js` for `check --json` / the MCP `check` tool). The + * input is the raw `RouteTable` from `buildRouteTable(appDir)` (the ONE route + * walker, reused, never re-implemented); the output is a stable, agent-friendly + * `{ pages, apis }` shape. + * + * This module is a LEAF: it imports only `node:path` and receives the two + * effectful dependencies it needs (`readFile` to read a `route.{js,ts}` file's + * source, `extractRouteMethods` to lexically pull its HTTP-verb exports) as + * injected arguments, the same dependency-injection style the MCP tool runners + * already use. That keeps it free of any `@webjsdev/mcp` main-entry import (so + * the CLI can pull just this projector without loading the MCP server) and free + * of a cycle with `mcp.js`. + * + * @module routes-report + */ + +import { relative } from 'node:path'; + +/** + * The literal URL path for a page/api directory: `blog/[slug]` -> `/blog/[slug]`, + * the root `.` -> `/`. Route groups `(group)` and `_private` segments drop, the + * same normalization `buildRouteTable` uses for matching. + * + * @param {string} routeDir POSIX-style, `.` for the app root. + * @returns {string} + */ +export function routePathFromDir(routeDir) { + if (!routeDir || routeDir === '.') return '/'; + const segs = routeDir + .split('/') + .filter((s) => !(s.startsWith('(') && s.endsWith(')')) && !s.startsWith('_')); + return segs.length ? '/' + segs.join('/') : '/'; +} + +/** + * @typedef {{ path: string, file: string, dynamic?: boolean, params?: string[] }} PageRouteReport + * @typedef {{ path: string, file: string, methods: string[] }} ApiRouteReport + * @typedef {{ pages: PageRouteReport[], apis: ApiRouteReport[] }} RoutesReport + */ + +/** + * Project a `RouteTable` into the structured `{ pages, apis }` report shared by + * `webjs routes --json` and the MCP `list_routes` tool. Pure over its inputs + * (given the same table + deps it produces the same output); the only I/O is + * the injected `readFile` reading each `route.{js,ts}` source to extract its + * methods. A `route.{js,ts}` that cannot be read degrades to an empty method + * list rather than throwing, so one unreadable file never sinks the whole + * report. + * + * @param {{ pages: any[], apis: any[] }} table the `buildRouteTable(appDir)` result + * @param {{ + * appDir: string, + * readFile: (path: string, enc: string) => Promise, + * extractRouteMethods: (src: string) => string[], + * }} deps + * @returns {Promise} + */ +export async function projectRoutes(table, { appDir, readFile, extractRouteMethods }) { + const pages = table.pages.map((r) => { + /** @type {PageRouteReport} */ + const out = { + path: routePathFromDir(r.routeDir), + file: relative(appDir, r.file), + }; + if (r.paramNames && r.paramNames.length) { + out.dynamic = true; + out.params = r.paramNames; + } + return out; + }); + const apis = await Promise.all( + table.apis.map(async (r) => { + let methods = []; + try { + methods = extractRouteMethods(await readFile(r.file, 'utf8')); + } catch {} + return { + path: routePathFromDir(r.routeDir), + file: relative(appDir, r.file), + methods, + }; + }), + ); + return { pages, apis }; +} From 84cfc97a432e82a4a46ca61cc8d724cb35c04f3c Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 14 Jul 2026 11:07:49 +0530 Subject: [PATCH 2/9] feat: add `webjs routes`, `doctor --json`/`--strict`, per-command help Three agent-facing CLI affordances borrowed from the Remix 3 CLI (#975): - \`webjs routes [--json|--table]\` prints the route table (path, owner file, methods). Reuses \`buildRouteTable\` and the shared \`projectRoutes\` projector, so \`--json\` is byte-identical to the MCP \`list_routes\` tool. Default is a grouped tree; \`--table\` is aligned columns. - \`webjs doctor --json\` emits the DoctorResult[] (each now carrying a stable SCREAMING_SNAKE \`code\` via the DOCTOR_CODES map) plus a summary, and \`--strict\` also fails the exit on warnings, so doctor can join a \`check --json\`-style agent fix loop. - \`webjs help \` prints per-command usage + an Examples block from a HELP map, so an agent sees exact flags instead of guessing. Adds \`routes\` to the prose-punctuation hook CLI allowlist so the new subcommand is recognised as a command, not brand prose. --- .claude/hooks/block-prose-punctuation.sh | 2 +- packages/cli/bin/webjs.js | 227 +++++++++++++++++++++-- packages/cli/lib/doctor.js | 44 ++++- 3 files changed, 257 insertions(+), 16 deletions(-) diff --git a/.claude/hooks/block-prose-punctuation.sh b/.claude/hooks/block-prose-punctuation.sh index 1039cb635..06a9d6a8a 100755 --- a/.claude/hooks/block-prose-punctuation.sh +++ b/.claude/hooks/block-prose-punctuation.sh @@ -264,7 +264,7 @@ fi # positives (a wrongly blocked write), the same tradeoff as the rules above: # e.g. a sentence-ending "built on webjs." is not flagged (trailing period), # and the `bin/webjs.js` "webjs commands:" usage banner may rarely trip it. -webjs_cli='create|dev|start|test|check|db|ui|doctor|types|typecheck|mcp|vendor|help|add|init|generate|migrate|push|studio|seed|pin|unpin|list|audit|outdated|update|view|diff|info|build' +webjs_cli='create|dev|start|test|check|routes|db|ui|doctor|types|typecheck|mcp|vendor|help|add|init|generate|migrate|push|studio|seed|pin|unpin|list|audit|outdated|update|view|diff|info|build' # Scan copy: drop fenced code blocks, inline code spans, and emphasis markers # so a `webjs` inside code is never considered and **webjs** still matches. diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 1d4c78482..91c990256 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -47,8 +47,10 @@ const USAGE = `webjs commands: webjs start [--port 8080] Start production server (serves source directly, no build step) webjs test [--server|--browser] Run server + browser tests webjs check [--json] Run correctness checks (--json emits structured violations) + webjs routes [--json|--table] Print the route table (path / owner file / methods). Default tree; --json matches the MCP list_routes shape webjs mcp Start the read-only MCP server (routes / actions / components / check) - webjs doctor Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision) + webjs doctor [--json] [--strict] Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision). + --json emits the structured results (with stable codes). --strict also fails the exit on warnings webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params) webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors) webjs create [--template full-stack|api|saas] [--db sqlite|postgres] [--runtime node|bun] [--no-install] Scaffold a new webjs app @@ -70,7 +72,105 @@ const USAGE = `webjs commands: --download: also downloads bundles for offline production webjs vendor unpin Remove a specific package from the pin file webjs vendor list Show pinned packages with versions and URLs - webjs help Show this help`; + webjs help [command] Show this help, or per-command usage + examples (e.g. webjs help routes)`; + +/** + * Per-command help: usage line, one-line summary, and an Examples block + * (#975). `webjs help ` renders this so an agent sees the exact flags + + * worked examples instead of guessing from the one-line USAGE row. Keyed by the + * top-level command; `db` / `ui` / `vendor` document their subcommand shape. + * @type {Record} + */ +const HELP = { + dev: { + usage: 'webjs dev [--port ] [--no-hot]', + summary: 'Start the dev server with live reload (source is the runtime, no build step).', + examples: ['webjs dev', 'webjs dev --port 3000', 'webjs dev --no-hot'], + }, + start: { + usage: 'webjs start [--port ]', + summary: 'Start the production server (serves source directly, plain HTTP/1.1).', + examples: ['webjs start', 'webjs start --port 8080', 'PORT=8080 webjs start'], + }, + test: { + usage: 'webjs test [--server] [--browser] [--watch]', + summary: 'Run the app test suites (server-side node:test and/or browser via web-test-runner).', + examples: ['webjs test', 'webjs test --server', 'webjs test --browser --watch'], + }, + check: { + usage: 'webjs check [--rules] [--json]', + summary: 'Run the correctness checks (report-only, no autofix). Exits non-zero on any violation.', + examples: ['webjs check', 'webjs check --json', 'webjs check --rules'], + }, + routes: { + usage: 'webjs routes [--json] [--table]', + summary: 'Print the route table: each page/route path, its owner file, and (for route handlers) its HTTP methods.', + examples: ['webjs routes', 'webjs routes --table', 'webjs routes --json'], + }, + doctor: { + usage: 'webjs doctor [--json] [--strict]', + summary: 'Verify project health. --json emits DoctorResult[] with stable codes; --strict fails the exit on warnings too.', + examples: ['webjs doctor', 'webjs doctor --json', 'webjs doctor --strict', 'webjs doctor --json --strict'], + }, + types: { + usage: 'webjs types', + summary: 'Generate .webjs/routes.d.ts (the typed Route href union + per-route params).', + examples: ['webjs types'], + }, + typecheck: { + usage: 'webjs typecheck [tsc args...]', + summary: "Type-check the app with the project's own tsc --noEmit. Extra args pass through.", + examples: ['webjs typecheck', 'webjs typecheck --watch'], + }, + create: { + usage: 'webjs create [--template full-stack|api|saas] [--db sqlite|postgres] [--runtime node|bun] [--no-install]', + summary: 'Scaffold a new app. Defaults: full-stack template, Drizzle + SQLite, Node runtime.', + examples: [ + 'webjs create my-app', + 'webjs create my-api --template api', + 'webjs create my-saas --template saas --db postgres', + 'webjs create my-app --runtime bun', + ], + }, + db: { + usage: 'webjs db ', + summary: 'Database tasks (wraps drizzle-kit); seed runs db/seed.server.ts.', + examples: ['webjs db generate', 'webjs db migrate', 'webjs db studio', 'webjs db seed'], + }, + ui: { + usage: 'webjs ui [names...]', + summary: 'AI-first component library CLI. Requires @webjsdev/ui installed in the project.', + examples: ['webjs ui init', 'webjs ui add button card', 'webjs ui list'], + }, + vendor: { + usage: 'webjs vendor [--from ] [--download]', + summary: 'Pin client-side npm packages into .webjs/vendor/importmap.json.', + examples: ['webjs vendor pin', 'webjs vendor pin --download', 'webjs vendor list', 'webjs vendor outdated'], + }, + mcp: { + usage: 'webjs mcp', + summary: 'Start the read-only MCP server (routes / actions / components / check + a docs/source knowledge layer).', + examples: ['webjs mcp'], + }, +}; + +/** + * Render `webjs help ` to stdout: usage, summary, and examples. Falls back + * to the full USAGE banner for an unknown command (with a short note). + * @param {string} name + */ +function printCommandHelp(name) { + const h = HELP[name]; + if (!h) { + console.log(`No per-command help for "${name}".\n`); + console.log(USAGE); + return; + } + console.log(`Usage: ${h.usage}\n`); + console.log(` ${h.summary}\n`); + console.log('Examples:'); + for (const ex of h.examples) console.log(` ${ex}`); +} /** @param {string[]} args */ function flag(args, name, def) { @@ -461,14 +561,6 @@ async function main() { // app's concern, not a broken toolchain). const { runDoctorChecks } = await import('../lib/doctor.js'); const results = await runDoctorChecks(process.cwd()); - const marker = { pass: '[pass]', warn: '[warn]', fail: '[fail]' }; - console.log('webjs doctor: project-health checklist\n'); - for (const r of results) { - console.log(` ${marker[r.status]} ${r.name}`); - console.log(` ${r.message}`); - if (r.fix && r.status !== 'pass') console.log(` Fix: ${r.fix}`); - console.log(); - } const counts = results.reduce((acc, r) => { acc[r.status] = (acc[r.status] || 0) + 1; return acc; @@ -476,15 +568,117 @@ async function main() { const pass = counts.pass || 0; const warn = counts.warn || 0; const fail = counts.fail || 0; + // `--strict` also fails the exit on warnings, so an agent can gate on a + // fully-clean toolchain (drift / staleness / pin freshness) in a fix loop, + // not just on a hard toolchain break. Default keeps warnings non-fatal. + const strict = rest.includes('--strict'); + const failing = fail > 0 || (strict && warn > 0); + + // --json emits the raw DoctorResult[] (each carries a stable `code`) plus + // a summary, so an agent consumes structured data instead of scraping the + // text. Shape mirrors `check --json`: a top-level array-bearing object + // with a `summary` count. The non-zero exit is preserved (an agent gates + // on the exit code AND parses the report). + if (rest.includes('--json')) { + console.log(JSON.stringify({ + results, + summary: { pass, warn, fail, strict, ok: !failing }, + })); + if (failing) process.exit(1); + break; + } + + const marker = { pass: '[pass]', warn: '[warn]', fail: '[fail]' }; + console.log('webjs doctor: project-health checklist\n'); + for (const r of results) { + console.log(` ${marker[r.status]} ${r.name} (${r.code})`); + console.log(` ${r.message}`); + if (r.fix && r.status !== 'pass') console.log(` Fix: ${r.fix}`); + console.log(); + } console.log(` ${pass} passed, ${warn} warning(s), ${fail} failed.`); - if (fail > 0) { - console.error( - `\nwebjs doctor: ${fail} hard check(s) failed. Fix the toolchain issue(s) above.`, - ); + if (failing) { + const reason = fail > 0 + ? `${fail} hard check(s) failed. Fix the toolchain issue(s) above.` + : `${warn} warning(s) found and --strict was set.`; + console.error(`\nwebjs doctor: ${reason}`); process.exit(1); } break; } + case 'routes': { + // Print the app route table to stdout (#975): every page (path, owner + // file, dynamic params) and every route.{js,ts} API handler (path, owner + // file, HTTP methods). Reuses the ONE route walker (`buildRouteTable`, the + // same walk that backs `webjs types` and the dev server) and the shared + // `projectRoutes` projector, so `--json` is byte-identical to the MCP + // `list_routes` tool. Read-only: no module load, no autofix. + const { buildRouteTable } = await import('@webjsdev/server'); + const { projectRoutes } = await import('@webjsdev/mcp/routes-report'); + const { extractRouteMethods } = await import('@webjsdev/mcp'); + const { readFile } = await import('node:fs/promises'); + const appDir = process.cwd(); + const table = await buildRouteTable(appDir); + const report = await projectRoutes(table, { appDir, readFile, extractRouteMethods }); + + // --json: the machine contract, identical to the MCP `list_routes` shape. + if (rest.includes('--json')) { + console.log(JSON.stringify(report)); + break; + } + + const { pages, apis } = report; + // A page is reached via GET (its server render); a route.{js,ts} exposes + // exactly its exported verbs. Present both as one path -> methods -> file + // view. Dynamic pages append their param names. + const pageMethods = 'GET'; + + // --table: flat, aligned columns (KIND / PATH / METHODS / FILE), the + // easiest shape for an agent to scan or a human to grep. + if (rest.includes('--table')) { + /** @type {Array<[string,string,string,string]>} */ + const rows = [['KIND', 'PATH', 'METHODS', 'FILE']]; + for (const p of pages) { + rows.push(['page', p.path + (p.params ? ` [${p.params.join(', ')}]` : ''), pageMethods, p.file]); + } + for (const a of apis) { + rows.push(['api', a.path, a.methods.join(', ') || '(none)', a.file]); + } + const widths = [0, 1, 2].map((c) => Math.max(...rows.map((r) => r[c].length))); + for (const r of rows) { + console.log( + `${r[0].padEnd(widths[0])} ${r[1].padEnd(widths[1])} ${r[2].padEnd(widths[2])} ${r[3]}`, + ); + } + break; + } + + // Default: a grouped tree. + console.log(`webjs routes: ${pages.length} page(s), ${apis.length} API route(s)\n`); + if (pages.length) { + console.log('Pages'); + const pathW = Math.max(...pages.map((p) => p.path.length)); + for (const p of pages) { + const params = p.params ? ` ${p.params.map((n) => `[${n}]`).join(' ')}` : ''; + console.log(` ${p.path.padEnd(pathW)} ${p.file}${params}`); + } + console.log(); + } + if (apis.length) { + console.log('API routes'); + const pathW = Math.max(...apis.map((a) => a.path.length)); + const methW = Math.max(...apis.map((a) => (a.methods.join(', ') || '(none)').length)); + for (const a of apis) { + const methods = a.methods.join(', ') || '(none)'; + console.log(` ${a.path.padEnd(pathW)} ${methods.padEnd(methW)} ${a.file}`); + } + console.log(); + } + if (!pages.length && !apis.length) { + console.log(' No routes found. Add an app/page.ts or an app/**/route.ts.'); + } + break; + } case 'types': { // Generate `.webjs/routes.d.ts` from the app's `app/` routes (#258), // narrowing the @webjsdev/core `Route` href union + per-route `params`. @@ -900,6 +1094,11 @@ Full docs: https://docs.webjs.dev`); break; } case 'help': + // `webjs help ` prints that command's usage + Examples (#975); + // a bare `webjs help` prints the full banner. + if (rest[0]) printCommandHelp(rest[0]); + else console.log(USAGE); + break; case undefined: console.log(USAGE); break; diff --git a/packages/cli/lib/doctor.js b/packages/cli/lib/doctor.js index 7f7aaa163..aa226e87e 100644 --- a/packages/cli/lib/doctor.js +++ b/packages/cli/lib/doctor.js @@ -44,9 +44,48 @@ import { checkNodeInline } from './node-preflight.js'; /** * @typedef {'pass' | 'warn' | 'fail'} DoctorStatus - * @typedef {{ name: string, status: DoctorStatus, message: string, fix?: string }} DoctorResult + * @typedef {{ name: string, code: string, status: DoctorStatus, message: string, fix?: string }} DoctorResult */ +/** + * Stable machine-readable code per check (#975), so an agent consuming + * `webjs doctor --json` branches on the failure KIND, not the human message + * text (which is free to change). The `name` stays the display identity (some + * are kebab-case, two are prose); the `code` is the durable contract, a + * SCREAMING_SNAKE_CASE constant that never changes for a given check. Attached + * centrally in `runDoctorChecks` so every check function stays focused on its + * own logic. Mirrors Remix's `DoctorFindingCode` enum (its `doctor/types.ts`). + * + * Keyed by each check's `name`. A missing entry falls back to a name-derived + * code (see `codeForName`), but every shipped check is listed here explicitly + * and a drift test asserts each result carries one of these codes. + * @type {Record} + */ +export const DOCTOR_CODES = { + 'node-version': 'NODE_VERSION', + 'tsconfig-erasable': 'TSCONFIG_ERASABLE', + 'env-drift': 'ENV_DRIFT', + 'vendor-pin': 'VENDOR_PIN', + 'vendor-gitignore': 'VENDOR_GITIGNORE', + 'webjs-versions': 'WEBJS_VERSIONS', + 'framework-resolve': 'FRAMEWORK_RESOLVE', + 'importmap-coherence': 'IMPORTMAP_COHERENCE', + 'git-hook': 'GIT_HOOK', + 'Page/layout elision (carrier hygiene)': 'ELISION_CARRIERS', + 'Static build outputs (dev.regenerate freshness)': 'STATIC_ASSET_FRESHNESS', +}; + +/** + * The stable code for a check name: the explicit `DOCTOR_CODES` entry, else a + * best-effort derivation (uppercased, non-alphanumerics collapsed to `_`) so a + * newly-added check that forgets its map entry still gets a non-empty code. + * @param {string} name + * @returns {string} + */ +export function codeForName(name) { + return DOCTOR_CODES[name] || name.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, ''); +} + /** * Read the CLI package's own `engines.node` so the required Node major lives in * one place (mirrors how `bin/webjs.js` sources it). Falls back to `>=24.0.0`. @@ -1020,5 +1059,8 @@ export async function runDoctorChecks(appDir, opts = {}) { checkElisionCarriers(appDir), checkStaticAssetFreshness(appDir), ]); + // Attach the stable machine code to every result (#975). Centralized here so + // each check function stays free of the code-contract concern. + for (const r of results) r.code = codeForName(r.name); return results; } From fa481a96af7807428d3bf846f58e2be87e7a4fe1 Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 14 Jul 2026 11:10:56 +0530 Subject: [PATCH 3/9] test: cover webjs routes, doctor --json/--strict, help, projector drift - test/cli/routes.test.mjs: the pure projectRoutes projector (shape + unreadable-file degradation) and the CLI tree/--table/--json variants, asserting --json is byte-identical to the projector. - test/cli/doctor.test.mjs: stable code on every result, --json shape, and the --strict counterfactual (a warning-only app exits 0 without --strict and 1 with it, proving --strict is what flips it). - test/cli/help.test.mjs: per-command usage + Examples, unknown-command fallback, and a drift guard that every HELP entry has an example. - packages/mcp/test/mcp.test.mjs: list_routes output equals projectRoutes over the same app, locking the shared-projector guarantee. --- packages/mcp/test/mcp.test.mjs | 21 +++++ test/cli/doctor.test.mjs | 93 +++++++++++++++++++++ test/cli/help.test.mjs | 77 +++++++++++++++++ test/cli/routes.test.mjs | 148 +++++++++++++++++++++++++++++++++ 4 files changed, 339 insertions(+) create mode 100644 test/cli/help.test.mjs create mode 100644 test/cli/routes.test.mjs diff --git a/packages/mcp/test/mcp.test.mjs b/packages/mcp/test/mcp.test.mjs index 659d34412..570869c6f 100644 --- a/packages/mcp/test/mcp.test.mjs +++ b/packages/mcp/test/mcp.test.mjs @@ -171,6 +171,27 @@ test('mcp: tools/call list_routes returns the route projection', async () => { assert.deepEqual(api.methods.sort(), ['GET', 'POST']); }); +test('mcp: list_routes output equals the shared projectRoutes (no drift with the CLI)', async () => { + // The MCP tool and `webjs routes --json` both project through + // routes-report.js, so the tool output MUST equal projectRoutes over the same + // app. This locks the shared-projector guarantee (#975): if the MCP tool ever + // stops delegating to projectRoutes, this fails. + const { projectRoutes } = await import(resolve(REPO, 'packages', 'mcp', 'src', 'routes-report.js')); + const { buildRouteTable } = await import('@webjsdev/server'); + const { readFile } = await import('node:fs/promises'); + const dir = tmpDir(); + write(dir, 'app/page.ts', `export default function P() {}\n`); + write(dir, 'app/blog/[slug]/page.ts', `export default function B() {}\n`); + write(dir, 'app/api/users/route.ts', `export async function GET() {}\nexport async function POST() {}\n`); + + const { frames } = await driveMcp(dir, [ + { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'list_routes', arguments: {} } }, + ]); + const toolOut = JSON.parse(frames[0].result.content[0].text); + const expected = await projectRoutes(await buildRouteTable(dir), { appDir: dir, readFile, extractRouteMethods }); + assert.deepEqual(toolOut, expected); +}); + test('mcp: tools/call list_actions reports file + fn + RPC endpoint', async () => { const dir = tmpDir(); write( diff --git a/test/cli/doctor.test.mjs b/test/cli/doctor.test.mjs index edf6f9dfb..ade0d3abe 100644 --- a/test/cli/doctor.test.mjs +++ b/test/cli/doctor.test.mjs @@ -544,6 +544,99 @@ test('CLI exits non-zero when a hard check fails (bad tsconfig)', () => { assert.match(r.stdout + r.stderr, /\[fail\] tsconfig-erasable/); }); +// --------------------------------------------------------------------------- +// Stable codes (#975): every result carries a machine `code`. +// --------------------------------------------------------------------------- +const { DOCTOR_CODES, codeForName } = await import(resolve(CLI_LIB_DIR, 'doctor.js')); + +test('every DoctorResult carries a stable non-empty code', async () => { + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'x' })); + const results = await runDoctorChecks(dir, baseOpts({ nodeVersion: '24.0.0' })); + const codes = new Set(Object.values(DOCTOR_CODES)); + for (const r of results) { + assert.ok(r.code && typeof r.code === 'string', `${r.name} has a code`); + assert.ok(codes.has(r.code), `${r.name} code "${r.code}" is a declared DOCTOR_CODE`); + } + // The code is stable per check name, independent of the human message. + assert.equal(byName(results, 'node-version').code, 'NODE_VERSION'); + assert.equal(byName(results, 'tsconfig-erasable').code, 'TSCONFIG_ERASABLE'); +}); + +test('codeForName falls back to a derived code for an unmapped name', () => { + assert.equal(codeForName('node-version'), 'NODE_VERSION'); + assert.equal(codeForName('Some New Check!'), 'SOME_NEW_CHECK'); +}); + +// --------------------------------------------------------------------------- +// CLI --json + --strict (#975). +// --------------------------------------------------------------------------- +function runCliArgs(cwd, args) { + return spawnSync(process.execPath, [CLI, 'doctor', ...args], { cwd, encoding: 'utf8' }); +} + +test('doctor --json emits the results (with codes) + a summary, valid JSON', () => { + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'x' })); + write(dir, 'tsconfig.json', JSON.stringify({ compilerOptions: { erasableSyntaxOnly: true } })); + const r = runCliArgs(dir, ['--json']); + assert.equal(r.status, 0, `no hard fail -> exit 0, got ${r.status}\n${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.ok(Array.isArray(out.results), 'results is an array'); + assert.ok(out.results.every((x) => x.code), 'every result carries a code'); + assert.equal(typeof out.summary.pass, 'number'); + assert.equal(out.summary.strict, false); + assert.equal(out.summary.ok, true); + // --json emits ONLY the JSON object (no human banner leaking onto stdout). + assert.doesNotMatch(r.stdout, /project-health checklist/); +}); + +test('doctor --json still exits non-zero on a hard fail', () => { + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'x' })); + write(dir, 'tsconfig.json', JSON.stringify({ compilerOptions: { strict: true } })); + const r = runCliArgs(dir, ['--json']); + assert.notEqual(r.status, 0, 'a hard fail exits non-zero even in --json mode'); + const out = JSON.parse(r.stdout); + assert.equal(out.summary.ok, false); + assert.ok(out.results.some((x) => x.code === 'TSCONFIG_ERASABLE' && x.status === 'fail')); +}); + +// The --strict counterfactual: an app with a WARN but NO hard fail exits 0 +// normally, and 1 under --strict. Proving BOTH sides is what shows --strict is +// what flips it (not some unrelated hard fail). +test('--strict flips a warning-only app from exit 0 to exit 1', () => { + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'x' })); + write(dir, 'tsconfig.json', JSON.stringify({ compilerOptions: { erasableSyntaxOnly: true } })); + // Seed a guaranteed WARN with no hard fail: an .env.example whose key is + // absent from .env is the env-drift warn. + write(dir, '.env.example', 'DATABASE_URL=\nAUTH_SECRET=\n'); + write(dir, '.env', 'DATABASE_URL=x\n'); + + const plain = runCliArgs(dir, []); + assert.equal(plain.status, 0, `without --strict a warn does NOT fail: got ${plain.status}\n${plain.stdout}`); + + const strict = runCliArgs(dir, ['--strict']); + assert.equal(strict.status, 1, 'with --strict the same warn fails the exit'); + assert.match(strict.stderr, /--strict was set/); +}); + +test('--strict with --json reports ok:false and exits 1 on a warning', () => { + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'x' })); + write(dir, 'tsconfig.json', JSON.stringify({ compilerOptions: { erasableSyntaxOnly: true } })); + write(dir, '.env.example', 'AUTH_SECRET=\n'); + write(dir, '.env', '\n'); + const r = runCliArgs(dir, ['--json', '--strict']); + assert.equal(r.status, 1, 'strict + a warn -> exit 1'); + const out = JSON.parse(r.stdout); + assert.equal(out.summary.strict, true); + assert.equal(out.summary.ok, false); + assert.ok(out.summary.warn > 0, 'a warning was present'); + assert.equal(out.summary.fail, 0, 'and it was a warn, not a hard fail'); +}); + // Regression: the doctor pin check imports hasVendorPin from @webjsdev/server on // the REAL (un-stubbed) path. If it is not re-exported, the check silently // reports "no pin file" for a pinned app and the freshness check is inert. The diff --git a/test/cli/help.test.mjs b/test/cli/help.test.mjs new file mode 100644 index 000000000..313a3989d --- /dev/null +++ b/test/cli/help.test.mjs @@ -0,0 +1,77 @@ +/** + * Tests for `webjs help ` (#975): per-command usage + Examples. + * + * A bare `webjs help` still prints the full USAGE banner; `webjs help ` + * prints that command's usage line, summary, and an Examples block. An unknown + * command falls back to the banner with a short note. Every documented command + * must expose at least one example (that is the whole point: an agent reads the + * exact invocation instead of guessing flags). + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(__dirname, '..', '..'); +const CLI = resolve(REPO, 'packages', 'cli', 'bin', 'webjs.js'); + +function help(...args) { + return spawnSync(process.execPath, [CLI, 'help', ...args], { encoding: 'utf8' }); +} + +test('bare `webjs help` prints the full USAGE banner', () => { + const r = help(); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /webjs commands:/); + assert.match(r.stdout, /webjs routes/); +}); + +test('`webjs help routes` prints usage + summary + examples', () => { + const r = help('routes'); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^Usage: webjs routes \[--json\] \[--table\]/m); + assert.match(r.stdout, /^Examples:/m); + assert.match(r.stdout, /webjs routes --json/); + assert.match(r.stdout, /webjs routes --table/); +}); + +test('`webjs help doctor` documents --json and --strict', () => { + const r = help('doctor'); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /Usage: webjs doctor \[--json\] \[--strict\]/); + assert.match(r.stdout, /webjs doctor --strict/); + assert.match(r.stdout, /webjs doctor --json/); +}); + +test('`webjs help ` falls back to the banner with a note', () => { + const r = help('bogus'); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /No per-command help for "bogus"/); + assert.match(r.stdout, /webjs commands:/); +}); + +// Drift guard: every command in the HELP map must carry a usage + at least one +// example. Reads the HELP object literal out of the CLI source so a new command +// added without an example reds this test. +test('every HELP entry has a usage line and at least one example', async () => { + const { readFile } = await import('node:fs/promises'); + const src = await readFile(CLI, 'utf8'); + const start = src.indexOf('const HELP = {'); + assert.ok(start >= 0, 'HELP map is present in the CLI'); + // Slice out the object literal up to the printCommandHelp function. + const end = src.indexOf('function printCommandHelp', start); + const block = src.slice(start, end); + // Each top-level command entry looks like ` name: {` at two-space indent. + const commands = [...block.matchAll(/^ {2}([a-z]+): \{/gm)].map((m) => m[1]); + assert.ok(commands.length >= 10, `found the HELP commands (${commands.length})`); + for (const cmd of commands) { + const r = help(cmd); + assert.match(r.stdout, /^Usage: /m, `${cmd} shows a usage line`); + assert.match(r.stdout, /^Examples:/m, `${cmd} shows an Examples block`); + // At least one example line under Examples (indented, non-empty). + const after = r.stdout.split('Examples:')[1] || ''; + assert.match(after, /\n {2}\S/, `${cmd} lists at least one example`); + } +}); diff --git a/test/cli/routes.test.mjs b/test/cli/routes.test.mjs new file mode 100644 index 000000000..9163a1a63 --- /dev/null +++ b/test/cli/routes.test.mjs @@ -0,0 +1,148 @@ +/** + * Tests for `webjs routes` (#975): the route-table printer. + * + * Two layers: + * - The PURE projector `projectRoutes(table, deps)` (the shared + * `@webjsdev/mcp/routes-report` module) against a tmp fixture app, so the + * `{ pages, apis }` shape is asserted without spawning anything. + * - The CLI integration: spawn `webjs routes` and assert the tree / --table / + * --json variants. The --json branch MUST be byte-identical to what the + * projector produces (the same projector backs the MCP `list_routes` tool), + * which is the whole point of the shared module. + */ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readFile } from 'node:fs/promises'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(__dirname, '..', '..'); +const CLI = resolve(REPO, 'packages', 'cli', 'bin', 'webjs.js'); + +const { projectRoutes } = await import( + resolve(REPO, 'packages', 'mcp', 'src', 'routes-report.js') +); +const { buildRouteTable } = await import('@webjsdev/server'); +const { extractRouteMethods } = await import( + resolve(REPO, 'packages', 'mcp', 'src', 'mcp.js') +); + +const cleanup = []; +after(() => { for (const d of cleanup) rmSync(d, { recursive: true, force: true }); }); + +/** A fresh tmp fixture dir. */ +function tmpDir() { + const dir = mkdtempSync(join(tmpdir(), 'routes-')); + cleanup.push(dir); + return dir; +} + +/** Write a file, creating parent dirs. */ +function write(dir, rel, content) { + const full = join(dir, rel); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content); +} + +/** A fixture app with a static page, a dynamic page, and an API route. */ +function fixtureApp() { + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'fx' })); + write(dir, 'app/page.ts', 'export default function P(){}\n'); + write(dir, 'app/blog/[slug]/page.ts', 'export default function B(){}\n'); + write(dir, 'app/api/users/route.ts', 'export async function GET(){}\nexport async function POST(){}\n'); + return dir; +} + +function runCli(cwd, args) { + return spawnSync(process.execPath, [CLI, 'routes', ...args], { cwd, encoding: 'utf8' }); +} + +// --------------------------------------------------------------------------- +// The pure projector. +// --------------------------------------------------------------------------- +test('projectRoutes returns pages (path/file/dynamic/params) and apis (path/file/methods)', async () => { + const dir = fixtureApp(); + const table = await buildRouteTable(dir); + const report = await projectRoutes(table, { appDir: dir, readFile, extractRouteMethods }); + + const paths = report.pages.map((p) => p.path).sort(); + assert.ok(paths.includes('/'), 'root page present'); + assert.ok(paths.includes('/blog/[slug]'), 'dynamic page present'); + + const root = report.pages.find((p) => p.path === '/'); + assert.equal(root.file, 'app/page.ts'); + assert.equal(root.dynamic, undefined, 'a static page carries no dynamic flag'); + + const dyn = report.pages.find((p) => p.path === '/blog/[slug]'); + assert.equal(dyn.dynamic, true); + assert.deepEqual(dyn.params, ['slug']); + + const api = report.apis.find((a) => a.path === '/api/users'); + assert.ok(api, 'api route present'); + assert.equal(api.file, 'app/api/users/route.ts'); + assert.deepEqual(api.methods.sort(), ['GET', 'POST']); +}); + +test('projectRoutes tolerates an unreadable route file (empty methods, no throw)', async () => { + const dir = fixtureApp(); + const table = await buildRouteTable(dir); + // A readFile that always throws simulates a race-deleted / permission-denied + // route file; the api should still appear, with an empty method list. + const report = await projectRoutes(table, { + appDir: dir, + readFile: async () => { throw new Error('ENOENT'); }, + extractRouteMethods, + }); + const api = report.apis.find((a) => a.path === '/api/users'); + assert.ok(api, 'api still listed'); + assert.deepEqual(api.methods, [], 'unreadable route degrades to no methods'); +}); + +// --------------------------------------------------------------------------- +// CLI integration. +// --------------------------------------------------------------------------- +test('webjs routes --json is byte-identical to the shared projector', async () => { + const dir = fixtureApp(); + const table = await buildRouteTable(dir); + const expected = await projectRoutes(table, { appDir: dir, readFile, extractRouteMethods }); + + const r = runCli(dir, ['--json']); + assert.equal(r.status, 0, `expected exit 0, got ${r.status}\n${r.stderr}`); + assert.deepEqual(JSON.parse(r.stdout), expected); + // And the serialized form matches too (the CLI stringifies the projector output verbatim). + assert.equal(r.stdout.trim(), JSON.stringify(expected)); +}); + +test('webjs routes (tree) lists pages and API routes with methods', () => { + const dir = fixtureApp(); + const r = runCli(dir, []); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /2 page\(s\), 1 API route\(s\)/); + assert.match(r.stdout, /\/blog\/\[slug\]/); + assert.match(r.stdout, /app\/page\.ts/); + assert.match(r.stdout, /\/api\/users/); + assert.match(r.stdout, /GET, POST/); +}); + +test('webjs routes --table prints aligned KIND/PATH/METHODS/FILE columns', () => { + const dir = fixtureApp(); + const r = runCli(dir, ['--table']); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^KIND\s+PATH\s+METHODS\s+FILE/m); + assert.match(r.stdout, /^page\s+\/\s+GET\s+app\/page\.ts/m); + assert.match(r.stdout, /^api\s+\/api\/users\s+GET, POST\s+app\/api\/users\/route\.ts/m); +}); + +test('webjs routes on an app with no routes says so', () => { + const dir = tmpDir(); + write(dir, 'package.json', JSON.stringify({ name: 'empty' })); + const r = runCli(dir, []); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /0 page\(s\), 0 API route\(s\)/); + assert.match(r.stdout, /No routes found/); +}); From 240a1b79ea3b4225a9aed53a4d24963f4cd40407 Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 14 Jul 2026 11:15:58 +0530 Subject: [PATCH 4/9] docs: document webjs routes / doctor flags / help; widen hook for HTML tags - AGENTS.md CLI reference: add webjs routes, the doctor --json/--strict flags, and webjs help [command]. - docs configuration page: new CLI-options sections for routes, doctor (--json codes + --strict), and help. - block-prose-punctuation hook: admit an HTML tag boundary (< or >) after a `webjs ` so a docs `

webjs routes

` or `webjs types` reads as a command, not brand prose, with a test + counterfactual. --- .claude/hooks/block-prose-punctuation.sh | 6 ++++-- AGENTS.md | 4 +++- docs/app/docs/configuration/page.ts | 17 +++++++++++++++++ test/hooks/block-prose-punctuation.test.mjs | 12 ++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.claude/hooks/block-prose-punctuation.sh b/.claude/hooks/block-prose-punctuation.sh index 06a9d6a8a..6e7c50b13 100755 --- a/.claude/hooks/block-prose-punctuation.sh +++ b/.claude/hooks/block-prose-punctuation.sh @@ -288,9 +288,11 @@ brand_hits=$(printf '%s\n' "$brand_scan" \ if [ -n "$brand_hits" ]; then # Drop lines whose "webjs" is a `webjs ` CLI reference. The # trailing class also admits a closing quote (" or ') so a package.json - # script value ending in a bare subcommand is a command, not brand prose. + # script value ending in a bare subcommand is a command, not brand prose, + # and an HTML tag boundary (< or >) so a docs `

webjs routes

` or + # `webjs types` reads as a command, not brand prose. offending=$(printf '%s\n' "$brand_hits" \ - | grep -vE "webjs[[:space:]]+(${webjs_cli})([[:space:]]|[.,:;)\"']|\$)" 2>/dev/null || true) + | grep -vE "webjs[[:space:]]+(${webjs_cli})([[:space:]]|[.,:;)\"'<>]|\$)" 2>/dev/null || true) if [ -n "$offending" ]; then cat >&2 <<'EOF' BLOCKED: lowercase "webjs" naming the brand in prose. diff --git a/AGENTS.md b/AGENTS.md index b5fafa9f8..c9b9df3e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -462,9 +462,11 @@ webjs dev [--port N] [--no-hot] # dev server with live reload (node --watch o webjs start [--port N] # prod server; source IS the runtime, plain HTTP/1.1 (reverse-proxy for TLS + HTTP/2). Runs webjs.start.before first (#550) webjs test [--server] [--browser] [--watch] webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop +webjs routes [--json] [--table] # print the route table (path / owner file / methods, #975). Default tree; --json is byte-identical to the MCP list_routes tool webjs mcp # read-only MCP: routes, actions (RPC hashes), components, check -webjs doctor # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory); non-zero exit on a hard fail +webjs doctor [--json] [--strict] # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory); non-zero exit on a hard fail. --json emits DoctorResult[] with stable codes; --strict also fails the exit on warnings (#975) webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258) +webjs help [command] # full usage banner, or per-command usage + Examples (e.g. webjs help routes, #975) webjs typecheck [tsc args...] # the project's own tsc --noEmit webjs create [--template api|saas] webjs db # wraps drizzle-kit (+ runs db/seed.server.ts) diff --git a/docs/app/docs/configuration/page.ts b/docs/app/docs/configuration/page.ts index 164624ebb..4423d57e7 100644 --- a/docs/app/docs/configuration/page.ts +++ b/docs/app/docs/configuration/page.ts @@ -36,6 +36,23 @@ webjs db push # drizzle-kit push webjs db studio # drizzle-kit studio webjs db seed # run db/seed.server.ts +

webjs routes

+
webjs routes            # a grouped tree of pages + route handlers
+webjs routes --table    # aligned KIND / PATH / METHODS / FILE columns
+webjs routes --json     # structured JSON (matches the MCP list_routes tool)
+

Prints the route table to stdout: every page (path, owner file, dynamic params) and every route.{js,ts} handler (path, owner file, HTTP methods). It reuses the same route walker that backs the typed-routes generator and the dev server, so it always reflects exactly what the framework will serve. The --json shape is byte-identical to the read-only MCP list_routes tool, so an agent gets the same data whether it shells out or calls the MCP.

+ +

webjs doctor

+
webjs doctor            # human-readable project-health checklist
+webjs doctor --json     # structured results (each with a stable code) + a summary
+webjs doctor --strict   # also fail the exit on warnings, not just hard failures
+

Verifies project health: the Node version floor, erasableSyntaxOnly, .env drift, vendor-pin freshness, importmap coherence, @webjsdev/* version coherence, framework resolvability, the git hook, and a page/layout elision advisory. Each result carries a stable machine code (for example NODE_VERSION, TSCONFIG_ERASABLE, IMPORTMAP_COHERENCE) so an agent branches on the failure kind, not the message text. By default the exit is non-zero only on a hard toolchain failure; --strict also fails on warnings, so it can gate a fully-clean fix loop the way webjs check --json does.

+ +

webjs help

+
webjs help              # the full command banner
+webjs help routes       # usage + a summary + an Examples block for one command
+

webjs help <command> prints that command's exact usage line, a one-line summary, and worked examples, so you (or an agent) read the real invocation instead of guessing flags.

+

tsconfig.json

Optional but recommended for editor + CI type-checking:

{
diff --git a/test/hooks/block-prose-punctuation.test.mjs b/test/hooks/block-prose-punctuation.test.mjs
index 69776ba9b..a86d62e64 100644
--- a/test/hooks/block-prose-punctuation.test.mjs
+++ b/test/hooks/block-prose-punctuation.test.mjs
@@ -115,6 +115,18 @@ test('allows a package.json script value ending in `webjs ` before a quo
   assert.equal(runContent(`command = '${B} dev'`).status, 0);
 });
 
+test('allows `webjs ` inside an HTML tag boundary (docs headings / code)', () => {
+  // The docs pages reference commands as `

webjs routes

` and + // `webjs types`, where the subcommand is immediately followed by + // a `<` (or `>`). That is a command, not brand prose, so it must pass (#975). + assert.equal(runContent(`

${B} routes

`).status, 0); + assert.equal(runContent(`${B} types`).status, 0); + assert.equal(runContent(`

${B} doctor

`).status, 0); + // Counterfactual: a real verb after the brand before a tag still blocks (the + // widening admits only known subcommands, not arbitrary words). + assert.equal(runContent(`

${B} powers

`).status, 2); +}); + test('still blocks genuine lowercase-brand prose (counterfactual for #956)', () => { // The quote widening must NOT let real brand prose through: the brand followed // by a verb is not a `ship` subcommand, so it still blocks. From 59cf93702ed7e27fd505b1a3467d20deeaaf41e8 Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 14 Jul 2026 11:25:03 +0530 Subject: [PATCH 5/9] docs: sync per-package AGENTS.md for routes/doctor/help/routes-report --- packages/cli/AGENTS.md | 4 +++- packages/mcp/AGENTS.md | 12 +++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 0c36ddecd..867cea4be 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -123,13 +123,15 @@ README.md npm-facing package readme. | `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). | | `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 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) | +| `webjs routes [--json\|--table]` | 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. Read-only. Tests: `test/cli/routes.test.mjs` | | `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` / `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`. 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, 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/`)). PURE checks render with a `[pass]` / `[warn]` / `[fail]` marker; non-zero exit iff a HARD check fails (Node below the floor, or `erasableSyntaxOnly` missing in an existing tsconfig), so CI can gate. Warns (drift / staleness) never fail the exit. 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. An onboarding/setup-verify tool, NOT a scaffold-CI hard gate. Tests: `test/cli/doctor.test.mjs` | +| `webjs doctor` | `runDoctorChecks()` from `lib/doctor.js`. 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, 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/`)). PURE checks render with a `[pass]` / `[warn]` / `[fail]` marker; non-zero exit iff a HARD check fails (Node below the floor, or `erasableSyntaxOnly` missing in an existing tsconfig), so CI can gate. Warns (drift / staleness) never fail 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 the raw `DoctorResult[]` + a summary (the same shape convention as `check --json`); `--strict` also fails the exit on warnings (not just hard failures), 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. An onboarding/setup-verify tool, NOT a scaffold-CI hard gate. 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 [--template …] [--db …] [--runtime node\|bun]` | `scaffoldApp()` from `lib/create.js`. `--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 ` | 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 ` | Proxies to `@webjsdev/ui` (see "UI subcommand" below) | +| `webjs help [command]` | Bare: the full USAGE banner. `webjs help ` prints that command's usage line, a one-line summary, 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 command falls back to the banner. Tests: `test/cli/help.test.mjs` | ## UI subcommand: proxies to `@webjsdev/ui` diff --git a/packages/mcp/AGENTS.md b/packages/mcp/AGENTS.md index fb6d736e3..095794b67 100644 --- a/packages/mcp/AGENTS.md +++ b/packages/mcp/AGENTS.md @@ -35,7 +35,8 @@ src/ reply; tools/list, tools/call, resources/*, prompts/*. INTROSPECTION tools (read-only, appDir-scoped), each projecting a @webjsdev/server function: list_routes - (buildRouteTable), list_actions (buildActionIndex + + (buildRouteTable, via the shared projectRoutes from + routes-report.js), list_actions (buildActionIndex + hashFile, now reports verb/cache/tags/invalidates per #488 and excludes reserved config exports from the callable-action list), list_components (scanComponents), @@ -60,6 +61,15 @@ src/ The shared shape returned by BOTH the MCP `check` tool and `webjs check --json` (the CLI imports it from `@webjsdev/mcp/check-report`), so the two are identical. + routes-report.js routePathFromDir(routeDir) + projectRoutes(table, + { appDir, readFile, extractRouteMethods }) -> + { pages, apis } (#975). The shared route projector + returned by BOTH the MCP `list_routes` tool and + `webjs routes --json` (the CLI imports it from + `@webjsdev/mcp/routes-report`), so the two are identical. + A leaf module: the two effectful deps are injected so it + needs no `mcp.js` import (no cycle). A drift test asserts + `list_routes` output equals `projectRoutes`. scripts/ copy-mcp-resources.js prepack: bundle the repo-root skill (references + SKILL.md) + AGENTS.md into resources/ (in `files`) so npx is self-contained. From b85c02010e2889a87297bc40cbe47ca07d9ef11d Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 14 Jul 2026 11:42:14 +0530 Subject: [PATCH 6/9] feat: support --help / -h flags (top-level banner + per-command) `webjs --help` / `-h` prints the banner, and `webjs --help` / `-h` prints that command help and short-circuits the command body, handled at the top of main() before the Node preflight so it works on an old Node too. typecheck is excluded (it forwards args to tsc, where --help means tsc own help). Previously --help fell through to "Unknown command" at the top level and was silently ignored after a subcommand. Documents the flag forms in USAGE, the AGENTS.md CLI reference, the per-package cli AGENTS.md command table, and the docs-site CLI page. --- AGENTS.md | 2 +- docs/app/docs/configuration/page.ts | 6 ++-- packages/cli/AGENTS.md | 2 +- packages/cli/bin/webjs.js | 25 ++++++++++++++--- test/cli/help.test.mjs | 43 +++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c9b9df3e3..093fc73a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -466,7 +466,7 @@ webjs routes [--json] [--table] # print the route table (path / owner file / webjs mcp # read-only MCP: routes, actions (RPC hashes), components, check webjs doctor [--json] [--strict] # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory); non-zero exit on a hard fail. --json emits DoctorResult[] with stable codes; --strict also fails the exit on warnings (#975) webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258) -webjs help [command] # full usage banner, or per-command usage + Examples (e.g. webjs help routes, #975) +webjs help [command] # full usage banner, or per-command usage + Examples (e.g. webjs help routes, #975). Flag forms: webjs --help / -h (banner), webjs --help / -h (that command). typecheck --help forwards to tsc webjs typecheck [tsc args...] # the project's own tsc --noEmit webjs create [--template api|saas] webjs db # wraps drizzle-kit (+ runs db/seed.server.ts) diff --git a/docs/app/docs/configuration/page.ts b/docs/app/docs/configuration/page.ts index 4423d57e7..a8b46a390 100644 --- a/docs/app/docs/configuration/page.ts +++ b/docs/app/docs/configuration/page.ts @@ -50,8 +50,10 @@ webjs doctor --strict # also fail the exit on warnings, not just hard failures

webjs help

webjs help              # the full command banner
-webjs help routes       # usage + a summary + an Examples block for one command
-

webjs help <command> prints that command's exact usage line, a one-line summary, and worked examples, so you (or an agent) read the real invocation instead of guessing flags.

+webjs help routes # usage + a summary + an Examples block for one command +webjs --help / -h # the banner (flag form) +webjs routes --help # one command's help (flag form)
+

webjs help <command> prints that command's exact usage line, a one-line summary, and worked examples, so you (or an agent) read the real invocation instead of guessing flags. The --help / -h flag forms are equivalent: bare at the top level for the banner, or after a command for that command's help. The one exception is webjs typecheck --help, which forwards to tsc's own help (typecheck is a thin tsc wrapper).

tsconfig.json

Optional but recommended for editor + CI type-checking:

diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 867cea4be..2a57f91d3 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -131,7 +131,7 @@ README.md npm-facing package readme. | `webjs create [--template …] [--db …] [--runtime node\|bun]` | `scaffoldApp()` from `lib/create.js`. `--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 ` | 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 ` | Proxies to `@webjsdev/ui` (see "UI subcommand" below) | -| `webjs help [command]` | Bare: the full USAGE banner. `webjs help ` prints that command's usage line, a one-line summary, 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 command falls back to the banner. Tests: `test/cli/help.test.mjs` | +| `webjs help [command]` | Bare: the full USAGE banner. `webjs help ` prints that command's usage line, a one-line summary, 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 command falls back to the banner. 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 --help` / `-h` prints that command's help and short-circuits the command body. `typecheck` is excluded from the flag intercept (it forwards args to `tsc`, where `--help` means tsc's own help). Tests: `test/cli/help.test.mjs` | ## UI subcommand: proxies to `@webjsdev/ui` diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 91c990256..98143b571 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -19,8 +19,9 @@ const [cmd, ...rest] = process.argv.slice(2); // `../lib/node-preflight.js`, which imports nothing), depending only on // `process.versions.node`. The richer `assertNodeVersion` import inside main() // stays as belt-and-suspenders for the link-ok (>= 22.13) cases. -// `help` / no-arg is exempt so a user on an old Node can still read usage. -if (cmd !== 'help' && cmd !== undefined) { +// `help` / no-arg / a `--help`|`-h` flag is exempt so a user on an old Node can +// still read usage. +if (cmd !== 'help' && cmd !== undefined && cmd !== '--help' && cmd !== '-h') { let engines = '>=24.0.0'; try { const { readFileSync } = await import('node:fs'); @@ -72,7 +73,8 @@ const USAGE = `webjs commands: --download: also downloads bundles for offline production webjs vendor unpin Remove a specific package from the pin file webjs vendor list Show pinned packages with versions and URLs - webjs help [command] Show this help, or per-command usage + examples (e.g. webjs help routes)`; + webjs help [command] Show this help, or per-command usage + examples (e.g. webjs help routes). + The flag forms work too: webjs --help / -h for this banner, webjs --help / -h for one command`; /** * Per-command help: usage line, one-line summary, and an Examples block @@ -216,7 +218,22 @@ async function startDevParallelTasks(commands, cwd) { } async function main() { - // Preflight: webjs needs Node 24+ (built-in TS strip + recursive fs.watch). + // `--help` / `-h` (#975): a top-level flag (webjs --help / -h) prints the + // banner; the same flag AFTER a subcommand (webjs routes --help) prints that + // command's help. Handled before the Node preflight so `--help` works even on + // an old Node, and before the command body so it short-circuits the command. + // `typecheck` is excluded because it forwards its args to `tsc`, where + // `--help` idiomatically means tsc's own help there. + if (cmd === '--help' || cmd === '-h') { + console.log(USAGE); + return; + } + if (cmd && cmd !== 'help' && cmd !== 'typecheck' && (rest.includes('--help') || rest.includes('-h'))) { + printCommandHelp(cmd); + return; + } + + // Node preflight: WebJs needs Node 24+ (built-in TS strip + recursive fs.watch). // Run before any subcommand so an older Node fails fast with a clear, // actionable message naming the found + required version, exiting non-zero // instead of crashing cryptically later. `help` is exempt so a user on an diff --git a/test/cli/help.test.mjs b/test/cli/help.test.mjs index 313a3989d..7305f52ed 100644 --- a/test/cli/help.test.mjs +++ b/test/cli/help.test.mjs @@ -21,6 +21,49 @@ function help(...args) { return spawnSync(process.execPath, [CLI, 'help', ...args], { encoding: 'utf8' }); } +/** Run the CLI with arbitrary argv (for the --help / -h flag forms). */ +function cli(...args) { + return spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8' }); +} + +// A stable banner fragment that does not read as brand prose. +const BANNER = /per-command usage \+ examples/; + +test('`--help` and `-h` at the top level print the banner', () => { + for (const flag of ['--help', '-h']) { + const r = cli(flag); + assert.equal(r.status, 0, `${flag}: ${r.stderr}`); + assert.match(r.stdout, BANNER, `${flag} prints the banner`); + } +}); + +test('a `--help` / `-h` flag after a command prints that command\'s help', () => { + for (const flag of ['--help', '-h']) { + const routes = cli('routes', flag); + assert.equal(routes.status, 0, `routes ${flag}: ${routes.stderr}`); + assert.match(routes.stdout, /^Usage: webjs routes /m, `routes ${flag} shows routes help`); + assert.match(routes.stdout, /^Examples:/m); + + const doctor = cli('doctor', flag); + assert.match(doctor.stdout, /^Usage: webjs doctor \[--json\] \[--strict\]/m, `doctor ${flag} shows doctor help`); + } +}); + +test('a `--help` flag short-circuits (does NOT run the command)', () => { + // A route printer would emit "N page(s)"; the help intercept must fire first, + // so `routes --help` in any directory prints help, never a route table. + const r = cli('routes', '--help'); + assert.doesNotMatch(r.stdout, /page\(s\)/, 'help short-circuits before the routes body runs'); + assert.match(r.stdout, /^Usage: /m); +}); + +test('a `--help` flag on typecheck is NOT intercepted (forwards to tsc)', () => { + // typecheck is a thin tsc wrapper, so --help idiomatically means tsc's own + // help. The intercept must skip it: our Usage line must NOT appear. + const r = cli('typecheck', '--help'); + assert.doesNotMatch(r.stdout, /^Usage: webjs typecheck /m, 'typecheck --help is not the framework help'); +}); + test('bare `webjs help` prints the full USAGE banner', () => { const r = help(); assert.equal(r.status, 0, r.stderr); From 6ca46366465d1988ec6c8d4f19ce0b092f0ba1be Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 14 Jul 2026 11:56:36 +0530 Subject: [PATCH 7/9] feat: match the Remix CLI agent surface (version, Options, exit codes) Bring the WebJs CLI to parity with (and past) the Remix 3 CLI on the agent-facing affordances, and fix three issues found while comparing: - Add `webjs version` + top-level `--version` / `-v` (Remix has this; we had no version command at all). Reads @webjsdev/cli own package.json. - Per-command help now prints an Options table (each flag + a universal -h/--help row), matching the Remix per-command Options section. - `webjs routes --no-headers` drops the --table header for clean piping. - An unknown help topic now exits 1 with an error (was a silent 0), correct for an agent gating on exit codes. - The per-command --help flag now works on an old Node too (module preflight and main() Node assertion skip a help/version request). - db and ui join typecheck in HELP_FLAG_PASSTHROUGH, so `--help` on a command wrapping an external tool reaches that tool, and an unrecognised command with --help is no longer silently intercepted. Docs (root + cli AGENTS.md, docs-site CLI page) and the prose hook allowlist updated for the new `version` subcommand. --- .claude/hooks/block-prose-punctuation.sh | 2 +- AGENTS.md | 5 +- docs/app/docs/configuration/page.ts | 16 ++- packages/cli/AGENTS.md | 5 +- packages/cli/bin/webjs.js | 169 ++++++++++++++++++----- test/cli/help.test.mjs | 48 ++++++- 6 files changed, 195 insertions(+), 50 deletions(-) diff --git a/.claude/hooks/block-prose-punctuation.sh b/.claude/hooks/block-prose-punctuation.sh index 6e7c50b13..0d22b877d 100755 --- a/.claude/hooks/block-prose-punctuation.sh +++ b/.claude/hooks/block-prose-punctuation.sh @@ -264,7 +264,7 @@ fi # positives (a wrongly blocked write), the same tradeoff as the rules above: # e.g. a sentence-ending "built on webjs." is not flagged (trailing period), # and the `bin/webjs.js` "webjs commands:" usage banner may rarely trip it. -webjs_cli='create|dev|start|test|check|routes|db|ui|doctor|types|typecheck|mcp|vendor|help|add|init|generate|migrate|push|studio|seed|pin|unpin|list|audit|outdated|update|view|diff|info|build' +webjs_cli='create|dev|start|test|check|routes|db|ui|doctor|types|typecheck|mcp|vendor|help|version|add|init|generate|migrate|push|studio|seed|pin|unpin|list|audit|outdated|update|view|diff|info|build' # Scan copy: drop fenced code blocks, inline code spans, and emphasis markers # so a `webjs` inside code is never considered and **webjs** still matches. diff --git a/AGENTS.md b/AGENTS.md index 093fc73a6..87d91d5ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -462,11 +462,12 @@ webjs dev [--port N] [--no-hot] # dev server with live reload (node --watch o webjs start [--port N] # prod server; source IS the runtime, plain HTTP/1.1 (reverse-proxy for TLS + HTTP/2). Runs webjs.start.before first (#550) webjs test [--server] [--browser] [--watch] webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop -webjs routes [--json] [--table] # print the route table (path / owner file / methods, #975). Default tree; --json is byte-identical to the MCP list_routes tool +webjs routes [--json] [--table] [--no-headers] # print the route table (path / owner file / methods, #975). Default tree; --json is byte-identical to the MCP list_routes tool; --no-headers drops the --table header for piping webjs mcp # read-only MCP: routes, actions (RPC hashes), components, check webjs doctor [--json] [--strict] # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory); non-zero exit on a hard fail. --json emits DoctorResult[] with stable codes; --strict also fails the exit on warnings (#975) webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258) -webjs help [command] # full usage banner, or per-command usage + Examples (e.g. webjs help routes, #975). Flag forms: webjs --help / -h (banner), webjs --help / -h (that command). typecheck --help forwards to tsc +webjs version # print the installed @webjsdev/cli version (also: webjs --version / -v, #975) +webjs help [command] # full usage banner, or per-command usage + Options + Examples (e.g. webjs help routes, #975). Flag forms: webjs --help / -h (banner), webjs --help / -h (that command). typecheck/db/ui --help forward to their wrapped tool; an unknown topic exits 1 webjs typecheck [tsc args...] # the project's own tsc --noEmit webjs create [--template api|saas] webjs db # wraps drizzle-kit (+ runs db/seed.server.ts) diff --git a/docs/app/docs/configuration/page.ts b/docs/app/docs/configuration/page.ts index a8b46a390..dbcb6ce3c 100644 --- a/docs/app/docs/configuration/page.ts +++ b/docs/app/docs/configuration/page.ts @@ -37,9 +37,10 @@ webjs db studio # drizzle-kit studio webjs db seed # run db/seed.server.ts

webjs routes

-
webjs routes            # a grouped tree of pages + route handlers
-webjs routes --table    # aligned KIND / PATH / METHODS / FILE columns
-webjs routes --json     # structured JSON (matches the MCP list_routes tool)
+
webjs routes                    # a grouped tree of pages + route handlers
+webjs routes --table            # aligned KIND / PATH / METHODS / FILE columns
+webjs routes --table --no-headers   # same, without the header row (pipe-friendly)
+webjs routes --json             # structured JSON (matches the MCP list_routes tool)

Prints the route table to stdout: every page (path, owner file, dynamic params) and every route.{js,ts} handler (path, owner file, HTTP methods). It reuses the same route walker that backs the typed-routes generator and the dev server, so it always reflects exactly what the framework will serve. The --json shape is byte-identical to the read-only MCP list_routes tool, so an agent gets the same data whether it shells out or calls the MCP.

webjs doctor

@@ -48,12 +49,17 @@ webjs doctor --json # structured results (each with a stable code) + a summa webjs doctor --strict # also fail the exit on warnings, not just hard failures

Verifies project health: the Node version floor, erasableSyntaxOnly, .env drift, vendor-pin freshness, importmap coherence, @webjsdev/* version coherence, framework resolvability, the git hook, and a page/layout elision advisory. Each result carries a stable machine code (for example NODE_VERSION, TSCONFIG_ERASABLE, IMPORTMAP_COHERENCE) so an agent branches on the failure kind, not the message text. By default the exit is non-zero only on a hard toolchain failure; --strict also fails on warnings, so it can gate a fully-clean fix loop the way webjs check --json does.

+

webjs version

+
webjs version           # print the installed @webjsdev/cli version
+webjs --version  /  -v  # the same, flag form
+

Prints the installed @webjsdev/cli version, so an agent can detect the toolchain version before relying on a command.

+

webjs help

webjs help              # the full command banner
-webjs help routes       # usage + a summary + an Examples block for one command
+webjs help routes       # usage + summary + an Options table + Examples for one command
 webjs --help  /  -h     # the banner (flag form)
 webjs routes --help     # one command's help (flag form)
-

webjs help <command> prints that command's exact usage line, a one-line summary, and worked examples, so you (or an agent) read the real invocation instead of guessing flags. The --help / -h flag forms are equivalent: bare at the top level for the banner, or after a command for that command's help. The one exception is webjs typecheck --help, which forwards to tsc's own help (typecheck is a thin tsc wrapper).

+

webjs help <command> prints that command's exact usage line, a one-line summary, an Options table documenting every flag, and worked examples, so you (or an agent) read the real invocation instead of guessing flags. The --help / -h flag forms are equivalent: bare at the top level for the banner, or after a command for that command's help. Commands that wrap an external tool (typecheck to tsc, db to drizzle-kit, ui to @webjsdev/ui) forward --help to that tool. An unknown help topic exits non-zero.

tsconfig.json

Optional but recommended for editor + CI type-checking:

diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 2a57f91d3..81b08e7e6 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -123,7 +123,7 @@ README.md npm-facing package readme. | `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). | | `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 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) | -| `webjs routes [--json\|--table]` | 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. Read-only. Tests: `test/cli/routes.test.mjs` | +| `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 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` / `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`. 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, 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/`)). PURE checks render with a `[pass]` / `[warn]` / `[fail]` marker; non-zero exit iff a HARD check fails (Node below the floor, or `erasableSyntaxOnly` missing in an existing tsconfig), so CI can gate. Warns (drift / staleness) never fail 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 the raw `DoctorResult[]` + a summary (the same shape convention as `check --json`); `--strict` also fails the exit on warnings (not just hard failures), 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. An onboarding/setup-verify tool, NOT a scaffold-CI hard gate. 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 | @@ -131,7 +131,8 @@ README.md npm-facing package readme. | `webjs create [--template …] [--db …] [--runtime node\|bun]` | `scaffoldApp()` from `lib/create.js`. `--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 ` | 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 ` | Proxies to `@webjsdev/ui` (see "UI subcommand" below) | -| `webjs help [command]` | Bare: the full USAGE banner. `webjs help ` prints that command's usage line, a one-line summary, 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 command falls back to the banner. 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 --help` / `-h` prints that command's help and short-circuits the command body. `typecheck` is excluded from the flag intercept (it forwards args to `tsc`, where `--help` means tsc's own help). Tests: `test/cli/help.test.mjs` | +| `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 ` 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 --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` | ## UI subcommand: proxies to `@webjsdev/ui` diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 98143b571..8424480c8 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -1,6 +1,7 @@ #!/usr/bin/env node import { resolve, join, dirname } from 'node:path'; import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { resolveBin } from '../lib/resolve-bin.js'; import { dbGenerateTtyHint } from '../lib/db-hints.js'; @@ -11,6 +12,11 @@ import { planDevSupervisor } from '../lib/dev-supervisor.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const [cmd, ...rest] = process.argv.slice(2); +// A `--help`/`-h` or `--version`/`-v` request (top-level or after a subcommand) +// must not be gated by the Node-version preflight, so it works on an old Node. +const wantsHelp = cmd === '--help' || cmd === '-h' || rest.includes('--help') || rest.includes('-h'); +const wantsVersion = cmd === '--version' || cmd === '-v' || cmd === 'version'; + // Node-version preflight (issue #238), INLINE and dependency-free. // This MUST run before any `import @webjsdev/server`: importing the server // package links `src/dev.js`, which references Node 24+ builtins, so on an old @@ -19,9 +25,9 @@ const [cmd, ...rest] = process.argv.slice(2); // `../lib/node-preflight.js`, which imports nothing), depending only on // `process.versions.node`. The richer `assertNodeVersion` import inside main() // stays as belt-and-suspenders for the link-ok (>= 22.13) cases. -// `help` / no-arg / a `--help`|`-h` flag is exempt so a user on an old Node can -// still read usage. -if (cmd !== 'help' && cmd !== undefined && cmd !== '--help' && cmd !== '-h') { +// `help` / no-arg / any `--help`|`-h` / `version`|`--version`|`-v` request is +// exempt so a user on an old Node can still read usage and the version. +if (cmd !== 'help' && cmd !== undefined && !wantsHelp && !wantsVersion) { let engines = '>=24.0.0'; try { const { readFileSync } = await import('node:fs'); @@ -48,7 +54,7 @@ const USAGE = `webjs commands: webjs start [--port 8080] Start production server (serves source directly, no build step) webjs test [--server|--browser] Run server + browser tests webjs check [--json] Run correctness checks (--json emits structured violations) - webjs routes [--json|--table] Print the route table (path / owner file / methods). Default tree; --json matches the MCP list_routes shape + webjs routes [--json|--table] [--no-headers] Print the route table (path / owner file / methods). Default tree; --json matches the MCP list_routes shape; --no-headers drops the --table header webjs mcp Start the read-only MCP server (routes / actions / components / check) webjs doctor [--json] [--strict] Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision). --json emits the structured results (with stable codes). --strict also fails the exit on warnings @@ -73,45 +79,74 @@ const USAGE = `webjs commands: --download: also downloads bundles for offline production webjs vendor unpin Remove a specific package from the pin file webjs vendor list Show pinned packages with versions and URLs + webjs version Print the installed @webjsdev/cli version (also: webjs --version / -v) webjs help [command] Show this help, or per-command usage + examples (e.g. webjs help routes). The flag forms work too: webjs --help / -h for this banner, webjs --help / -h for one command`; /** - * Per-command help: usage line, one-line summary, and an Examples block - * (#975). `webjs help ` renders this so an agent sees the exact flags + - * worked examples instead of guessing from the one-line USAGE row. Keyed by the - * top-level command; `db` / `ui` / `vendor` document their subcommand shape. - * @type {Record} + * Per-command help: usage line, one-line summary, an Options table, and an + * Examples block (#975). `webjs help ` (and `webjs --help`) renders + * this so an agent sees the exact flags + worked examples instead of guessing + * from the one-line USAGE row. Keyed by the top-level command; `db` / `ui` / + * `vendor` document their subcommand shape. Every entry that takes flags lists + * them in `options` so the flag surface is machine-readable, matching the Remix + * CLI's per-command Options section. + * @type {Record, examples: string[] }>} */ const HELP = { dev: { usage: 'webjs dev [--port ] [--no-hot]', summary: 'Start the dev server with live reload (source is the runtime, no build step).', + options: [ + { flag: '--port ', description: 'Port to listen on (else PORT, else 8080).' }, + { flag: '--no-hot', description: 'Run in-process, without the hot-reload supervisor.' }, + ], examples: ['webjs dev', 'webjs dev --port 3000', 'webjs dev --no-hot'], }, start: { usage: 'webjs start [--port ]', summary: 'Start the production server (serves source directly, plain HTTP/1.1).', + options: [ + { flag: '--port ', description: 'Port to listen on (else PORT, else 8080).' }, + ], examples: ['webjs start', 'webjs start --port 8080', 'PORT=8080 webjs start'], }, test: { usage: 'webjs test [--server] [--browser] [--watch]', summary: 'Run the app test suites (server-side node:test and/or browser via web-test-runner).', + options: [ + { flag: '--server', description: 'Run only the server-side tests.' }, + { flag: '--browser', description: 'Run only the browser tests.' }, + { flag: '--watch', description: 'Re-run on change.' }, + ], examples: ['webjs test', 'webjs test --server', 'webjs test --browser --watch'], }, check: { usage: 'webjs check [--rules] [--json]', summary: 'Run the correctness checks (report-only, no autofix). Exits non-zero on any violation.', + options: [ + { flag: '--json', description: 'Emit the structured violations + summary as JSON (agent-friendly).' }, + { flag: '--rules', description: 'List the correctness rules instead of running them.' }, + ], examples: ['webjs check', 'webjs check --json', 'webjs check --rules'], }, routes: { - usage: 'webjs routes [--json] [--table]', + usage: 'webjs routes [--json | --table] [--no-headers]', summary: 'Print the route table: each page/route path, its owner file, and (for route handlers) its HTTP methods.', - examples: ['webjs routes', 'webjs routes --table', 'webjs routes --json'], + options: [ + { flag: '--json', description: 'Emit { pages, apis } as JSON (byte-identical to the MCP list_routes tool).' }, + { flag: '--table', description: 'Flat aligned KIND / PATH / METHODS / FILE columns.' }, + { flag: '--no-headers', description: 'Omit the header row (only with --table); easier to pipe.' }, + ], + examples: ['webjs routes', 'webjs routes --table', 'webjs routes --table --no-headers', 'webjs routes --json'], }, doctor: { usage: 'webjs doctor [--json] [--strict]', - summary: 'Verify project health. --json emits DoctorResult[] with stable codes; --strict fails the exit on warnings too.', + summary: 'Verify project health. Each result carries a stable code so an agent branches on the failure kind.', + options: [ + { flag: '--json', description: 'Emit the DoctorResult[] (with stable codes) + a summary as JSON.' }, + { flag: '--strict', description: 'Also fail the exit on warnings, not just hard failures.' }, + ], examples: ['webjs doctor', 'webjs doctor --json', 'webjs doctor --strict', 'webjs doctor --json --strict'], }, types: { @@ -121,12 +156,18 @@ const HELP = { }, typecheck: { usage: 'webjs typecheck [tsc args...]', - summary: "Type-check the app with the project's own tsc --noEmit. Extra args pass through.", + summary: "Type-check the app with the project's own tsc --noEmit. Extra args pass through to tsc.", examples: ['webjs typecheck', 'webjs typecheck --watch'], }, create: { usage: 'webjs create [--template full-stack|api|saas] [--db sqlite|postgres] [--runtime node|bun] [--no-install]', summary: 'Scaffold a new app. Defaults: full-stack template, Drizzle + SQLite, Node runtime.', + options: [ + { flag: '--template ', description: 'full-stack (default), api, or saas.' }, + { flag: '--db ', description: 'sqlite (default) or postgres.' }, + { flag: '--runtime ', description: 'node (default) or bun.' }, + { flag: '--no-install', description: 'Skip the package-manager install step.' }, + ], examples: [ 'webjs create my-app', 'webjs create my-api --template api', @@ -147,6 +188,10 @@ const HELP = { vendor: { usage: 'webjs vendor [--from ] [--download]', summary: 'Pin client-side npm packages into .webjs/vendor/importmap.json.', + options: [ + { flag: '--from ', description: 'jspm (default), jsdelivr, unpkg, or skypack.' }, + { flag: '--download', description: 'Also download the bundles for offline production (with pin).' }, + ], examples: ['webjs vendor pin', 'webjs vendor pin --download', 'webjs vendor list', 'webjs vendor outdated'], }, mcp: { @@ -154,24 +199,62 @@ const HELP = { summary: 'Start the read-only MCP server (routes / actions / components / check + a docs/source knowledge layer).', examples: ['webjs mcp'], }, + version: { + usage: 'webjs version', + summary: 'Print the installed @webjsdev/cli version. Also available as webjs --version / -v.', + examples: ['webjs version', 'webjs --version'], + }, }; /** - * Render `webjs help ` to stdout: usage, summary, and examples. Falls back - * to the full USAGE banner for an unknown command (with a short note). + * Render `webjs help ` to stdout: usage, summary, an Options table (when + * the command takes flags), and Examples, in the Remix-CLI section shape. A + * universal `-h, --help` option row is appended so every command advertises it. + * Returns true if the command was found, false otherwise (so the caller can + * exit non-zero on an unknown help topic, matching the Remix CLI). * @param {string} name + * @returns {boolean} */ function printCommandHelp(name) { const h = HELP[name]; if (!h) { - console.log(`No per-command help for "${name}".\n`); - console.log(USAGE); - return; + console.error(`Unknown help topic "${name}".\n`); + console.error(USAGE); + return false; } console.log(`Usage: ${h.usage}\n`); console.log(` ${h.summary}\n`); - console.log('Examples:'); + const options = [ + ...(h.options || []), + { flag: '-h, --help', description: 'Show this help.' }, + ]; + const width = Math.max(...options.map((o) => o.flag.length)); + console.log('Options:'); + for (const o of options) console.log(` ${o.flag.padEnd(width)} ${o.description}`); + console.log('\nExamples:'); for (const ex of h.examples) console.log(` ${ex}`); + return true; +} + +/** + * Commands that forward their remaining args to an external CLI, so a trailing + * `--help` / `-h` should reach THAT tool's own help, not webjs's: `typecheck` + * (tsc), `db` (drizzle-kit), `ui` (@webjsdev/ui). They are skipped by the + * per-command help-flag intercept. + */ +const HELP_FLAG_PASSTHROUGH = new Set(['typecheck', 'db', 'ui']); + +/** + * The installed `@webjsdev/cli` version, read from this package's own + * package.json. Falls back to `0.0.0` if unreadable (never throws). + * @returns {string} + */ +function readCliVersion() { + try { + return JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version || '0.0.0'; + } catch { + return '0.0.0'; + } } /** @param {string[]} args */ @@ -218,17 +301,24 @@ async function startDevParallelTasks(commands, cwd) { } async function main() { + // `--version` / `-v` (top level): print the installed CLI version and exit. + if (cmd === '--version' || cmd === '-v') { + console.log(readCliVersion()); + return; + } // `--help` / `-h` (#975): a top-level flag (webjs --help / -h) prints the // banner; the same flag AFTER a subcommand (webjs routes --help) prints that - // command's help. Handled before the Node preflight so `--help` works even on - // an old Node, and before the command body so it short-circuits the command. - // `typecheck` is excluded because it forwards its args to `tsc`, where - // `--help` idiomatically means tsc's own help there. + // command's help and short-circuits it. Handled before the Node preflight so + // it works even on an old Node. A command that forwards its args to an + // external CLI (typecheck/db/ui, in HELP_FLAG_PASSTHROUGH) is skipped so the + // tool's own --help still reaches it; an UNRECOGNISED command is not + // intercepted either, so it falls through to the Unknown-command error + // (exit 1) rather than a silent 0. if (cmd === '--help' || cmd === '-h') { console.log(USAGE); return; } - if (cmd && cmd !== 'help' && cmd !== 'typecheck' && (rest.includes('--help') || rest.includes('-h'))) { + if (cmd && HELP[cmd] && !HELP_FLAG_PASSTHROUGH.has(cmd) && (rest.includes('--help') || rest.includes('-h'))) { printCommandHelp(cmd); return; } @@ -236,9 +326,9 @@ async function main() { // Node preflight: WebJs needs Node 24+ (built-in TS strip + recursive fs.watch). // Run before any subcommand so an older Node fails fast with a clear, // actionable message naming the found + required version, exiting non-zero - // instead of crashing cryptically later. `help` is exempt so a user on an - // old Node can still read usage. - if (cmd !== 'help' && cmd !== undefined) { + // instead of crashing cryptically later. `help` / a help or version request + // is exempt so a user on an old Node can still read usage and the version. + if (cmd !== 'help' && cmd !== undefined && !wantsHelp && !wantsVersion) { const { assertNodeVersion } = await import('@webjsdev/server'); assertNodeVersion({ onFail: 'exit' }); } @@ -651,16 +741,20 @@ async function main() { const pageMethods = 'GET'; // --table: flat, aligned columns (KIND / PATH / METHODS / FILE), the - // easiest shape for an agent to scan or a human to grep. + // easiest shape for an agent to scan or a human to grep. `--no-headers` + // drops the header row so the output pipes cleanly into awk/cut. if (rest.includes('--table')) { /** @type {Array<[string,string,string,string]>} */ - const rows = [['KIND', 'PATH', 'METHODS', 'FILE']]; + const rows = []; + if (!rest.includes('--no-headers')) rows.push(['KIND', 'PATH', 'METHODS', 'FILE']); for (const p of pages) { rows.push(['page', p.path + (p.params ? ` [${p.params.join(', ')}]` : ''), pageMethods, p.file]); } for (const a of apis) { rows.push(['api', a.path, a.methods.join(', ') || '(none)', a.file]); } + // With --no-headers and no routes there are no rows; nothing to print. + if (rows.length === 0) break; const widths = [0, 1, 2].map((c) => Math.max(...rows.map((r) => r[c].length))); for (const r of rows) { console.log( @@ -1110,11 +1204,20 @@ Full docs: https://docs.webjs.dev`); }); break; } + case 'version': + // Print the installed CLI version (#975). Also reachable as the top-level + // `webjs --version` / `-v` flag, handled at the top of main(). + console.log(readCliVersion()); + break; case 'help': - // `webjs help ` prints that command's usage + Examples (#975); - // a bare `webjs help` prints the full banner. - if (rest[0]) printCommandHelp(rest[0]); - else console.log(USAGE); + // `webjs help ` prints that command's usage + Options + Examples + // (#975); a bare `webjs help` prints the full banner. An unknown topic + // exits non-zero (printCommandHelp returns false), matching the Remix CLI. + if (rest[0]) { + if (!printCommandHelp(rest[0])) process.exit(1); + } else { + console.log(USAGE); + } break; case undefined: console.log(USAGE); diff --git a/test/cli/help.test.mjs b/test/cli/help.test.mjs index 7305f52ed..26cedb645 100644 --- a/test/cli/help.test.mjs +++ b/test/cli/help.test.mjs @@ -64,6 +64,34 @@ test('a `--help` flag on typecheck is NOT intercepted (forwards to tsc)', () => assert.doesNotMatch(r.stdout, /^Usage: webjs typecheck /m, 'typecheck --help is not the framework help'); }); +test('a `--help` flag on a passthrough command (db/ui) is NOT intercepted', () => { + // db forwards to drizzle-kit and ui forwards to @webjsdev/ui; --help there + // must reach the wrapped tool, not print the framework command help. + for (const c of ['db', 'ui']) { + const r = cli(c, '--help'); + assert.doesNotMatch(r.stdout, new RegExp(`^Usage: webjs ${c} `, 'm'), `${c} --help is not the framework help`); + } +}); + +test('an unknown command with `--help` still errors (not silently intercepted)', () => { + // `webjs bogus --help` must NOT become a silent success: only known, + // non-passthrough commands are intercepted, so this falls to Unknown-command. + const r = cli('bogus', '--help'); + assert.equal(r.status, 1, 'an unknown command is an error even with --help'); + assert.match(r.stderr, /Unknown command: bogus/); +}); + +// --- version ------------------------------------------------------------- +test('`webjs version`, `--version`, and `-v` print the CLI version', async () => { + const { readFile } = await import('node:fs/promises'); + const pkg = JSON.parse(await readFile(resolve(REPO, 'packages', 'cli', 'package.json'), 'utf8')); + for (const form of [['version'], ['--version'], ['-v']]) { + const r = cli(...form); + assert.equal(r.status, 0, `${form.join(' ')}: ${r.stderr}`); + assert.equal(r.stdout.trim(), pkg.version, `${form.join(' ')} prints ${pkg.version}`); + } +}); + test('bare `webjs help` prints the full USAGE banner', () => { const r = help(); assert.equal(r.status, 0, r.stderr); @@ -71,13 +99,18 @@ test('bare `webjs help` prints the full USAGE banner', () => { assert.match(r.stdout, /webjs routes/); }); -test('`webjs help routes` prints usage + summary + examples', () => { +test('`webjs help routes` prints usage + summary + an Options table + examples', () => { const r = help('routes'); assert.equal(r.status, 0, r.stderr); - assert.match(r.stdout, /^Usage: webjs routes \[--json\] \[--table\]/m); + assert.match(r.stdout, /^Usage: webjs routes /m); + // The Options section documents each flag (Remix-CLI parity), incl. --no-headers + // and a universal -h, --help row. + assert.match(r.stdout, /^Options:/m); + assert.match(r.stdout, /^ {2}--json\s+Emit/m); + assert.match(r.stdout, /^ {2}--no-headers\s+/m); + assert.match(r.stdout, /^ {2}-h, --help\s+Show this help\./m); assert.match(r.stdout, /^Examples:/m); assert.match(r.stdout, /webjs routes --json/); - assert.match(r.stdout, /webjs routes --table/); }); test('`webjs help doctor` documents --json and --strict', () => { @@ -88,11 +121,12 @@ test('`webjs help doctor` documents --json and --strict', () => { assert.match(r.stdout, /webjs doctor --json/); }); -test('`webjs help ` falls back to the banner with a note', () => { +test('`webjs help ` exits non-zero with an error (Remix CLI parity)', () => { const r = help('bogus'); - assert.equal(r.status, 0, r.stderr); - assert.match(r.stdout, /No per-command help for "bogus"/); - assert.match(r.stdout, /webjs commands:/); + assert.equal(r.status, 1, 'an unknown help topic is an error, not a silent success'); + assert.match(r.stderr, /Unknown help topic "bogus"/); + // Still prints the banner (to stderr) so the caller sees the real commands. + assert.match(r.stderr, /webjs commands:/); }); // Drift guard: every command in the HELP map must carry a usage + at least one From 953f8e58e386b609508e3768a63a3536a4dd7f8f Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 14 Jul 2026 12:01:23 +0530 Subject: [PATCH 8/9] fix: word the -h/--help help row for passthrough commands accurately webjs help typecheck/db/ui rendered a generic "-h, --help Show this help." row, but --help on those forwards to the wrapped tool (tsc / drizzle-kit / @webjsdev/ui) and never shows our help. Word that row as "Forwarded to " instead, driven by a single HELP_FLAG_PASSTHROUGH_TOOL map that also backs the intercept skip. --- packages/cli/bin/webjs.js | 22 ++++++++++++++-------- test/cli/help.test.mjs | 12 ++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 8424480c8..81905fd8c 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -224,10 +224,14 @@ function printCommandHelp(name) { } console.log(`Usage: ${h.usage}\n`); console.log(` ${h.summary}\n`); - const options = [ - ...(h.options || []), - { flag: '-h, --help', description: 'Show this help.' }, - ]; + // A command that forwards to an external tool does NOT show webjs's own help + // for `--help`; word its `-h, --help` row to say so, rather than the generic + // "Show this help." which would be false for those three commands. + const tool = HELP_FLAG_PASSTHROUGH_TOOL[name]; + const helpRow = tool + ? { flag: '-h, --help', description: `Forwarded to ${tool} (this command wraps it).` } + : { flag: '-h, --help', description: 'Show this help.' }; + const options = [...(h.options || []), helpRow]; const width = Math.max(...options.map((o) => o.flag.length)); console.log('Options:'); for (const o of options) console.log(` ${o.flag.padEnd(width)} ${o.description}`); @@ -238,11 +242,13 @@ function printCommandHelp(name) { /** * Commands that forward their remaining args to an external CLI, so a trailing - * `--help` / `-h` should reach THAT tool's own help, not webjs's: `typecheck` - * (tsc), `db` (drizzle-kit), `ui` (@webjsdev/ui). They are skipped by the - * per-command help-flag intercept. + * `--help` / `-h` should reach THAT tool's own help, not webjs's. Maps the + * command to the tool it wraps (used both to skip the help-flag intercept and + * to word the `-h, --help` row in that command's help accurately). + * @type {Record} */ -const HELP_FLAG_PASSTHROUGH = new Set(['typecheck', 'db', 'ui']); +const HELP_FLAG_PASSTHROUGH_TOOL = { typecheck: 'tsc', db: 'drizzle-kit', ui: '@webjsdev/ui' }; +const HELP_FLAG_PASSTHROUGH = new Set(Object.keys(HELP_FLAG_PASSTHROUGH_TOOL)); /** * The installed `@webjsdev/cli` version, read from this package's own diff --git a/test/cli/help.test.mjs b/test/cli/help.test.mjs index 26cedb645..3e5583fd2 100644 --- a/test/cli/help.test.mjs +++ b/test/cli/help.test.mjs @@ -113,6 +113,18 @@ test('`webjs help routes` prints usage + summary + an Options table + examples', assert.match(r.stdout, /webjs routes --json/); }); +test('a passthrough command\'s help says -h/--help forwards to the wrapped tool', () => { + // `webjs help typecheck` must NOT claim "-h, --help Show this help." because + // typecheck --help actually forwards to tsc. The row is worded accordingly. + const tc = help('typecheck'); + assert.match(tc.stdout, /-h, --help\s+Forwarded to tsc/); + const db = help('db'); + assert.match(db.stdout, /-h, --help\s+Forwarded to drizzle-kit/); + // A non-passthrough command keeps the generic wording. + const routes = help('routes'); + assert.match(routes.stdout, /-h, --help\s+Show this help\./); +}); + test('`webjs help doctor` documents --json and --strict', () => { const r = help('doctor'); assert.equal(r.status, 0, r.stderr); From 266de92c340923d0fe51c4d0dbbb77548e4948c4 Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 14 Jul 2026 12:08:21 +0530 Subject: [PATCH 9/9] docs: name the doctor --json envelope ({ results, summary }), not a bare array The docs said doctor --json emits DoctorResult[], but the payload is an object { results, summary }. An agent reading the old wording could do JSON.parse(out)[0] and fail. Name the envelope in all three surfaces. --- AGENTS.md | 2 +- docs/app/docs/configuration/page.ts | 2 +- packages/cli/AGENTS.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 87d91d5ae..ff4c09b41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -464,7 +464,7 @@ webjs test [--server] [--browser] [--watch] webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop webjs routes [--json] [--table] [--no-headers] # print the route table (path / owner file / methods, #975). Default tree; --json is byte-identical to the MCP list_routes tool; --no-headers drops the --table header for piping webjs mcp # read-only MCP: routes, actions (RPC hashes), components, check -webjs doctor [--json] [--strict] # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory); non-zero exit on a hard fail. --json emits DoctorResult[] with stable codes; --strict also fails the exit on warnings (#975) +webjs doctor [--json] [--strict] # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory); non-zero exit on a hard fail. --json emits `{ results, summary }` (results is the DoctorResult[], each carrying a stable code); --strict also fails the exit on warnings (#975) webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258) webjs version # print the installed @webjsdev/cli version (also: webjs --version / -v, #975) webjs help [command] # full usage banner, or per-command usage + Options + Examples (e.g. webjs help routes, #975). Flag forms: webjs --help / -h (banner), webjs --help / -h (that command). typecheck/db/ui --help forward to their wrapped tool; an unknown topic exits 1 diff --git a/docs/app/docs/configuration/page.ts b/docs/app/docs/configuration/page.ts index dbcb6ce3c..b00852496 100644 --- a/docs/app/docs/configuration/page.ts +++ b/docs/app/docs/configuration/page.ts @@ -47,7 +47,7 @@ webjs routes --json # structured JSON (matches the MCP list_routes t
webjs doctor            # human-readable project-health checklist
 webjs doctor --json     # structured results (each with a stable code) + a summary
 webjs doctor --strict   # also fail the exit on warnings, not just hard failures
-

Verifies project health: the Node version floor, erasableSyntaxOnly, .env drift, vendor-pin freshness, importmap coherence, @webjsdev/* version coherence, framework resolvability, the git hook, and a page/layout elision advisory. Each result carries a stable machine code (for example NODE_VERSION, TSCONFIG_ERASABLE, IMPORTMAP_COHERENCE) so an agent branches on the failure kind, not the message text. By default the exit is non-zero only on a hard toolchain failure; --strict also fails on warnings, so it can gate a fully-clean fix loop the way webjs check --json does.

+

Verifies project health: the Node version floor, erasableSyntaxOnly, .env drift, vendor-pin freshness, importmap coherence, @webjsdev/* version coherence, framework resolvability, the git hook, and a page/layout elision advisory. Each result carries a stable machine code (for example NODE_VERSION, TSCONFIG_ERASABLE, IMPORTMAP_COHERENCE) so an agent branches on the failure kind, not the message text. The --json payload is an object { results, summary } (the results array holds the per-check objects, each with its code). By default the exit is non-zero only on a hard toolchain failure; --strict also fails on warnings, so it can gate a fully-clean fix loop the way webjs check --json does.

webjs version

webjs version           # print the installed @webjsdev/cli version
diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md
index 81b08e7e6..53ca62dda 100644
--- a/packages/cli/AGENTS.md
+++ b/packages/cli/AGENTS.md
@@ -125,7 +125,7 @@ README.md                npm-facing package readme.
 | `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) |
 | `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 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` / `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`. 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, 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/`)). PURE checks render with a `[pass]` / `[warn]` / `[fail]` marker; non-zero exit iff a HARD check fails (Node below the floor, or `erasableSyntaxOnly` missing in an existing tsconfig), so CI can gate. Warns (drift / staleness) never fail 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 the raw `DoctorResult[]` + a summary (the same shape convention as `check --json`); `--strict` also fails the exit on warnings (not just hard failures), 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. An onboarding/setup-verify tool, NOT a scaffold-CI hard gate. Tests: `test/cli/doctor.test.mjs` |
+| `webjs doctor` | `runDoctorChecks()` from `lib/doctor.js`. 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, 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/`)). PURE checks render with a `[pass]` / `[warn]` / `[fail]` marker; non-zero exit iff a HARD check fails (Node below the floor, or `erasableSyntaxOnly` missing in an existing tsconfig), so CI can gate. Warns (drift / staleness) never fail 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`) and `summary` is `{ pass, warn, fail, strict, ok }` (the same array-under-a-key convention as `check --json`); `--strict` also fails the exit on warnings (not just hard failures), 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. An onboarding/setup-verify tool, NOT a scaffold-CI hard gate. 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  [--template …] [--db …] [--runtime node\|bun]` | `scaffoldApp()` from `lib/create.js`. `--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). |