@webjsdev/cli 0.10.68 → 0.10.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/webjs.js CHANGED
@@ -591,7 +591,7 @@ async function main() {
591
591
  superviseDevServer({
592
592
  cwd: process.cwd(),
593
593
  plan,
594
- env: { ...process.env, __WEBJS_DEV_CHILD: '1' },
594
+ env: { ...process.env, ...plan.env, __WEBJS_DEV_CHILD: '1' },
595
595
  onExit: (code) => { killTasks(); process.exit(code); },
596
596
  });
597
597
  break;
package/lib/create.js CHANGED
@@ -383,10 +383,15 @@ export async function scaffoldApp(name, cwd, opts = {}) {
383
383
  // would exec WebJs under Node, silently running the "bun" app on Node).
384
384
  // Baking it into the script body means a plain `bun run dev` (or even
385
385
  // `npm run dev`) starts on Bun, so a user never has to remember the flag.
386
- // The runtime-neutral tooling scripts below (test / db / check / typecheck
387
- // / doctor) stay plain `webjs ...`: they spawn node tooling (`node --test`,
388
- // drizzle-kit, tsc) and forcing `--bun` there buys nothing (and `webjs
389
- // test` shells `node --test`, which a `bun --test` would not be).
386
+ // The db scripts force it too (#1598): `webjs db` runs drizzle-kit and the
387
+ // seed with the CLI's own runtime, and through the node shebang that was
388
+ // Node, at 2.5 to 4 times the CPU of the same command on Bun (generate
389
+ // about 3 CPU-s against 1, migrate 1.2 against 0.5, seed 1 against 0.2,
390
+ // measured on the Postgres scaffold); on Bun the seed also sees `.env`.
391
+ // It is the path a Node-less oven/bun image already takes (#570). The
392
+ // other tooling scripts (test / check / typecheck / doctor / ci) stay
393
+ // plain `webjs ...`: `webjs test` shells `node --test`, which a
394
+ // `bun --test` would not be, and `check` costs the same on both.
390
395
  // Compile Tailwind from public/input.css to a STATIC public/tailwind.css
391
396
  // that app/layout.ts links, so the app is fully styled with JavaScript
392
397
  // DISABLED (a real stylesheet, not an in-browser compile). Runs inside the
@@ -419,11 +424,11 @@ export async function scaffoldApp(name, cwd, opts = {}) {
419
424
  // from the step list in the `webjs.ci` block below. Runtime-neutral like
420
425
  // the other tooling scripts (it spawns `webjs ...` children).
421
426
  ci: 'webjs ci',
422
- 'db:generate': 'webjs db generate',
423
- 'db:migrate': 'webjs db migrate',
424
- 'db:push': 'webjs db push',
425
- 'db:studio': 'webjs db studio',
426
- 'db:seed': 'webjs db seed',
427
+ 'db:generate': isBun ? 'bun --bun webjs db generate' : 'webjs db generate',
428
+ 'db:migrate': isBun ? 'bun --bun webjs db migrate' : 'webjs db migrate',
429
+ 'db:push': isBun ? 'bun --bun webjs db push' : 'webjs db push',
430
+ 'db:studio': isBun ? 'bun --bun webjs db studio' : 'webjs db studio',
431
+ 'db:seed': isBun ? 'bun --bun webjs db seed' : 'webjs db seed',
427
432
  },
428
433
  dependencies: {
429
434
  // Drizzle ORM (no codegen, no engine binary). Pinned to the 1.0 line
@@ -1716,8 +1721,8 @@ ThemeToggle.register('theme-toggle');
1716
1721
  `);
1717
1722
  }
1718
1723
  console.log(`For AI agents, read this before editing:
1719
- • Read AGENTS.md, then .agents/skills/webjs/SKILL.md. The skill is the guide
1720
- to building a WebJs app and routes to focused references on demand.
1724
+ • Read AGENTS.md first: it carries the build steps and a worked example of
1725
+ every common pattern. .agents/skills/webjs/ is the deeper reference.
1721
1726
  • This scaffold is a minimal starting point, not a demo to prune. Grow the app
1722
1727
  in place: add routes under app/, components under components/, and features
1723
1728
  under modules/<feature>/, and keep server-only code behind .server.ts.
@@ -78,7 +78,7 @@ export function isBootFile(path) {
78
78
  * @param {string[]} opts.argv `process.argv.slice(1)` (the script path followed by its args), forwarded to the child verbatim.
79
79
  * @param {boolean} opts.noHot Whether `--no-hot` was passed (opt out of the supervisor entirely).
80
80
  * @param {boolean} [opts.sourceLocations] Whether dev source locations are on (`WEBJS_SOURCE_LOCATIONS` / `webjs.dev.sourceLocations`), which puts every app module behind a Bun plugin.
81
- * @returns {{ mode: 'inline' } | { mode: 'supervise', args: string[], restartOnChange: boolean, restartFor?: (path: string) => boolean, inPlaceRestartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] }}
81
+ * @returns {{ mode: 'inline' } | { mode: 'supervise', args: string[], env?: Record<string, string>, restartOnChange: boolean, restartFor?: (path: string) => boolean, inPlaceRestartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] }}
82
82
  * `inline` runs the server in this process (no reload watcher); `supervise`
83
83
  * spawns `process.execPath` with `args` and `__WEBJS_DEV_CHILD=1` under the
84
84
  * supervisor, which watches `watchDirs` (recursively) and `watchFiles` (at
@@ -101,6 +101,7 @@ export function planDevSupervisor({ isBun, argv, noHot, sourceLocations = false
101
101
  return {
102
102
  mode: 'supervise',
103
103
  args: ['--hot', ...argv],
104
+ env: BUN_CHILD_ENV,
104
105
  restartOnChange: true,
105
106
  restartFor: (p) => plugin(p) || isBootFile(p),
106
107
  inPlaceRestartFor: isBootFile,
@@ -110,6 +111,20 @@ export function planDevSupervisor({ isBun, argv, noHot, sourceLocations = false
110
111
  return { mode: 'supervise', args: [...argv], restartOnChange: true, ...watch };
111
112
  }
112
113
 
114
+ /**
115
+ * Extra env for the Bun dev child: turn off Bun's runtime transpiler cache.
116
+ *
117
+ * Bun caches the transpile of every source over 50KB on disk, keyed by the
118
+ * file's CONTENT, with the import paths a `Bun.plugin` `onResolve` returned
119
+ * baked in. The dev alias resolver (#1575) returns absolute paths, so the
120
+ * cached output of a large app module pins its `#` imports to the checkout
121
+ * that first ran `webjs dev`. Any other copy of the same file (a git worktree,
122
+ * a moved or copied app, a later `webjs start`) then imports from that old
123
+ * directory: a 500 when it is gone, the other copy's code when it is not. The
124
+ * variable is read at process start, so it has to be set on the child.
125
+ */
126
+ const BUN_CHILD_ENV = Object.freeze({ BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' });
127
+
113
128
  const SERVER_MODULE = /\.server\.m?[jt]s$/;
114
129
  const APP_MODULE = /\.m?[jt]s$/;
115
130
 
@@ -13,6 +13,14 @@
13
13
  * 'drizzle-kit/bin.cjs')` throws `ERR_PACKAGE_PATH_NOT_EXPORTED`. The `.` main
14
14
  * entry DOES resolve, so resolve that, walk up to the package root (the nearest
15
15
  * dir with a package.json), and read the `bin` field, which is version-robust.
16
+ *
17
+ * Installed means reachable through a `node_modules/<pkg>` entry in `cwd` or an
18
+ * ancestor, the lookup Node's resolver does. That is checked BEFORE resolving
19
+ * because Bun auto-installs: when no `node_modules` sits above `cwd`, Bun's
20
+ * `require.resolve` fetches an undeclared package into its global cache and
21
+ * returns that path. Without the check, an app that never installed
22
+ * @web/test-runner would launch whatever version Bun downloaded instead of
23
+ * getting the "not installed" remedy.
16
24
  */
17
25
  import { createRequire } from 'node:module';
18
26
  import { readFileSync, existsSync } from 'node:fs';
@@ -27,6 +35,9 @@ import { join, dirname, resolve } from 'node:path';
27
35
  * @throws if the package is not installed or has no matching bin
28
36
  */
29
37
  export function resolveBin(cwd, pkgName, binName) {
38
+ if (!hasNodeModulesEntry(cwd, pkgName)) {
39
+ throw new Error(`Cannot find package '${pkgName}' in a node_modules above ${cwd}`);
40
+ }
30
41
  const req = createRequire(join(cwd, 'package.json'));
31
42
  // `.` (the main entry) is exported even when subpaths are not.
32
43
  let pkgDir = dirname(req.resolve(pkgName));
@@ -40,3 +51,18 @@ export function resolveBin(cwd, pkgName, binName) {
40
51
  if (!binRel) throw new Error(`bin '${binName}' not found in ${pkgName}`);
41
52
  return resolve(pkgDir, binRel);
42
53
  }
54
+
55
+ /**
56
+ * @param {string} cwd
57
+ * @param {string} pkgName
58
+ * @returns {boolean} whether `node_modules/<pkgName>` exists in cwd or an ancestor
59
+ */
60
+ function hasNodeModulesEntry(cwd, pkgName) {
61
+ let dir = resolve(cwd);
62
+ for (;;) {
63
+ if (existsSync(join(dir, 'node_modules', pkgName))) return true;
64
+ const parent = dirname(dir);
65
+ if (parent === dir) return false;
66
+ dir = parent;
67
+ }
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.68",
3
+ "version": "0.10.69",
4
4
  "type": "module",
5
5
  "description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "@webjsdev/mcp": "^0.1.0",
21
- "@webjsdev/server": "^0.8.86",
21
+ "@webjsdev/server": "^0.8.87",
22
22
  "@webjsdev/ui": "^0.3.15"
23
23
  },
24
24
  "publishConfig": {
@@ -2,23 +2,21 @@
2
2
 
3
3
  You are working on a WebJs app (AI-first, no-build, web-components-first). This
4
4
  file is the WORKFLOW contract (git, tests, review). For HOW to build (routing,
5
- components, actions, styling, the framework API), read
6
- `.agents/skills/webjs/SKILL.md`, which routes to focused references on demand.
7
- Read `AGENTS.md` first. Full hosted docs are at https://webjs.dev/docs.
5
+ components, actions, styling, the framework API), read `AGENTS.md`; the
6
+ deeper reference set is `.agents/skills/webjs/SKILL.md`. Full hosted docs are
7
+ at https://webjs.dev/docs.
8
8
 
9
9
  ## Grow the app in place (non-negotiable)
10
10
 
11
- - **Study the shipped examples, then clear them and build.** The scaffold is a
12
- starting point with a browsable showcase to learn the real idioms from, plus a
13
- database wired up. A full-stack app ships a UI feature gallery (`app/features/`,
14
- `app/examples/todo`); the api template ships a backend-features showcase
15
- (`app/api/features/`), with logic in `modules/`. Building a real app: study the
16
- parts that match your task (the skill teaches the same and SURVIVES the clear),
17
- run `npm run gallery:clear` to shed the showcase (it keeps the agent skill and
18
- the database wiring, and resets to a clean base), then regenerate the database
19
- and grow the app in place under `app/`, `components/`, and `modules/<feature>/`.
20
- `AGENTS.md` carries the full template-specific build playbook and the order to
21
- follow.
11
+ - **Clear the showcase, then build.** The scaffold is a starting point with a
12
+ browsable demo showcase plus a database wired up. A full-stack app ships a UI
13
+ feature gallery (`app/features/`, `app/examples/todo`); the api template ships
14
+ a backend-features showcase (`app/api/features/`), with logic in `modules/`.
15
+ Building a real app: run `npm run gallery:clear` to shed the showcase (it
16
+ keeps the agent docs and the database wiring, and resets to a clean base),
17
+ then regenerate the database and grow the app in place under `app/`,
18
+ `components/`, and `modules/<feature>/`. `AGENTS.md` carries the
19
+ template-specific build steps and the order to follow.
22
20
  - **Use the wired-up database (Drizzle), never JSON files.** For any data the app
23
21
  stores, define a Drizzle table in `db/schema.server.ts`, then
24
22
  `npm run db:generate` and `npm run db:migrate`. Never use a JSON file, a
@@ -26,9 +24,8 @@ Read `AGENTS.md` first. Full hosted docs are at https://webjs.dev/docs.
26
24
  - **`app/` is routing-only.** Only routing files live in `app/` (page, layout,
27
25
  route, middleware, metadata routes). Browser-safe helpers go in `lib/utils/`,
28
26
  feature logic in `modules/`, server-only code behind `.server.ts`.
29
- - **For a UI app, render and LOOK before calling it done.** Define design tokens
30
- in `app/layout.ts` with a palette that fits the app
31
- (`.agents/skills/webjs/references/styling.md` is the guide), then open every
27
+ - **For a UI app, render and LOOK before calling it done.** Give the design
28
+ tokens in `public/input.css` a palette that fits the app, then open every
32
29
  route you changed in a real browser and play through its states.
33
30
  `npm run check` and `npm run typecheck` pass even when a layout collapses, so
34
31
  the browser is the real check.
@@ -9,6 +9,8 @@ Use this skill for end-to-end WebJs app work. It helps you choose the right laye
9
9
 
10
10
  ## Full Documentation
11
11
 
12
+ In a scaffolded app, `AGENTS.md` carries the build steps and a worked example of every common pattern (pages, layouts, form-bound actions with validation, queries, owner-scoped CRUD, `createAuth`, a component with signals, a test). Build from it first, and come here for a surface it does not show.
13
+
12
14
  This skill is the quick guide. When you need the full API reference for a surface, load the matching file in `references/` (listed below). For even deeper framework detail, WebJs ships buildless, so the source you run IS the source you read: look in `node_modules/@webjsdev/{core,server,cli}/` (each package ships its own `AGENTS.md`). The complete hosted docs live at https://webjs.dev/docs.
13
15
 
14
16
  ## What WebJs Is
@@ -44,7 +46,6 @@ Rows point rather than explain. The reference is the authority on the rule, and
44
46
  | add a URL, static or with a dynamic segment | a file at `app/<path>/page.ts`, `[id]` for a param | registering the route in a table or config | `references/routing-and-pages.md` | `app/features/routing` |
45
47
  | abandon a render because something is missing or not allowed | throw `notFound()` / `forbidden()` / `unauthorized()` | returning an error object and branching in the template | `references/routing-and-pages.md` | `app/features/boundaries` |
46
48
  | set a page's title, description, or social preview | `export const metadata` or `generateMetadata()` | writing `<head>` tags in the page | `references/routing-and-pages.md` | `app/features/metadata` |
47
- | give the app its own favicon, home-screen icon and manifest | replace the placeholder `app/icon.svg` with a simple symbol for the app in its colours, add `app/apple-icon.png`, edit `app/manifest.webmanifest` | leaving the scaffold placeholder, or a hand-written `<link rel="icon">` | `references/routing-and-pages.md` (App icon and manifest) | `app/icon.ts` |
48
49
  | make part of the page respond to a click or hold state | a `WebComponent` custom element | expecting the page's own markup to hydrate | `references/components.md` | `app/features/components` |
49
50
  | render a keyed list, or swap one node when state changes | `repeat()` / `watch()` from `/directives` | re-rendering the component or diffing by hand | `references/components.md` | `app/features/directives` |
50
51
  | get server data into a component's first paint | `async render()` awaiting an action | fetching in `connectedCallback`, which SSR never calls | `references/components.md` | `app/features/async-render` |
@@ -44,6 +44,8 @@ Three seams pick a runtime-specific implementation, all inside the framework, no
44
44
 
45
45
  Bun keeps ONE dev server process for the whole session (#1575), so it gets the refresh. `bun --hot` re-runs the CLI on a change; the first run's server owns the process (its listener, live-reload stream, watchers and analysis caches) and a re-run only tells it the module registry was reset, so nothing is started twice and memory stays flat over hundreds of edits (it used to grow about 25 MB an edit until `bun --hot` stopped reloading). The app's modules load through a `Bun.plugin` that reads them fresh, and its `#` imports resolve through the app's own `imports` map, because Bun keeps the old source of a file that was replaced (an atomic save) and a stale directory listing for a new file next to a `*.server.*` module. So `bun --hot` no longer sees app edits itself, and the dev server asks it for a registry reset after an edit to a module some other module imports, a new module, or a `*.server.*` module, all without a process restart. A page, layout or route handler nothing imports needs no reset: the dev re-import is keyed by the file's content, so an unchanged file reuses its loaded module (no new module instance per request) and an edited one is a new import. `instrumentation.*` and `env.*` run once per process, so an edit to one restarts the server on both runtimes. On Node the `webjs dev` supervisor (it replaced `node --watch` in #1521) RESTARTS the server process on a change under `app`, `components`, `modules`, `lib`, or `actions`, or to a root `middleware.{ts,js,mts,mjs}`, and a fresh process holds no record of what changed, so those edits are always a full reload. Two Node cases still refresh in place: an edit OUTSIDE that watched set (`db/schema.server.ts`, a `webjs.dev.watch` content dir), and running `npm run dev -- --no-hot`, which keeps the server in one process on either runtime. An in-place refresh loads the rebuilt stylesheets (a `webjs.dev.regenerate` compile runs on that request) BEFORE it swaps the new markup in, and drops the old sheets only after, so an element that gained a utility class never paints without its rule (#1535; a full reload never had the gap, since a head stylesheet is render-blocking). A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.
46
46
 
47
+ After a reset, app code that imports `@webjsdev/server` gets a fresh copy of the package while the first run's server keeps handling requests. Everything the two copies must agree on (the request `auth()`, `cookies()` and `headers()` read, the action signal, the seed collector and action identity, the default cache store, sessions, broadcast clients) lives in process-wide state keyed by `Symbol.for`, so a signed-in page and the `'use server'` queries it calls still see the request after any number of edits (#1590). Before that fix, `auth()` without an explicit request returned `null` in app code after the first edit.
48
+
47
49
  **`webjs dev` and `webjs start` serve the directory they are started in, and refuse anywhere else (#1526).** Run them in the app directory, the one holding `app/`. In a workspace (`apps/web` under a root `package.json` with `workspaces`) that is the member, even when the CLI is hoisted to the root `node_modules`: the hoisted bin and the Bun `--hot` child both keep the directory they were started in. Started where there is no `app/` (the workspace root, or a subdirectory such as `app/` itself), both exit 1 before any `before` step runs, naming the app to start (`cd apps/web && webjs dev`), instead of booting a server that answers 404 for every route.
48
50
 
49
51
  **`webjs dev` does not stay down (#1521).** The supervisor and the server's own watcher handle every watcher error: a file in a watched dir the dev server cannot read or watch (the 0600 temp file `sed -i` creates when another user runs it, a file removed mid-scan) logs one `file watcher skipped <path> (EACCES)` warning and both keep running, where `node --watch` used to crash and leave the preview dead. Edits that REPLACE a file (`sed -i`, an editor saving through a temp file, an atomic write) are heard every time, however often the same file is replaced (#1529: on Linux under Node the watchers watch each directory, since Node 24's own recursive watcher stopped hearing a file after its first replacement). A server process that crashes is started again on the next file change, or by itself after a backoff of 0.5s growing to 10s for repeated crashes. On Bun the supervisor restarts the server only for an `instrumentation.*` / `env.*` edit (#1575), and otherwise does only the crash recovery. An agent writing files the way an AI editor does (bursts, partial writes, syntax errors then fixes, renames, deletes, atomic writes) is exercised by `scripts/dev-reload-stress.mjs` (`node scripts/dev-reload-stress.mjs <appDir> <url>` against any running app). Stopping `webjs dev` (Ctrl-C, SIGTERM) stops the server child too, and a child whose supervisor was killed outright exits on its own, so nothing is left holding the port.
@@ -30,6 +30,13 @@ fi
30
30
  # Read stdin so we don't break Claude Code's hook contract.
31
31
  cat /dev/stdin >/dev/null 2>&1 || true
32
32
 
33
+ # A repository with no commit yet is a first build from the scaffold: the whole
34
+ # build is one logical unit (CLAUDE.md), committed once at the end, so nudging
35
+ # mid-build would only split it. The Stop hook still asks for that commit.
36
+ if ! git rev-parse --verify -q HEAD >/dev/null 2>&1; then
37
+ exit 0
38
+ fi
39
+
33
40
  CHANGED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
34
41
 
35
42
  if [ -z "$CHANGED" ] || [ "$CHANGED" -lt "$THRESHOLD" ]; then
@@ -1,82 +1,52 @@
1
1
  # AGENTS.md for {{APP_NAME}}
2
2
 
3
- This is a WebJs app: AI-first, web-components-first, buildless, and
4
- progressively enhanced. Read this whole file before you edit anything, then
5
- follow it. The steps here are required, not optional.
6
-
7
- ## Gather context BEFORE you build (required)
8
-
9
- WebJs is its own framework. It is not React, Next, or Lit, so writing code from
10
- that muscle memory produces broken WebJs code. Before you write or change
11
- anything, gather context from these sources. Do not skip a step to save time.
12
- This is what separates a working app from a broken one.
13
-
14
- 1. **Read the skill.** Start with `.agents/skills/webjs/SKILL.md`, then load the
15
- `references/*.md` files it routes to for the surface you are touching. The
16
- skill is the guide to building a WebJs app: it helps you choose the right
17
- layer, reach for the right export, and avoid the mistakes Next.js or Lit
18
- habits cause. Reading it is never wasted work: it survives the
19
- gallery-clearing step in the playbook below.
20
- 2. **Study the shipped examples, then build on a clean slate.** The template
21
- playbook below says what ships and the exact order to follow. The workflow
22
- rules (git, tests, review) are in `.agents/rules/workflow.md`; follow them
23
- too.
24
- 3. **Read the framework source for exact contracts.** WebJs is 100% buildless
25
- native ES modules, so the source you run IS the source you read. When you
26
- need a precise API signature or behavior, open the package source under
27
- `node_modules/@webjsdev/*` directly (each package ships its own `AGENTS.md`).
28
- The full hosted docs are at https://webjs.dev/docs.
3
+ This is a WebJs app: server-rendered pages, web components for interactivity,
4
+ server actions, Drizzle, and no build step. WebJs is its own framework, not
5
+ React, Next.js or Lit, so write it from the patterns in this file rather than
6
+ from that memory. Read this whole file before you edit anything.
29
7
 
30
8
  {{PLAYBOOK}}
31
9
 
32
- ## Type everything (all templates)
33
-
34
- Full-stack type safety is what the `.server.ts` boundary buys you: a client
35
- component importing a server action resolves to that action's real signature at
36
- type-check time, with no build step and no code generation in between. So
37
- DERIVE the type at every boundary instead of widening it:
38
-
39
- - A database row: `export type Todo = typeof todos.$inferSelect` in
40
- `db/schema.server.ts` (`$inferInsert` for a write), carried into a
41
- browser-shipped component with `import type` (erased before it reaches the
42
- browser, so it does not trip the server-import boundary).
43
- - An action's input: a named `interface`. Its result: `ActionResult<T>`.
44
- Narrow with `if (result.success && result.data)`.
45
- - Routing files: `PageProps<'/blog/[slug]'>`, `LayoutProps`,
46
- `RouteHandlerContext`, all from `@webjsdev/core`. Run `npx webjsdev types`
47
- for the typed `Route` union and per-route `params`.
48
- - A reactive property: `prop<Student>(Object)`, `prop<Tag[]>(Array)`.
49
-
50
- Never reach for `any` or a loose `as any` cast, and do not reach for `unknown`
51
- either just because it looks safer. `unknown` is right for a payload nothing
52
- has vouched for yet, narrowed on the very next line (a `route.ts` `await
53
- req.json()`, an action's `export const validate` or a validator it delegates
54
- to, a `catch` binding), and for a parameter of YOUR OWN helper that forwards
55
- into an `html` template hole (a hole renders a string, a number, a
56
- `TemplateResult`, or an array of those, so `TemplateResult` alone is too
57
- narrow). That second case is about a value you accept, never one the framework
58
- already types. Everywhere else it is a missing type, not a safe one: `unknown`
59
- that survives into a return type, a component prop, a layout's `children`, or
60
- an action signature is the shape to fix.
61
- Nothing enforces this (both are valid TypeScript, so `webjs check` and `tsc`
62
- pass either way), which is exactly why it is written down. The full ladder,
63
- with an end-to-end example, is in
64
- `.agents/skills/webjs/references/typescript.md`.
65
-
66
- Keep server-only code (database drivers, secrets, `node:*` builtins) in
67
- `.server.ts` modules. There are exactly two kinds:
68
-
69
- - A `.server.ts` file WITH `'use server';` as its first line is a server
70
- action: WebJs exposes its exported async functions to browser code as RPC
71
- calls, so browser modules may import it directly.
72
- - A `.server.ts` file WITHOUT `'use server'` is a server-only utility:
73
- importing it from a page, layout, or component CRASHES in the browser at
74
- module load. Reach it only from `'use server'` actions, `route.ts` handlers,
75
- or middleware. Never add `'use server'` to a file only other server code
76
- imports (the DB connection, the schema).
77
-
78
- ## Data (all templates)
79
-
80
- Use the wired-up database (Drizzle) for every piece of data the app stores;
81
- the playbook above has the modeling step. Never store app data in a JSON file,
82
- an in-memory array, or localStorage.
10
+ ## Rules that hold everywhere
11
+
12
+ - **Type every boundary from its source.** A row is
13
+ `typeof posts.$inferSelect` (exported from `db/schema.server.ts`, carried
14
+ into browser code with `import type`), an action's input is a named
15
+ `interface`, a routing file uses `PageProps<'/posts/[id]'>` / `LayoutProps` /
16
+ `RouteHandlerContext` from `@webjsdev/core`, and a component prop that holds
17
+ an object is `prop<Post>(Object)`. Never reach for `any` or `as any`, and
18
+ keep `unknown` for an untrusted payload you narrow on the next line.
19
+ - **Server-only code stays behind `.server.ts`.** A file WITH `'use server'`
20
+ exposes its exported async functions as actions; a file WITHOUT it is a
21
+ server-only utility that only other server code may import. Never add
22
+ `'use server'` to a file only server code imports (the DB connection, the
23
+ schema).
24
+ - **Store data with Drizzle** in the wired-up database (`db/`), never in a
25
+ JSON file, an in-memory array or Map, or localStorage. Every schema change is
26
+ `npm run db:generate` then `npm run db:migrate`, and the generated
27
+ migrations are committed.
28
+ - **TypeScript is erasable:** no `enum`, no `namespace`, no constructor
29
+ parameter properties, no decorators. Never put a backtick inside an
30
+ `html` template body, even in a comment.
31
+ - **Errors tell you the fix.** `npm run check` and `npm run typecheck` name
32
+ the file, the rule and the fix; do what they say rather than searching.
33
+
34
+ ## Git
35
+
36
+ Commit per logical unit (CLAUDE.md has the rule). When you build a whole app
37
+ from a spec in one session, the build is one unit: work on a feature branch
38
+ (commits to `main` are refused), and commit once at the end after the checks
39
+ pass (`git add -A && git commit -m "<imperative subject>"`). Team workflow
40
+ (pull requests, CI, worktrees) is in `.agents/rules/workflow.md`.
41
+
42
+ ## When you need more
43
+
44
+ The reference set is `.agents/skills/webjs/` (`SKILL.md` routes to
45
+ `references/*.md`). Open one only for a surface this file does not show:
46
+ streaming and Suspense (`client-router-and-streaming.md`), optimistic UI
47
+ (`optimistic-ui.md`), caching, rate limits, file uploads, env vars
48
+ (`built-ins.md`), OAuth providers and sessions (`auth-and-sessions.md`),
49
+ browser and e2e tests (`testing.md`), deeper component topics such as slots,
50
+ shadow DOM, context and directives (`components.md`). The exact framework
51
+ source is under `node_modules/@webjsdev/*`, and the hosted docs are at
52
+ https://webjs.dev/docs.
@@ -3,21 +3,19 @@
3
3
  # Committing per logical unit (this OVERRIDES Claude Code's default)
4
4
 
5
5
  Claude Code's built-in default is to NEVER commit unless the user explicitly
6
- asks. **For this project that default does NOT apply.** Commit and push per
7
- logical unit (one feature, one fix, one rename, one doc rewrite) as soon as it
8
- is complete, WITHOUT being asked. Do not save all the work for one commit at the
9
- end. A finished implementation with zero commits is a mistake here, because git
10
- history is the user's revert and cherry-pick safety net.
6
+ asks. **For this project that default does NOT apply.** Commit per logical
7
+ unit (one feature, one fix, one rename, one doc rewrite) as soon as it is
8
+ complete and its checks pass, WITHOUT being asked, then push when there is a
9
+ remote. Git history is the user's revert and cherry-pick safety net.
11
10
 
12
- - After each completed unit whose tests pass, `git add` the related files and
13
- `git commit` with an imperative subject under 72 chars, then push. If 5+ files
14
- span more than one concern, you already waited too long.
15
- - Never commit to `main`. Work on a feature branch (the
16
- `.claude/hooks/guard-branch-context.sh` hook enforces this).
17
- - No AI-attribution trailers (`Co-Authored-By`, `Generated by`).
11
+ - Building a whole app from a spec in one session is ONE unit: build it,
12
+ verify it, then commit once. Later changes are one commit each.
13
+ - Never commit to `main`. Work on a feature branch (the pre-commit hook
14
+ refuses `main`).
15
+ - Imperative subject under 72 characters. No AI-attribution trailers
16
+ (`Co-Authored-By`, `Generated by`).
18
17
 
19
- See AGENTS.md "Git workflow" for the full contract. Two hooks back this up: the
20
- `.claude/hooks/nudge-uncommitted.sh` PostToolUse hook reminds you while
21
- uncommitted changes pile up during work, and the
22
- `.claude/hooks/commit-before-stop.sh` Stop hook stops you from ending a turn
23
- with a pile of uncommitted work still on a feature branch.
18
+ Two hooks back this up: `.claude/hooks/nudge-uncommitted.sh` reminds you while
19
+ uncommitted changes pile up on a branch that already has commits, and
20
+ `.claude/hooks/commit-before-stop.sh` stops you from ending a turn with
21
+ uncommitted work on a feature branch.
@@ -1,9 +1,9 @@
1
1
  # Conventions for {{APP_NAME}}
2
2
 
3
- The conventions for building a WebJs app live in the agent skill. **Read
4
- `AGENTS.md` first, then `.agents/skills/webjs/SKILL.md`** (it routes to focused
5
- references under `.agents/skills/webjs/references/`, loaded on demand). This file
6
- is the short version.
3
+ The conventions for building a WebJs app live in **`AGENTS.md`**, which shows
4
+ every common pattern as a worked example. The deeper reference set is
5
+ `.agents/skills/webjs/` (`SKILL.md` routes to `references/*.md`), for the rarer
6
+ surfaces. This file is the short version.
7
7
 
8
8
  ## The essentials
9
9
 
@@ -17,13 +17,12 @@ is the short version.
17
17
  - **Use the wired-up database (Drizzle).** Define real models in
18
18
  `db/schema.server.ts`, then `npm run db:generate` and `npm run db:migrate`.
19
19
  Never persist to a JSON file, an in-memory array or Map, or localStorage.
20
- - **The scaffold ships a showcase to learn from.** A full-stack app ships a UI
21
- feature gallery (`app/features/`, `app/examples/todo`); the api template ships
22
- a backend-features showcase (`app/api/features/`), with logic in `modules/`.
23
- When you build a real app, study the parts that match your task (the skill
24
- teaches the same and survives the clear), run `npm run gallery:clear` to shed
25
- the showcase, then grow the app in place. `AGENTS.md` has the full
26
- template-specific playbook.
20
+ - **The scaffold ships a demo showcase.** A full-stack app ships a UI feature
21
+ gallery (`app/features/`, `app/examples/todo`); the api template ships a
22
+ backend-features showcase (`app/api/features/`), with logic in `modules/`.
23
+ When you build a real app, run `npm run gallery:clear` first to shed the
24
+ showcase, then grow the app in place. `AGENTS.md` has the template-specific
25
+ build steps.
27
26
  - **Derive types at every boundary.** Rows from `$inferSelect`, action inputs
28
27
  from an `interface`, routing files from `PageProps` / `LayoutProps`. Never
29
28
  `any`, and never `unknown` where a real type exists.
@@ -1,135 +1,571 @@
1
- ## Build a full-stack app (default template)
2
-
3
- This scaffold ships a browsable feature gallery to learn from: single-concept
4
- demos under `app/features/`, the `app/examples/todo` app, and an example design
5
- system under `components/ui/`, with logic in `modules/`. Build in this order.
6
-
7
- ### 1. Study the gallery, then clear it
8
-
9
- Read the demos under `app/features/` (and `app/examples/todo`) that match what
10
- you are building, so you copy the real idiom: server actions, queries,
11
- optimistic UI, component hydration, design tokens. Then run
12
- `npm run gallery:clear` to shed the whole gallery and reset `app/page.ts` and
13
- `app/layout.ts` to a blank slate. The clear also removes the example
14
- `components/ui/` primitives, the demo `todos` table, and the demo migrations;
15
- it keeps the agent skill, the database wiring, and `lib/utils/cn.ts` (needed by
16
- `npx webjsdev ui add`). The skill teaches the same patterns, so the gallery is
17
- a runnable copy you study first, not something you lose.
18
-
19
- ### 2. Model the data
20
-
21
- Define real models in `db/schema.server.ts`, then run `npm run db:generate` and
22
- `npm run db:migrate` (required after the clear, which removed the demo table and
23
- migrations). Write a seed script at `db/seed.server.ts` and run
24
- `npm run db:seed` so list and detail pages render real rows while you build,
25
- instead of empty states. Put reads in `modules/<feature>/queries/*.server.ts`
26
- and writes in `modules/<feature>/actions/*.server.ts`, one function per file.
27
-
28
- ### 3. Build a token-based design system
29
-
30
- Full reference: `.agents/skills/webjs/references/styling.md`.
31
-
32
- - Define your color tokens as CSS custom properties in `app/layout.ts`, each
33
- written ONCE with the native CSS `light-dark(LIGHT, DARK)` function, so light
34
- and dark modes come from one declaration.
35
- - Define at least: `--background`, `--foreground`, `--card`, `--primary`,
36
- `--secondary`, `--muted`, `--muted-foreground`, `--accent`, `--border`,
37
- `--ring`, `--destructive`. Add the matching `*-foreground` pair for each
38
- surface token you use, following the styling guide's reference palette.
39
- - Consume colors ONLY as token utilities: `bg-background`, `text-foreground`,
40
- `bg-card`, `border-border`, `text-primary`, `text-muted-foreground`,
41
- `bg-destructive`.
42
- - NEVER put a raw un-themed Tailwind color (`red-500`, `blue-600`, `gray-100`)
43
- on an element or a `@webjsdev/ui` helper.
44
- - Add an inline theme-detection script in the layout `<head>` so the first
45
- paint matches the saved theme with no flash.
46
-
47
- ### 4. Use the UI kit, do not hand-roll primitives
48
-
49
- Pull primitives with `npx webjsdev ui add <name>`; the source is copied into
50
- `components/ui/`, so you own it fully and can add, remove, restructure, or theme
51
- it however your app needs. Do NOT guess a helper or tag signature. Inspect the
52
- copied file `components/ui/<name>.ts`, or run
53
- `npx webjsdev ui view <name>`, for the exact exported names, variants, and
54
- sizes. The kit has two tiers:
55
-
56
- - **Tier 1, class helpers** for static primitives (button, card, input, badge,
57
- native-select, textarea). Spread the helper onto a native element, for example
58
- `class=${buttonClass({ variant: 'outline', size: 'sm' })}`.
59
- - **Tier 2, custom elements** for stateful controls and overlays (`<ui-tabs>`,
60
- `<ui-dialog>`, `<ui-dropdown-menu>`, `<ui-tooltip>`, sonner toasts). Use the
61
- registered tag; it owns its ARIA, focus trap, and keyboard navigation out of
62
- the box. Never hand-author a tab strip or a modal when a Tier-2 element
63
- covers it.
64
-
65
- Full reference: `.agents/skills/webjs/references/ui-kit.md`.
66
-
67
- ### 5. Build a multi-page app (MPA), not a single page
68
-
69
- Structure the product as real routes, not one page that swaps client state:
70
-
71
- - `/` a home or overview page.
72
- - `/<resource>` a list page with search, filters, sorting, and a create form or
73
- modal.
74
- - `/<resource>/[id]` a detail page for one item.
75
- - a couple of additional feature pages as the product needs.
76
-
77
- Give `app/layout.ts` a navbar that links the main pages, pinned with
78
- `position: fixed` (never `position: sticky`, which flickers on iOS during a
79
- client-router navigation), and reserve its height on the content with a
80
- `--header-height` offset. In a list or table, clicking a row or card navigates
81
- to that item's detail page. Wrap each row action button (edit, delete, status)
82
- so its handler calls `event.stopPropagation()`, letting the button run its own
83
- action without also triggering the row navigation.
84
-
85
- ### 6. Build components for interactivity
86
-
87
- Pages and layouts (`app/**/page.ts`, `app/**/layout.ts`) are server-only HTML
88
- generators, so put every interactive behavior inside a `WebComponent` custom
89
- element. Declare a component's reactive properties in the base-class factory,
90
- never as a class-field initializer (`items = []` clobbers the reactive
91
- accessor). Use the shorthand for primitives
92
- (`extends WebComponent({ name: String, count: Number, open: Boolean })`) and the
93
- `prop<T>()` helper for typed objects and arrays
94
- (`extends WebComponent({ items: prop<Item[]>(Array), user: prop<User>(Object) })`).
95
-
96
- ### 7. Verify before you call it done
97
-
98
- Run `npm run ci` and fix what it reports. It is one command for every gate,
99
- the step list declared in `package.json` under `webjs.ci`, with a result line
100
- per step:
101
-
102
- - `webjs check` (correctness: no browser-import or boundary violation).
103
- - `webjs doctor` (project health). It fails on whatever `package.json`
104
- `webjs.doctor.gate` marks `error`, plus the two hard toolchain checks that
105
- are fatal with no gate entry, `NODE_VERSION` and `TSCONFIG_ERASABLE`.
106
- - `webjs typecheck` (zero type errors).
107
- - A dependency audit.
108
- - The server, browser, and e2e test layers for the features you built.
109
-
110
- The GitHub workflow runs the same list, so a green local run predicts CI.
111
- While iterating, `npm run ci -- --only Tests` runs one layer. Then
112
- `npm run css:build` (compile Tailwind).
113
-
114
- Then boot `npm run dev`, confirm every page route returns HTTP 200, and open
115
- every route you changed in a real browser and play through its states: `check`
116
- and `typecheck` pass even when a layout collapses, so the browser is the real
117
- check for UI work.
1
+ ## Build an app (full-stack template)
2
+
3
+ ### Build steps
4
+
5
+ Everything a typical app needs (pages, forms, validation, auth, owner-scoped
6
+ CRUD, a component, Drizzle) is in this file, so build straight from it instead
7
+ of exploring. Write in a few large steps (one shell heredoc or one write per
8
+ group of files), not one file per turn.
9
+
10
+ 1. Branch, clear the demo gallery, and add the UI kit, in one command:
11
+ `git checkout -b feat/<name> && npm run gallery:clear && npx webjsdev ui add button input label textarea native-select card badge`.
12
+ The gallery (`app/features/`, `app/examples/`, the demo `modules/`) is only a
13
+ demo: never read it, everything it teaches is below.
14
+ 2. Write `db/schema.server.ts` (replace the whole file: its `users` table is a
15
+ placeholder), then `npm run db:generate && npm run db:migrate`.
16
+ 3. Write every `modules/` file (auth, queries, actions, components, utils).
17
+ 4. Write `app/layout.ts` and `app/page.ts` (replace both whole; no need to
18
+ read them first), every other page, `app/not-found.ts`, and
19
+ `test/<feature>/*.test.ts`.
20
+ 5. `npm run check && npm run typecheck && npm run test:server`, and fix what
21
+ they report.
22
+ 6. Walk the app once in a real browser. `curl` cannot submit a bound form
23
+ (the form carries a hidden action field), so use Playwright, which is
24
+ installed (if Chromium is missing: `npx playwright install chromium`).
25
+ First write one script, `walk.mjs` in the app folder, that signs up and
26
+ drives each feature once with `page.getByLabel(...)` and
27
+ `page.getByRole('button', { name })`. With JavaScript on, a submit is
28
+ applied in place, so wait for its outcome (`await page.waitForURL(...)` or
29
+ `await page.getByText('...').waitFor()`), never a fixed timeout. Save a
30
+ phone-width and a desktop-width screenshot under `/tmp`. Then start
31
+ `PORT=<port> npm run dev > dev.log 2>&1 &` (`*.log` is gitignored), run
32
+ `node walk.mjs`, look at the screenshots, fix what the walk shows in the
33
+ app, stop the server you started, and delete `walk.mjs`.
34
+ 7. Commit (see Git below).
35
+
36
+ ### How WebJs works
37
+
38
+ - **Pages and layouts run only on the server.** They return `html` and never
39
+ hydrate: an `@click` in a page does nothing. Interactivity lives in a
40
+ `WebComponent` custom element; a page imports the component file to
41
+ register it and writes its tag.
42
+ - **`*.server.ts` is the server boundary.** With `'use server';` as the first
43
+ line, its exported async functions are server actions: a page calls them
44
+ directly on the server, a component calls them over RPC (the import becomes
45
+ a typed stub). WITHOUT `'use server'` the file is a server-only utility (the
46
+ DB, secrets, `node:*`, `createAuth`): import it only from other `.server.ts`
47
+ files, `route.ts` or `middleware.ts`, never from a page, layout or component
48
+ (it crashes the browser). So a page reaches data and the session only
49
+ through `'use server'` queries.
50
+ - **Forms post to actions.** `<form action=${someAction}>` is the whole wiring
51
+ (no `method`, no `fetch`, works without JavaScript; with JavaScript the router
52
+ applies the result in place). The action receives the `FormData` and returns:
53
+ `{ success: true, redirect: '/path' }` (a 303 to that path), or
54
+ `{ success: false, error?, fieldErrors?, status? }`, which re-renders the
55
+ same page (422) with the result on the page's `actionData`;
56
+ `actionData.values` already holds every submitted text field. A returned
57
+ `Response` (for example from `signIn`) is sent as is.
58
+ - **Control flow:** `notFound()` and `redirect(url)` from `@webjsdev/core`
59
+ throw; use them in pages, layouts and form actions. In a `route.ts` return a
60
+ `Response` instead, and in an action called over RPC return
61
+ `{ success: false, error }` instead of throwing.
62
+
63
+ ### File map
64
+
65
+ ```
66
+ app/layout.ts root layout: the only file that writes <head> content
67
+ app/page.ts /
68
+ app/<seg>/[id]/page.ts dynamic route; params.id is a string
69
+ app/<seg>/[id]/edit/page.ts nested route
70
+ app/not-found.ts the 404 page, also rendered by notFound()
71
+ app/<path>/route.ts HTTP endpoint: export async function GET(req, { params })
72
+ modules/<feature>/queries/<verb-noun>.server.ts reads, 'use server', one function per file
73
+ modules/<feature>/actions/<verb-noun>.server.ts writes, 'use server', one function per file
74
+ modules/<feature>/components/<tag>.ts one custom element per file
75
+ modules/<feature>/utils/*.ts, types.ts pure browser-safe helpers and types
76
+ lib/utils/*.ts app-wide browser-safe helpers
77
+ db/schema.server.ts tables; `db` is in db/connection.server.ts
78
+ test/<feature>/*.test.ts server tests (node:test), run by `npm run test:server`
79
+ ```
80
+
81
+ Import app files through the `#` root alias with the `.ts` extension:
82
+ `import { db } from '#db/connection.server.ts'`.
83
+
84
+ ### Worked example: a signed-in CRUD feature
85
+
86
+ Each block is a whole file. Copy the shape and rename (`posts` becomes your
87
+ resource). Child resources (a project's tasks) follow the same pattern: the
88
+ child table references the parent with `onDelete: 'cascade'`, and every query
89
+ and action checks that the parent belongs to the signed-in user.
90
+
91
+ ```ts
92
+ // db/schema.server.ts (columns.server.ts provides table, pk, text, integer, createdAt, index)
93
+ import { defineRelations } from 'drizzle-orm';
94
+ import { table, pk, text, integer, createdAt, index } from './columns.server.ts';
95
+ import { POST_STATUSES } from '#modules/posts/types.ts';
96
+
97
+ export const users = table('users', {
98
+ id: pk(),
99
+ email: text().notNull().unique(),
100
+ passwordHash: text().notNull(),
101
+ createdAt: createdAt(),
102
+ });
103
+ export const posts = table('posts', {
104
+ id: pk(),
105
+ ownerId: integer().notNull().references(() => users.id, { onDelete: 'cascade' }),
106
+ title: text().notNull(),
107
+ body: text().notNull().default(''),
108
+ status: text({ enum: POST_STATUSES }).notNull().default('draft'),
109
+ publishOn: text(), // 'YYYY-MM-DD' from <input type="date">, or null
110
+ createdAt: createdAt(),
111
+ }, (t) => [index(t.ownerId)]);
112
+ export const relations = defineRelations({ users, posts }, () => ({}));
113
+ export type User = typeof users.$inferSelect;
114
+ export type Post = typeof posts.$inferSelect;
115
+ ```
116
+
117
+ ```ts
118
+ // modules/posts/types.ts (browser-safe: components import this, never the schema)
119
+ export const POST_STATUSES = ['draft', 'review', 'published'] as const;
120
+ export type PostStatus = (typeof POST_STATUSES)[number];
121
+ export interface StatusCounts { draft: number; review: number; published: number; total: number }
122
+ ```
123
+
124
+ ```ts
125
+ // lib/utils/form.ts
126
+ import { html } from '@webjsdev/core';
127
+ import { labelClass } from '#components/ui/label.ts';
128
+ import { inputClass } from '#components/ui/input.ts';
129
+
130
+ /** What a failed form action hands back to the page as `actionData`. */
131
+ export interface FormState { error?: string; fieldErrors?: Record<string, string>; values?: Record<string, string> }
132
+ export const isEmail = (s: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
133
+ export const str = (fd: FormData, k: string) => String(fd.get(k) ?? '').trim();
134
+ export const toId = (v: unknown) => { const n = Number(v); return Number.isInteger(n) && n > 0 ? n : null; };
135
+
136
+ /** A labelled input with its server error under it and the typed value kept. */
137
+ export function field(o: { label: string; name: string; type?: string; value?: string; error?: string; required?: boolean }) {
138
+ return html`
139
+ <div class="grid gap-1.5">
140
+ <label for=${o.name} class=${labelClass()}>${o.label}</label>
141
+ <input id=${o.name} name=${o.name} type=${o.type ?? 'text'} value=${o.value ?? ''} ?required=${o.required}
142
+ aria-invalid=${o.error ? 'true' : 'false'} class=${inputClass()}>
143
+ ${o.error ? html`<p class="text-sm text-destructive">${o.error}</p>` : ''}
144
+ </div>`;
145
+ }
146
+ ```
147
+
148
+ Auth uses the built-in `createAuth` (a signed session cookie) and `node:crypto`
149
+ scrypt. No extra package is needed.
150
+
151
+ ```ts
152
+ // modules/auth/password.server.ts
153
+ import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
154
+ import { promisify } from 'node:util';
155
+ const scryptAsync = promisify(scrypt);
156
+ export async function hashPassword(pw: string) {
157
+ const salt = randomBytes(16).toString('hex');
158
+ return salt + ':' + ((await scryptAsync(pw, salt, 64)) as Buffer).toString('hex');
159
+ }
160
+ export async function verifyPassword(pw: string, stored: string) {
161
+ const [salt, key] = stored.split(':');
162
+ return timingSafeEqual((await scryptAsync(pw, salt, 64)) as Buffer, Buffer.from(key, 'hex'));
163
+ }
164
+ ```
165
+
166
+ ```ts
167
+ // modules/auth/auth.server.ts (server-only: no 'use server')
168
+ import { createAuth, Credentials } from '@webjsdev/server';
169
+ import { db } from '#db/connection.server.ts';
170
+ import { verifyPassword } from './password.server.ts';
171
+
172
+ const secret = process.env.AUTH_SECRET;
173
+ if (!secret) throw new Error('AUTH_SECRET is not set');
174
+ export const { auth, signIn, signOut } = createAuth({
175
+ secret,
176
+ pages: { signIn: '/signin', error: '/signin' },
177
+ providers: [Credentials({
178
+ async authorize(c: { email: string; password: string }) {
179
+ const user = await db.query.users.findFirst({ where: { email: c.email } });
180
+ if (!user || !(await verifyPassword(c.password, user.passwordHash))) return null;
181
+ return { id: String(user.id), email: user.email };
182
+ },
183
+ })],
184
+ });
185
+ export interface SessionUser { id: number; email: string }
186
+ export async function getUser(): Promise<SessionUser | null> {
187
+ const u = (await auth())?.user;
188
+ return u?.id ? { id: Number(u.id), email: String(u.email) } : null;
189
+ }
190
+ ```
191
+
192
+ ```ts
193
+ // modules/auth/queries/current-user.server.ts (for the layout and public pages)
194
+ 'use server';
195
+ import { getUser, type SessionUser } from '../auth.server.ts';
196
+ export async function currentUser(): Promise<SessionUser | null> {
197
+ return getUser();
198
+ }
199
+
200
+ // modules/auth/queries/require-user.server.ts (call first in every signed-in page)
201
+ 'use server';
202
+ import { redirect } from '@webjsdev/core';
203
+ import { getUser, type SessionUser } from '../auth.server.ts';
204
+ export async function requireUser(): Promise<SessionUser> {
205
+ return (await getUser()) ?? redirect('/signin');
206
+ }
207
+ ```
208
+
209
+ ```ts
210
+ // modules/auth/actions/sign-up.server.ts
211
+ 'use server';
212
+ import { db } from '#db/connection.server.ts';
213
+ import { users } from '#db/schema.server.ts';
214
+ import { isEmail, str } from '#lib/utils/form.ts';
215
+ import { hashPassword } from '../password.server.ts';
216
+ import { signIn } from '../auth.server.ts';
217
+
218
+ export async function signUp(fd: FormData) {
219
+ const email = str(fd, 'email').toLowerCase();
220
+ const password = String(fd.get('password') ?? '');
221
+ const fieldErrors: Record<string, string> = {};
222
+ if (!isEmail(email)) fieldErrors.email = 'Enter a valid email address.';
223
+ if (password.length < 8) fieldErrors.password = 'Password must be at least 8 characters.';
224
+ if (!fieldErrors.email && (await db.query.users.findFirst({ where: { email } }))) {
225
+ fieldErrors.email = 'An account with this email already exists.';
226
+ }
227
+ if (Object.keys(fieldErrors).length) return { success: false, fieldErrors };
228
+ await db.insert(users).values({ email, passwordHash: await hashPassword(password) });
229
+ return signIn('credentials', { email, password }, { redirectTo: '/posts' }); // sets the cookie, 302
230
+ }
231
+ ```
232
+
233
+ Sign-in is the same shape: look the user up, `verifyPassword`, return
234
+ `{ success: false, error: 'Invalid email or password.' }` on a mismatch, else
235
+ `return signIn('credentials', { email, password }, { redirectTo: '/posts' })`.
236
+ Sign-out is an action bound to a form in the layout:
237
+
238
+ ```ts
239
+ // modules/auth/actions/sign-out.server.ts
240
+ 'use server';
241
+ import { signOut } from '../auth.server.ts';
242
+ export async function signOutUser(_fd: FormData) {
243
+ return signOut({ redirectTo: '/signin' }); // clears the cookie, 302
244
+ }
245
+ ```
246
+
247
+ ```ts
248
+ // modules/posts/utils/validate-post.ts (pure: shared by create and update, unit-tested)
249
+ import { str } from '#lib/utils/form.ts';
250
+ import { POST_STATUSES, type PostStatus } from '../types.ts';
251
+ export interface PostInput { title: string; body: string; status: PostStatus; publishOn: string | null }
252
+ export function validatePost(fd: FormData) {
253
+ const values = { title: str(fd, 'title'), body: str(fd, 'body'), status: str(fd, 'status') || 'draft', publishOn: str(fd, 'publishOn') };
254
+ const fieldErrors: Record<string, string> = {};
255
+ if (!values.title) fieldErrors.title = 'Title is required.';
256
+ if (!(POST_STATUSES as readonly string[]).includes(values.status)) fieldErrors.status = 'Pick a status.';
257
+ if (values.publishOn && !/^\d{4}-\d{2}-\d{2}$/.test(values.publishOn)) fieldErrors.publishOn = 'Use a valid date.';
258
+ if (Object.keys(fieldErrors).length) return { ok: false as const, fieldErrors, values };
259
+ const data: PostInput = { ...values, status: values.status as PostStatus, publishOn: values.publishOn || null };
260
+ return { ok: true as const, data };
261
+ }
262
+ ```
263
+
264
+ Reads use the relational API (`db.query.<table>.findMany/findFirst` with an
265
+ object `where` and `orderBy`) and always filter by the owner:
266
+
267
+ ```ts
268
+ // modules/posts/queries/get-post.server.ts (list-posts.server.ts is the same with findMany + orderBy: { createdAt: 'desc' })
269
+ 'use server';
270
+ import { db } from '#db/connection.server.ts';
271
+ import type { Post } from '#db/schema.server.ts';
272
+ import { getUser } from '#modules/auth/auth.server.ts';
273
+ import { toId } from '#lib/utils/form.ts';
274
+
275
+ /** The post when it exists AND belongs to the signed-in user, else null (the page throws notFound()). */
276
+ export async function getPost(id: string): Promise<Post | null> {
277
+ const user = await getUser();
278
+ const postId = toId(id);
279
+ if (!user || !postId) return null;
280
+ return (await db.query.posts.findFirst({ where: { id: postId, ownerId: user.id } })) ?? null;
281
+ }
282
+ ```
283
+
284
+ ```ts
285
+ // modules/posts/queries/count-posts.server.ts (one grouped query, never one per row)
286
+ 'use server';
287
+ import { count, eq } from 'drizzle-orm';
288
+ import { db } from '#db/connection.server.ts';
289
+ import { posts } from '#db/schema.server.ts';
290
+ import { getUser } from '#modules/auth/auth.server.ts';
291
+ import type { StatusCounts } from '../types.ts';
292
+
293
+ export async function countPosts(): Promise<StatusCounts> {
294
+ const c: StatusCounts = { draft: 0, review: 0, published: 0, total: 0 };
295
+ const user = await getUser();
296
+ if (!user) return c;
297
+ const rows = await db.select({ status: posts.status, n: count() }).from(posts)
298
+ .where(eq(posts.ownerId, user.id)).groupBy(posts.status);
299
+ for (const r of rows) { c[r.status] = r.n; c.total += r.n; }
300
+ return c;
301
+ }
302
+ ```
303
+
304
+ Writes use the query builder with `eq` / `and`, and put the owner in the
305
+ `where` so another user's id changes nothing. `create-post.server.ts` is
306
+ `validatePost`, then `db.insert(posts).values({ ...v.data, ownerId: user.id }).returning()`,
307
+ then `{ success: true, redirect: '/posts/' + post.id }`. `delete-post.server.ts`
308
+ reads the id from a hidden input and returns `{ success: true, redirect: '/posts' }`.
309
+
310
+ ```ts
311
+ // modules/posts/actions/update-post.server.ts
312
+ 'use server';
313
+ import { and, eq } from 'drizzle-orm';
314
+ import { db } from '#db/connection.server.ts';
315
+ import { posts } from '#db/schema.server.ts';
316
+ import { getUser } from '#modules/auth/auth.server.ts';
317
+ import { toId } from '#lib/utils/form.ts';
318
+ import { validatePost } from '../utils/validate-post.ts';
319
+
320
+ export async function updatePost(fd: FormData) {
321
+ const user = await getUser();
322
+ const id = toId(fd.get('id'));
323
+ if (!user || !id) return { success: false, error: 'Not found.', status: 404 };
324
+ const v = validatePost(fd);
325
+ if (!v.ok) return { success: false, fieldErrors: v.fieldErrors, values: v.values };
326
+ const rows = await db.update(posts).set(v.data).where(and(eq(posts.id, id), eq(posts.ownerId, user.id))).returning();
327
+ if (!rows.length) return { success: false, error: 'Not found.', status: 404 };
328
+ return { success: true, redirect: `/posts/${id}` };
329
+ }
330
+ ```
331
+
332
+ An action a component calls over RPC takes a typed object, checks it, and
333
+ returns a result (it never throws or redirects):
334
+
335
+ ```ts
336
+ // modules/posts/actions/set-post-status.server.ts
337
+ 'use server';
338
+ import { and, eq } from 'drizzle-orm';
339
+ import { db } from '#db/connection.server.ts';
340
+ import { posts } from '#db/schema.server.ts';
341
+ import { getUser } from '#modules/auth/auth.server.ts';
342
+ import { POST_STATUSES, type PostStatus } from '../types.ts';
343
+
344
+ export interface SetStatusInput { id: number; status: PostStatus }
345
+ export async function setPostStatus(input: SetStatusInput) {
346
+ const user = await getUser();
347
+ if (!user) return { success: false, error: 'Sign in first.', status: 401 };
348
+ if (!POST_STATUSES.includes(input.status)) return { success: false, error: 'Bad status.', status: 400 };
349
+ const rows = await db.update(posts).set({ status: input.status })
350
+ .where(and(eq(posts.id, Number(input.id)), eq(posts.ownerId, user.id))).returning();
351
+ return rows.length ? { success: true } : { success: false, error: 'Not found.', status: 404 };
352
+ }
353
+ ```
354
+
355
+ A component declares reactive properties in the `WebComponent({...})` factory
356
+ (attributes arrive kebab-cased: `postId` is `post-id`), keeps local state in
357
+ signals, and binds events with an unquoted `@event=${fn}`:
358
+
359
+ ```ts
360
+ // modules/posts/components/post-status.ts
361
+ import { WebComponent, html, signal } from '@webjsdev/core';
362
+ import { setPostStatus } from '../actions/set-post-status.server.ts';
363
+ import { POST_STATUSES, type PostStatus } from '../types.ts';
364
+ import { labelClass } from '#components/ui/label.ts';
365
+ import { nativeSelectClass } from '#components/ui/native-select.ts';
366
+
367
+ /** Status select that saves on change over RPC, with no page reload. */
368
+ export class PostStatusSelect extends WebComponent({ postId: Number, status: String }) {
369
+ note = signal('');
370
+ async onChange(e: Event) {
371
+ const select = e.target as HTMLSelectElement;
372
+ const before = this.status;
373
+ this.status = select.value;
374
+ const res = await setPostStatus({ id: this.postId, status: select.value as PostStatus });
375
+ if (res.success) this.note.set('Saved');
376
+ else { this.status = before; select.value = before; this.note.set(res.error ?? 'Could not save'); }
377
+ }
378
+ render() {
379
+ const id = `status-${this.postId}`;
380
+ return html`
381
+ <div class="flex items-center gap-2">
382
+ <label for=${id} class=${labelClass()}>Status</label>
383
+ <select id=${id} class=${nativeSelectClass()} @change=${(e: Event) => this.onChange(e)}>
384
+ ${POST_STATUSES.map((s) => html`<option value=${s} ?selected=${s === this.status}>${s}</option>`)}
385
+ </select>
386
+ <span class="text-xs text-muted-foreground" aria-live="polite">${this.note.get()}</span>
387
+ </div>`;
388
+ }
389
+ }
390
+ PostStatusSelect.register('post-status');
391
+ ```
392
+
393
+ ```ts
394
+ // app/layout.ts
395
+ import { html, asset } from '@webjsdev/core';
396
+ import type { LayoutProps } from '@webjsdev/core';
397
+ import { buttonClass } from '#components/ui/button.ts';
398
+ import { currentUser } from '#modules/auth/queries/current-user.server.ts';
399
+ import { signOutUser } from '#modules/auth/actions/sign-out.server.ts';
400
+
401
+ export const metadata = { title: { default: 'Posts', template: '%s | Posts' } };
402
+ export default async function RootLayout({ children }: LayoutProps) {
403
+ const user = await currentUser();
404
+ return html`
405
+ <meta name="viewport" content="width=device-width, initial-scale=1">
406
+ <link rel="stylesheet" href=${asset('/public/tailwind.css')}>
407
+ <script>if (matchMedia('(prefers-color-scheme: dark)').matches) document.documentElement.classList.add('dark');</script>
408
+ <style>
409
+ :root {
410
+ color-scheme: light dark;
411
+ --background: light-dark(#ffffff, #14161a); --foreground: light-dark(#17191c, #e6e8eb);
412
+ --card: light-dark(#f7f8fa, #1c1f24); --card-foreground: var(--foreground);
413
+ --primary: light-dark(#2f5bd3, #8fb0ff); --primary-foreground: light-dark(#ffffff, #0b1530);
414
+ --secondary: light-dark(#eef0f3, #2a2e34); --secondary-foreground: var(--foreground);
415
+ --muted: light-dark(#f1f3f5, #23272d); --muted-foreground: light-dark(#5b626b, #9aa1aa);
416
+ --accent: light-dark(#e9edf5, #2a3140); --accent-foreground: var(--foreground);
417
+ --border: light-dark(#e2e5e9, #343a42); --input: var(--border); --ring: light-dark(#8aa4e8, #5b78c4);
418
+ --destructive: light-dark(#c0362c, #f28b82);
419
+ }
420
+ body { margin: 0; background: var(--background); color: var(--foreground); font: 15px/1.6 system-ui, sans-serif; }
421
+ </style>
422
+ <header class="fixed inset-x-0 top-0 z-40 h-14 border-b border-border bg-background/95 backdrop-blur">
423
+ <nav class="mx-auto flex h-full max-w-4xl items-center gap-4 px-4">
424
+ <a href="/" class="font-semibold text-foreground no-underline">Posts</a>
425
+ ${user ? html`
426
+ <span class="ml-auto hidden text-sm text-muted-foreground sm:inline">${user.email}</span>
427
+ <form action=${signOutUser} class="ml-auto sm:ml-0"><button class=${buttonClass({ variant: 'outline', size: 'sm' })}>Sign out</button></form>`
428
+ : html`<a href="/signin" class="ml-auto text-sm">Sign in</a>`}
429
+ </nav>
430
+ </header>
431
+ <main class="mx-auto min-h-dvh max-w-4xl px-4 pb-16 pt-20 text-foreground">${children}</main>`;
432
+ }
433
+ ```
434
+
435
+ ```ts
436
+ // app/page.ts
437
+ import { redirect } from '@webjsdev/core';
438
+ import { currentUser } from '#modules/auth/queries/current-user.server.ts';
439
+ export default async function Home() {
440
+ redirect((await currentUser()) ? '/posts' : '/signin');
441
+ }
442
+ ```
443
+
444
+ A page with a form reads `actionData` (typed with `FormState`). The sign-in
445
+ and sign-up pages are this shape too, with `if (await currentUser()) redirect('/posts');`
446
+ first and `actionData.error` shown above the fields.
447
+
448
+ ```ts
449
+ // app/posts/page.ts
450
+ import { html } from '@webjsdev/core';
451
+ import type { PageProps } from '@webjsdev/core';
452
+ import { buttonClass } from '#components/ui/button.ts';
453
+ import { cardClass } from '#components/ui/card.ts';
454
+ import { field, type FormState } from '#lib/utils/form.ts';
455
+ import { requireUser } from '#modules/auth/queries/require-user.server.ts';
456
+ import { listPosts } from '#modules/posts/queries/list-posts.server.ts';
457
+ import { countPosts } from '#modules/posts/queries/count-posts.server.ts';
458
+ import { createPost } from '#modules/posts/actions/create-post.server.ts';
459
+
460
+ export const metadata = { title: 'Your posts' };
461
+ export default async function PostsPage({ actionData }: PageProps<'/posts'> & { actionData?: FormState }) {
462
+ await requireUser();
463
+ const [items, counts] = await Promise.all([listPosts(), countPosts()]);
464
+ const e = actionData?.fieldErrors ?? {};
465
+ const v = actionData?.values ?? {};
466
+ return html`
467
+ <h1 class="text-2xl font-semibold">Your posts</h1>
468
+ <p class="mt-1 text-sm text-muted-foreground">${counts.total} total, ${counts.published} published</p>
469
+ <form action=${createPost} class="${cardClass()} mt-6 grid gap-3 p-4 sm:grid-cols-[1fr_auto] sm:items-end">
470
+ ${field({ label: 'Title', name: 'title', value: v.title, error: e.title, required: true })}
471
+ <button class=${buttonClass()}>Create post</button>
472
+ </form>
473
+ <ul class="mt-6 grid gap-3 sm:grid-cols-2">
474
+ ${items.map((p) => html`
475
+ <li class="${cardClass()} p-4">
476
+ <a href="/posts/${p.id}" class="font-medium text-foreground">${p.title}</a>
477
+ <p class="mt-1 text-sm text-muted-foreground">${p.status}</p>
478
+ </li>`)}
479
+ </ul>
480
+ ${items.length ? '' : html`<p class="mt-6 text-muted-foreground">No posts yet.</p>`}`;
481
+ }
482
+ ```
483
+
484
+ ```ts
485
+ // app/posts/[id]/page.ts
486
+ import { html, notFound } from '@webjsdev/core';
487
+ import type { PageProps } from '@webjsdev/core';
488
+ import { buttonClass } from '#components/ui/button.ts';
489
+ import { requireUser } from '#modules/auth/queries/require-user.server.ts';
490
+ import { getPost } from '#modules/posts/queries/get-post.server.ts';
491
+ import { deletePost } from '#modules/posts/actions/delete-post.server.ts';
492
+ import '#modules/posts/components/post-status.ts'; // registers <post-status>
493
+
494
+ export default async function PostPage({ params }: PageProps<'/posts/[id]'>) {
495
+ await requireUser();
496
+ const post = await getPost(params.id);
497
+ if (!post) notFound();
498
+ return html`
499
+ <h1 class="text-2xl font-semibold">${post.title}</h1>
500
+ ${post.body ? html`<p class="mt-3 whitespace-pre-line">${post.body}</p>` : ''}
501
+ <div class="mt-6 flex flex-wrap items-center gap-3">
502
+ <post-status post-id=${post.id} status=${post.status}></post-status>
503
+ <a href="/posts/${post.id}/edit" class=${buttonClass({ variant: 'outline', size: 'sm' })}>Edit</a>
504
+ <form action=${deletePost} onsubmit="return confirm('Delete this post?')">
505
+ <input type="hidden" name="id" value=${post.id}>
506
+ <button class=${buttonClass({ variant: 'destructive', size: 'sm' })}>Delete</button>
507
+ </form>
508
+ </div>`;
509
+ }
510
+ ```
511
+
512
+ The edit page loads the row the same way, pre-fills from it
513
+ (`const v = actionData?.values ?? { title: post.title, ... }`), and posts a
514
+ hidden `id` to `updatePost`. A `<textarea class=${textareaClass()}>` holds its
515
+ value as text content; a `<select class=${nativeSelectClass()}>` marks the
516
+ current option with `?selected=${s === v.status}`. Both need a `<label for>`.
517
+
518
+ ```ts
519
+ // app/not-found.ts
520
+ import { html } from '@webjsdev/core';
521
+ export default function NotFound() {
522
+ return html`<h1 class="text-2xl font-semibold">Not found</h1><p class="mt-2 text-muted-foreground"><a href="/">Go home</a></p>`;
523
+ }
524
+ ```
525
+
526
+ ```ts
527
+ // test/posts/validate-post.test.ts
528
+ import { test } from 'node:test';
529
+ import assert from 'node:assert/strict';
530
+ import { validatePost } from '#modules/posts/utils/validate-post.ts';
531
+ const fd = (o: Record<string, string>) => { const f = new FormData(); for (const [k, v] of Object.entries(o)) f.set(k, v); return f; };
532
+ test('a post needs a title', () => {
533
+ const r = validatePost(fd({ title: ' ' }));
534
+ assert.equal(r.ok ? '' : r.fieldErrors.title, 'Title is required.');
535
+ });
536
+ ```
537
+
538
+ ### Look and the UI kit
539
+
540
+ - The palette is the token block in the layout's `<style>`, each colour
541
+ written once as `light-dark(LIGHT, DARK)`; `public/input.css` maps the tokens
542
+ into Tailwind. Pick values that fit the product, and style only with token
543
+ utilities:
544
+ `bg-background text-foreground bg-card text-card-foreground bg-primary
545
+ text-primary-foreground bg-muted text-muted-foreground border-border
546
+ text-destructive ring-ring`. Never a raw colour such as `bg-blue-600`.
547
+ - Pin the header with `position: fixed` (never `sticky`) and offset the
548
+ content by its height, as the layout above does. Mobile first: one column
549
+ that widens at `sm:` / `md:`.
550
+ - The kit copies class helpers into `components/ui/` (you own them; no need to
551
+ open them): `buttonClass({ variant?: 'default' | 'destructive' | 'outline' |
552
+ 'secondary' | 'ghost' | 'link', size?: 'default' | 'xs' | 'sm' | 'lg' |
553
+ 'icon' })`, `inputClass()`, `textareaClass()`, `labelClass()`,
554
+ `nativeSelectClass()`, `cardClass({ size?: 'default' | 'sm' })`,
555
+ `badgeClass({ variant?: 'default' | 'secondary' | 'destructive' | 'outline' })`.
556
+ Use them as `class=${buttonClass({ variant: 'outline' })}` on native
557
+ elements. Stateful widgets (dialog, tabs, dropdown menu, tooltip, toasts) are
558
+ custom elements: `npx webjsdev ui add dialog`, then `npx webjsdev ui view dialog`
559
+ for the tags.
118
560
 
119
561
  ### Commands
120
562
 
121
563
  ```sh
122
- npm install
123
- npm run gallery:clear # shed the demo gallery before building a real app
124
- npm run dev # dev server at http://localhost:8080
125
- npm run start # production server
126
- npm test # unit + browser tests
127
- npm run typecheck
128
- npm run css:build # compile Tailwind
129
- npm run ci # every gate, one command (the webjs.ci steps in package.json)
130
- npm run check # correctness checks
131
- npm run doctor # project health (severity per check: webjs.doctor.gate)
132
- npx webjsdev ui add <name> # copy a ui primitive into components/ui/
133
- npx webjsdev ui view <name> # inspect a primitive's exact signature
134
- npm run db:generate && npm run db:migrate
564
+ npm run dev # dev server; PORT=<port> to choose the port
565
+ npm run db:generate && npm run db:migrate # after every schema change
566
+ npm run check # framework rules (boundaries, forms, components)
567
+ npm run typecheck # TypeScript
568
+ npm run test:server # node:test files under test/
569
+ npm run ci # every gate, before you push
570
+ npx webjsdev ui add <name> # copy a UI kit primitive into components/ui/
135
571
  ```
@@ -147,8 +147,8 @@ for (const f of ['db/dev.db', 'db/dev.db-shm', 'db/dev.db-wal']) rm(f);
147
147
  // children before test/ so it reads as empty.
148
148
  for (const d of ['app/api', 'test/unit', 'test/e2e', 'test']) if (pruneEmpty(d)) removed++;
149
149
 
150
- console.log(`Gallery cleared (${removed} paths removed). The agent skill and your database wiring are kept. Build your own design system: run \`npx webjsdev ui add <name>\` and theme it (see .agents/skills/webjs/references/styling.md).`);
151
- console.log('Next: regenerate the database (db:generate then db:migrate), then start the dev server and build your app in app/ and modules/.');
150
+ console.log(`Gallery cleared (${removed} paths removed). The agent docs and your database wiring are kept.`);
151
+ console.log('Next: follow the build steps in AGENTS.md (schema, then db:generate and db:migrate, then modules/ and app/).');
152
152
 
153
153
  function MINIMAL_PAGE() {
154
154
  return `import { html } from '@webjsdev/core';
@@ -163,7 +163,7 @@ export default function Home() {
163
163
  <h1 class="text-4xl font-bold tracking-tight m-0">Your app</h1>
164
164
  <p class="text-base leading-relaxed m-0 opacity-70">
165
165
  The gallery is cleared. This is <code class="text-[0.9em]">app/page.ts</code>. Build your
166
- app from here. The guide is <code class="text-[0.9em]">.agents/skills/webjs/SKILL.md</code>.
166
+ app from here. The guide is <code class="text-[0.9em]">AGENTS.md</code>.
167
167
  </p>
168
168
  <nav class="flex items-center gap-5 text-sm opacity-70">
169
169
  <a href="https://webjs.dev/docs" target="_blank" rel="noopener" class="hover:opacity-100 transition-opacity no-underline">Docs</a>