@webjsdev/cli 0.10.19 → 0.10.21

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
@@ -2,6 +2,7 @@
2
2
  import { resolve, join, dirname } from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { resolveBin } from '../lib/resolve-bin.js';
5
6
  import { checkNodeInline, nodeInlineMessage } from '../lib/node-preflight.js';
6
7
  import { loadAppEnv, resolvePort } from '../lib/port.js';
7
8
  import { planDevSupervisor } from '../lib/dev-supervisor.js';
@@ -49,8 +50,10 @@ const USAGE = `webjs commands:
49
50
  webjs doctor Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook)
50
51
  webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
51
52
  webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors)
52
- webjs create <name> [--template full-stack|api|saas] [--db sqlite|postgres] [--no-install] Scaffold a new webjs app
53
- (only 3 templates exist. default: full-stack, Drizzle, --db sqlite)
53
+ webjs create <name> [--template full-stack|api|saas] [--db sqlite|postgres] [--runtime node|bun] [--no-install] Scaffold a new webjs app
54
+ (only 3 templates exist. default: full-stack, Drizzle, --db sqlite, --runtime node)
55
+ --runtime bun emits a Bun-flavored app (bun.lock, bun Dockerfile/CI, bun docs);
56
+ also auto-detected when run via "bun create webjs".
54
57
  Auto-runs the detected package manager's install in the new dir
55
58
  unless --no-install is passed.
56
59
  webjs db generate Generate a SQL migration from the schema (drizzle-kit generate)
@@ -216,7 +219,22 @@ async function main() {
216
219
  const map = { generate: ['generate'], migrate: ['migrate'], push: ['push'], studio: ['studio'] };
217
220
  const kitArgs = map[sub];
218
221
  if (!kitArgs) { console.error('Unknown db subcommand.\n' + USAGE); process.exit(1); }
219
- const child = spawn('npx', ['drizzle-kit', ...kitArgs, ...args], { stdio: 'inherit', cwd: process.cwd() });
222
+ // Resolve the app's own drizzle-kit bin and spawn it with the CURRENT
223
+ // runtime (process.execPath). This drops the hard `npx` dependency (#570):
224
+ // `npx` is absent in a pure oven/bun image, which broke `webjs db migrate`
225
+ // at boot. On Node this is `node drizzle-kit`, on Bun `bun drizzle-kit`
226
+ // (drizzle-kit runs under both).
227
+ let dkPath;
228
+ try {
229
+ dkPath = resolveBin(process.cwd(), 'drizzle-kit', 'drizzle-kit');
230
+ } catch {
231
+ console.error(
232
+ 'webjs db: drizzle-kit is not installed in this project.\n' +
233
+ 'Install it with `npm install -D drizzle-kit`, then re-run `webjs db ' + sub + '`.',
234
+ );
235
+ process.exit(1);
236
+ }
237
+ const child = spawn(process.execPath, [dkPath, ...kitArgs, ...args], { stdio: 'inherit', cwd: process.cwd() });
220
238
  child.on('exit', (code) => process.exit(code ?? 0));
221
239
  break;
222
240
  }
@@ -296,7 +314,14 @@ async function main() {
296
314
 
297
315
  if (testFiles.length > 0) {
298
316
  console.log(`webjs test: running ${testFiles.length} server test file(s)…\n`);
299
- const child = spawn(process.execPath, ['--test', ...testFiles], {
317
+ // Dispatch to the current runtime's test runner (#570). Node uses
318
+ // `node --test <files>`; Bun's runner is the `bun test <files>`
319
+ // subcommand (`bun --test` is invalid). process.execPath is the
320
+ // active runtime, so the args differ but the runner is native to it.
321
+ const testArgs = process.versions.bun
322
+ ? ['test', ...testFiles]
323
+ : ['--test', ...testFiles];
324
+ const child = spawn(process.execPath, testArgs, {
300
325
  stdio: 'inherit', cwd, env: { ...process.env },
301
326
  });
302
327
  const code = await new Promise(r => child.on('exit', r));
@@ -306,25 +331,32 @@ async function main() {
306
331
 
307
332
  // --- Browser tests (WTR + Playwright) ---
308
333
  if (runBrowser) {
309
- const wtrConfig = join(cwd, 'web-test-runner.config.js');
310
- if (existsSync(wtrConfig) || existsSync(join(cwd, 'web-test-runner.config.mjs'))) {
334
+ const hasConfig = existsSync(join(cwd, 'web-test-runner.config.js'))
335
+ || existsSync(join(cwd, 'web-test-runner.config.mjs'));
336
+ // Fall back to the test/browser dir only when there is no explicit config.
337
+ const useBrowserDir = !hasConfig && !serverOnly && existsSync(join(cwd, 'test', 'browser'));
338
+ // Only resolve + run when there is actually something to run, so a
339
+ // `webjs test` with no browser tests stays a no-op (not a hard error).
340
+ if (hasConfig || useBrowserDir) {
341
+ // Resolve the app's @web/test-runner bin and spawn it with the current
342
+ // runtime, dropping `npx` (#570; absent in a pure oven/bun image).
343
+ let wtrPath;
344
+ try {
345
+ wtrPath = resolveBin(cwd, '@web/test-runner', 'wtr');
346
+ } catch {
347
+ console.error(
348
+ '\nwebjs test --browser: @web/test-runner is not installed in this project.\n' +
349
+ 'Install it with `npm install -D @web/test-runner @web/test-runner-playwright`.',
350
+ );
351
+ process.exit(1);
352
+ }
311
353
  console.log(`\nwebjs test: running browser tests (WTR + Playwright)…\n`);
312
- const child = spawn('npx', ['wtr'], {
354
+ const wtrArgs = hasConfig ? [wtrPath] : [wtrPath, '--files', 'test/browser/**/*.test.js'];
355
+ const child = spawn(process.execPath, wtrArgs, {
313
356
  stdio: 'inherit', cwd, env: { ...process.env },
314
357
  });
315
358
  const code = await new Promise(r => child.on('exit', r));
316
359
  if (code !== 0) process.exit(code ?? 1);
317
- } else if (!serverOnly) {
318
- // No WTR config, check for test/browser directory
319
- const browserDir = join(cwd, 'test', 'browser');
320
- if (existsSync(browserDir)) {
321
- console.log(`\nwebjs test: running browser tests (WTR + Playwright)…\n`);
322
- const child = spawn('npx', ['wtr', '--files', 'test/browser/**/*.test.js'], {
323
- stdio: 'inherit', cwd, env: { ...process.env },
324
- });
325
- const code = await new Promise(r => child.on('exit', r));
326
- if (code !== 0) process.exit(code ?? 1);
327
- }
328
360
  }
329
361
  }
330
362
 
@@ -492,8 +524,16 @@ Full docs: https://docs.webjs.com`);
492
524
  const noInstall = rest.includes('--no-install');
493
525
  // --db picks the database dialect: sqlite (default) or postgres.
494
526
  const db = flag(rest, '--db', 'sqlite');
527
+ // --runtime picks the target runtime: node (default) or bun. Orthogonal
528
+ // to --template (#541). When omitted, scaffoldApp auto-detects bun from
529
+ // the invoking PM (so `bun create webjs` implies bun).
530
+ const runtime = flag(rest, '--runtime');
531
+ if (runtime && !['node', 'bun'].includes(runtime)) {
532
+ console.error(`Error: unknown --runtime '${runtime}'. Only node / bun are supported.`);
533
+ process.exit(1);
534
+ }
495
535
  const { scaffoldApp } = await import('../lib/create.js');
496
- await scaffoldApp(name, process.cwd(), { template, db, install: !noInstall });
536
+ await scaffoldApp(name, process.cwd(), { template, db, runtime, install: !noInstall });
497
537
  break;
498
538
  }
499
539
  case 'vendor': {
package/lib/create.js CHANGED
@@ -17,6 +17,7 @@ import { fileURLToPath } from 'node:url';
17
17
  import { existsSync } from 'node:fs';
18
18
  import { createRequire } from 'node:module';
19
19
  import { spawnSync } from 'node:child_process';
20
+ import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi } from './runtime-rewrite.js';
20
21
 
21
22
  /**
22
23
  * Detect which package manager invoked us. Reads `npm_config_user_agent`,
@@ -259,6 +260,19 @@ export async function scaffoldApp(name, cwd, opts = {}) {
259
260
  if (!VALID_DIALECTS.includes(dialect)) {
260
261
  throw new Error(`Unknown --db '${dialect}'. Only ${VALID_DIALECTS.join(' / ')} are supported.`);
261
262
  }
263
+
264
+ // Runtime axis (#541), ORTHOGONAL to --template (the exactly-3-templates
265
+ // invariant is untouched). Default node; bun opt-in via `--runtime bun` OR
266
+ // auto-detected when the scaffold is invoked through bun (`bun create webjs`),
267
+ // with the explicit flag winning over detection. A bun-flavored app SERVES on
268
+ // Bun (its dev/start scripts force `--bun`), commits `bun.lock`, sets
269
+ // `trustedDependencies`, and ships a bun Dockerfile / CI / agent docs.
270
+ const runtime = opts.runtime || (detectPackageManager() === 'bun' ? 'bun' : 'node');
271
+ const VALID_RUNTIMES = ['node', 'bun'];
272
+ if (!VALID_RUNTIMES.includes(runtime)) {
273
+ throw new Error(`Unknown --runtime '${runtime}'. Only ${VALID_RUNTIMES.join(' / ')} are supported.`);
274
+ }
275
+ const isBun = runtime === 'bun';
262
276
  const appDir = join(cwd, name);
263
277
  if (existsSync(appDir)) {
264
278
  console.error(`Error: directory '${name}' already exists.`);
@@ -303,8 +317,19 @@ export async function scaffoldApp(name, cwd, opts = {}) {
303
317
  // the start orchestration (`webjs db migrate`), run INSIDE `webjs start`,
304
318
  // so `npm run start` (a thin alias) behaves identically. Drizzle has no
305
319
  // codegen, so there is no dev `before` step.
306
- dev: 'webjs dev',
307
- start: 'webjs start',
320
+ //
321
+ // Bun runtime (#541): the long-running server scripts (`dev` / `start`)
322
+ // are prefixed `bun --bun` so the app SERVES on Bun. The `--bun` overrides
323
+ // the `webjs` bin's `#!/usr/bin/env node` shebang (without it `bun run dev`
324
+ // would exec webjs under Node, silently running the "bun" app on Node).
325
+ // Baking it into the script body means a plain `bun run dev` (or even
326
+ // `npm run dev`) starts on Bun, so a user never has to remember the flag.
327
+ // The runtime-neutral tooling scripts below (test / db / check / typecheck
328
+ // / doctor) stay plain `webjs ...`: they spawn node tooling (`node --test`,
329
+ // drizzle-kit, tsc) and forcing `--bun` there buys nothing (and `webjs
330
+ // test` shells `node --test`, which a `bun --test` would not be).
331
+ dev: isBun ? 'bun --bun webjs dev' : 'webjs dev',
332
+ start: isBun ? 'bun --bun webjs start' : 'webjs start',
308
333
  test: 'webjs test',
309
334
  'test:server': 'webjs test --server',
310
335
  'test:browser': 'webjs test --browser',
@@ -371,6 +396,14 @@ export async function scaffoldApp(name, cwd, opts = {}) {
371
396
  // applies pending migrations at boot via `webjs db migrate` (drizzle-kit).
372
397
  start: { before: ['webjs db migrate'] },
373
398
  },
399
+ // Bun runtime (#541): Bun does NOT run a dependency's postinstall by default
400
+ // (a security default); a package must be listed here for its install script
401
+ // to run on `bun install`. The sqlite driver `better-sqlite3` fetches its
402
+ // native prebuild in a postinstall, so without this the native binding is
403
+ // missing and the app crashes at first DB access. Postgres `pg` is pure JS
404
+ // (no postinstall), so the list is sqlite-only. Omitted entirely on Node
405
+ // (npm runs postinstalls), keeping the node-mode package.json byte-identical.
406
+ ...(isBun && dialect !== 'postgres' ? { trustedDependencies: ['better-sqlite3'] } : {}),
374
407
  }, null, 2) + '\n');
375
408
 
376
409
  await writeFile(join(appDir, 'tsconfig.json'), JSON.stringify({
@@ -484,12 +517,39 @@ export async function scaffoldApp(name, cwd, opts = {}) {
484
517
  'compose.yaml',
485
518
  '.dockerignore',
486
519
  ];
520
+ // Bun runtime (#541): the agent-config markdown shows bun commands, and the
521
+ // deploy files (Dockerfile / compose / CI) run on Bun. Each is DERIVED from
522
+ // the canonical node template by a pure transform (see runtime-rewrite.js), so
523
+ // there is no parallel bun template to drift. Prose files get the command
524
+ // rewrites; the three infra files get their file-specific transform. On Node,
525
+ // every file is copied byte-identical (the map is empty).
526
+ const PROSE_REWRITE = new Set([
527
+ 'AGENTS.md', 'CONVENTIONS.md', '.cursorrules',
528
+ '.agents/rules/workflow.md', '.github/copilot-instructions.md',
529
+ // The starter tests carry header comments with run commands (`npx wtr`,
530
+ // `npm i -D puppeteer-core`); bun-ify those too so a bun app's test files
531
+ // do not tell the user to run npm/npx (#541 review). The transform only
532
+ // touches npm/npx command tokens, so the test code itself is unaffected.
533
+ 'test/hello/browser/hello.test.js', 'test/hello/e2e/hello.test.ts',
534
+ ]);
535
+ // compose.yaml builds from the (pure oven/bun) Dockerfile and inherits its
536
+ // `bun --bun run start` CMD; only its healthcheck needs switching off node
537
+ // (the pure Bun image has no node), which bunifyCompose does.
538
+ const FILE_REWRITE = {
539
+ 'Dockerfile': bunifyDockerfile,
540
+ 'compose.yaml': bunifyCompose,
541
+ '.github/workflows/ci.yml': bunifyCi,
542
+ };
487
543
  for (const f of templateFiles) {
488
544
  const src = join(TEMPLATES, f);
489
545
  if (existsSync(src)) {
490
546
  await mkdir(dirname(join(appDir, f)), { recursive: true });
491
547
  let content = await readFile(src, 'utf8');
492
548
  content = content.replace(/\{\{APP_NAME\}\}/g, name);
549
+ if (isBun) {
550
+ if (PROSE_REWRITE.has(f)) content = bunifyProse(content);
551
+ else if (FILE_REWRITE[f]) content = FILE_REWRITE[f](content);
552
+ }
493
553
  await writeFile(join(appDir, f), content);
494
554
  }
495
555
  }
@@ -717,6 +777,15 @@ export default cors({
717
777
 
718
778
  await writeFile(join(appDir, 'modules', 'users', 'queries', 'list-users.server.ts'), `'use server';
719
779
 
780
+ // A GET server action (#488): a read declares its HTTP semantics via reserved
781
+ // sibling exports the framework reads statically. 'method' makes the call ride
782
+ // the URL (cacheable, ETag/304-aware, SSR-seeded on first paint); 'cache' is the
783
+ // max-age in seconds (private by default, do NOT add { public: true } unless the
784
+ // data is identical for EVERY visitor); 'tags' label the cached entry so a
785
+ // mutation can evict it. One function per file.
786
+ export const method = 'GET';
787
+ export const cache = 30;
788
+ export const tags = () => ['users'];
720
789
  export async function listUsers() {
721
790
  // TODO: replace with real data source
722
791
  return [
@@ -727,6 +796,11 @@ export async function listUsers() {
727
796
  `);
728
797
  await writeFile(join(appDir, 'modules', 'users', 'actions', 'create-user.server.ts'), `'use server';
729
798
 
799
+ // A mutation server action (#488). With no 'method' export it defaults to POST
800
+ // (CSRF-protected, rich request body). 'invalidates' lists the cache tags to
801
+ // evict on success, so the next listUsers() read refetches fresh instead of
802
+ // serving a stale browser-cached value. One function per file.
803
+ export const invalidates = () => ['users'];
730
804
  export async function createUser(input: { name: string; email: string }) {
731
805
  // TODO: validate input, persist to database
732
806
  return { success: true, data: { id: Date.now().toString(), ...input } };
@@ -1173,7 +1247,7 @@ ThemeToggle.register('theme-toggle');
1173
1247
  // --- SaaS template extras: auth, dashboard, drizzle User model ---
1174
1248
  if (isSaas) {
1175
1249
  const { writeSaasFiles } = await import('./saas-template.js');
1176
- await writeSaasFiles(appDir);
1250
+ await writeSaasFiles(appDir, { runtime });
1177
1251
  }
1178
1252
 
1179
1253
  // AGENTS.md is already in place via the shared `templateFiles` loop
@@ -1264,7 +1338,10 @@ For AI agents, read this before editing scaffolded files:
1264
1338
  // pnpm / yarn / bun users get their own. Pass `--no-install` (or
1265
1339
  // `{ install: false }` to scaffoldApp) to opt out, e.g. for CI tests
1266
1340
  // that exercise the scaffold without paying the install cost.
1267
- const pm = detectPackageManager();
1341
+ // In bun mode, install with bun regardless of the invoking PM, so the app
1342
+ // commits `bun.lock` (text JSONC, git-diffable) instead of `package-lock.json`
1343
+ // (#541). Otherwise honour the invoking PM (npm / pnpm / yarn / bun).
1344
+ const pm = isBun ? 'bun' : detectPackageManager();
1268
1345
  let installed = false;
1269
1346
  if (shouldInstall) {
1270
1347
  console.log(`Running '${pm} install' in ${name}/ ...\n`);
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Resolve a dependency's executable from an app's node_modules (#570).
3
+ *
4
+ * `webjs db` / `webjs test --browser` used to shell `npx drizzle-kit` / `npx
5
+ * wtr`, but `npx` is absent in a pure `oven/bun` image (and the whole point of
6
+ * runtime-native commands is to run under whatever the current runtime is). So
7
+ * instead resolve the tool's bin from the APP's node_modules and let the caller
8
+ * spawn it with `process.execPath` (Node or Bun), the same pattern `webjs
9
+ * typecheck` uses for the app's `tsc`.
10
+ *
11
+ * The wrinkle: CLIs like drizzle-kit and @web/test-runner do NOT expose
12
+ * `./package.json` or their bin subpath in `exports`, so `require.resolve(
13
+ * 'drizzle-kit/bin.cjs')` throws `ERR_PACKAGE_PATH_NOT_EXPORTED`. The `.` main
14
+ * entry DOES resolve, so resolve that, walk up to the package root (the nearest
15
+ * dir with a package.json), and read the `bin` field, which is version-robust.
16
+ */
17
+ import { createRequire } from 'node:module';
18
+ import { readFileSync, existsSync } from 'node:fs';
19
+ import { join, dirname, resolve } from 'node:path';
20
+
21
+ /**
22
+ * @param {string} cwd the app root (a dir containing the app's package.json)
23
+ * @param {string} pkgName the dependency package name (e.g. 'drizzle-kit')
24
+ * @param {string} binName the key in the package's `bin` map (e.g. 'wtr');
25
+ * ignored when `bin` is a plain string
26
+ * @returns {string} absolute path to the bin's JS entry
27
+ * @throws if the package is not installed or has no matching bin
28
+ */
29
+ export function resolveBin(cwd, pkgName, binName) {
30
+ const req = createRequire(join(cwd, 'package.json'));
31
+ // `.` (the main entry) is exported even when subpaths are not.
32
+ let pkgDir = dirname(req.resolve(pkgName));
33
+ while (!existsSync(join(pkgDir, 'package.json'))) {
34
+ const parent = dirname(pkgDir);
35
+ if (parent === pkgDir) throw new Error(`package.json not found for ${pkgName}`);
36
+ pkgDir = parent;
37
+ }
38
+ const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
39
+ const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.[binName];
40
+ if (!binRel) throw new Error(`bin '${binName}' not found in ${pkgName}`);
41
+ return resolve(pkgDir, binRel);
42
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Bun-first scaffold rewrites (#541).
3
+ *
4
+ * The scaffold authors every template in its node/npm form (ONE source of
5
+ * truth, no drift) and DERIVES the bun-mode variant by transform when the app
6
+ * is scaffolded with `--runtime bun` (or through `bun create webjs`). These are
7
+ * pure string transforms so they unit-test without touching the filesystem.
8
+ *
9
+ * Why a transform and not a second set of template files: the agent-config
10
+ * markdown (AGENTS.md / CONVENTIONS.md / .cursorrules / ...) plus the deploy
11
+ * files (Dockerfile / ci.yml) are long and change often; a parallel bun copy
12
+ * would silently drift from the node original. A transform keeps the node
13
+ * template canonical and the bun output a deterministic function of it.
14
+ *
15
+ * The runtime axis is ORTHOGONAL to `--template` (the exactly-3-templates
16
+ * invariant is untouched, this is a separate dimension).
17
+ *
18
+ * WHAT RUNS ON BUN, AND WHAT DOES NOT (the load-bearing design decision):
19
+ * the SERVER (the `dev` / `start` scripts) runs on Bun, because that is the
20
+ * app. The dev/build TOOLING (`webjs test` / `db:*` / `check` / `typecheck`)
21
+ * stays on Node, because `webjs test` spawns `node --test` (Bun has no
22
+ * `--test` flag; its runner is `bun test`). So:
23
+ * - the `dev` / `start` scripts force `bun --bun` (the server is Bun),
24
+ * - every other command stays `bun run` / `webjs ...` (runs on Node via the
25
+ * `webjs` bin's `#!/usr/bin/env node` shebang),
26
+ * - the Dockerfile is a pure `oven/bun:1` base (#595). This is safe as of
27
+ * `@webjsdev/cli@0.10.20` (#570): `webjs db migrate` resolves drizzle-kit
28
+ * and runs it under Bun (no `npx`), so a Node-less image works. (Before
29
+ * #570 shipped as `latest`, this stayed on `node:24-alpine` + a copied Bun
30
+ * binary, since the installed CLI could still shell `npx`.)
31
+ */
32
+
33
+ /**
34
+ * Rewrite command-shaped npm/npx invocations in prose (markdown / agent config /
35
+ * the starter-test header comments) to their bun equivalents.
36
+ *
37
+ * `npm run dev` / `start` become `bun --bun run` (the `--bun` overrides the
38
+ * `webjs` bin's Node shebang so the SERVER runs on Bun). Every OTHER script
39
+ * becomes a plain `bun run` (Node tooling: forcing `--bun` on `webjs test`
40
+ * would spawn the invalid `bun --test`). Bare "npm" as a word (e.g. "a
41
+ * third-party npm package", "the npm registry") is left alone: packages still
42
+ * come from npm, only the COMMAND changes.
43
+ *
44
+ * @param {string} s
45
+ * @returns {string}
46
+ */
47
+ export function bunifyProse(s) {
48
+ return s
49
+ // Prose claim about the Dockerfile CMD (AGENTS.md dev-start parity section);
50
+ // the bun Dockerfile's CMD becomes `bun --bun run start`.
51
+ .replaceAll('`CMD ["npm", "start"]`', '`CMD ["bun", "--bun", "run", "start"]`')
52
+ // The "Containerized deploy" prose describes the node template's
53
+ // node:24-alpine base; the bun Dockerfile is a pure oven/bun:1 image (#595),
54
+ // so rewrite the base claim to match what the bun app actually ships. (The
55
+ // trailing `npm start` is rewritten to `bun --bun run start` by the generic
56
+ // rule below.)
57
+ .replaceAll(
58
+ 'Dockerfile pins `node:24-alpine` (the same Node major CI uses), installs\ndeps (no build step, since Drizzle has no codegen), and starts via',
59
+ 'Dockerfile is a pure `oven/bun:1` image (no Node, since `webjs db migrate`\nresolves drizzle-kit and runs under Bun with no `npx`, #570), installs deps\nwith `bun install` (no build step, since Drizzle has no codegen), and starts via',
60
+ )
61
+ // The "Running on Bun" section frames Bun as opt-in ("force it with --bun").
62
+ // In a bun-flavored app the dev/start scripts ALREADY embed --bun, so reframe
63
+ // it as the configured default.
64
+ .replaceAll('### Running on Bun instead of Node', '### Runtime: this app runs on Bun')
65
+ .replaceAll(
66
+ 'The same `package.json` scripts work on\neither; to run under Bun, force it with `--bun` so the server executes on Bun\nrather than the `webjs` bin\'s Node shebang:',
67
+ 'This app is configured for Bun. Its `dev` / `start` scripts already force\n`--bun` (which overrides the `webjs` bin\'s Node shebang), so a plain `bun run dev`\nserves on Bun. The other scripts (test / db / check) run on Node, the runtime\nthe `webjs` tooling targets:',
68
+ )
69
+ // Invocation styles first, so "npm create webjs@latest" does not get
70
+ // mangled by the generic "npm <x>" rules below.
71
+ .replaceAll('npm create webjs@latest', 'bun create webjs')
72
+ .replaceAll('npm create webjs', 'bun create webjs')
73
+ // dev-dep installs (`npm install -D` and the `npm i -D` shorthand)
74
+ .replace(/npm install -D /g, 'bun add -d ')
75
+ .replace(/npm install --save-dev /g, 'bun add -d ')
76
+ .replace(/npm i -D /g, 'bun add -d ')
77
+ // a package install (followed by a package name) -> `bun add <pkg>`
78
+ .replace(/npm install (?=[A-Za-z@])/g, 'bun add ')
79
+ .replace(/npm i (?=[A-Za-z@])/g, 'bun add ')
80
+ // bare install / ci -> `bun install`
81
+ .replace(/npm install\b/g, 'bun install')
82
+ .replace(/npm ci\b/g, 'bun install')
83
+ // The SERVER scripts run on Bun (force --bun, overriding the Node shebang).
84
+ .replace(/npm run dev\b/g, 'bun --bun run dev')
85
+ .replace(/npm run start\b/g, 'bun --bun run start')
86
+ .replace(/npm start\b/g, 'bun --bun run start')
87
+ // Every other script is Node tooling (webjs test -> node --test, db -> npx
88
+ // drizzle-kit): a plain `bun run` lets the shebang pick Node. NEVER --bun
89
+ // here (it would make `webjs test` spawn the invalid `bun --test`).
90
+ .replace(/npm run /g, 'bun run ')
91
+ .replace(/npm test\b/g, 'bun run test')
92
+ // one-off executors
93
+ .replace(/npx /g, 'bunx ');
94
+ }
95
+
96
+ /**
97
+ * Rewrite the scaffolded Dockerfile for Bun.
98
+ *
99
+ * Base decision (acceptance criterion): a pure `oven/bun:1` image (no Node).
100
+ * Safe as of `@webjsdev/cli@0.10.20` (#570): `webjs db` / `webjs test` resolve
101
+ * their tools (drizzle-kit, wtr) and spawn them with the current runtime instead
102
+ * of `npx`, so the boot-time `webjs db migrate` runs under Bun with no Node
103
+ * toolchain. (Before #570 was the published `latest`, this stayed on a
104
+ * `node:24-alpine` base with a copied Bun binary, since the installed CLI could
105
+ * still shell `npx`, which a pure Bun image lacks. #595 flipped it once the
106
+ * npx-free CLI shipped.) `oven/bun:1` is Debian-based: `ca-certificates` ship in
107
+ * the image and better-sqlite3 fetches its glibc prebuild on `bun install`
108
+ * (gated by `trustedDependencies`), so no build toolchain is needed.
109
+ *
110
+ * @param {string} s
111
+ * @returns {string}
112
+ */
113
+ export function bunifyDockerfile(s) {
114
+ return s
115
+ // Top comment: explain the pure oven/bun base.
116
+ .replace(
117
+ /# webjs serves \.ts directly[\s\S]*?since the built-in stripper and recursive fs\.watch need it\.\n/,
118
+ '# webjs serves .ts directly by stripping types at the runtime layer, so there is\n' +
119
+ '# NO JavaScript build step (webjs is buildless end to end; there is no bundler or\n' +
120
+ '# esbuild fallback). This image runs the app on **Bun**: the type-strip comes from\n' +
121
+ '# `amaro`, the server serves via Bun.serve, and `webjs db migrate` runs under Bun\n' +
122
+ '# (the CLI resolves drizzle-kit without npx, #570), so no Node is needed. webjs also\n' +
123
+ '# runs on Node 24+; for a Node base instead, swap to `node:24-alpine` and start with\n' +
124
+ '# `npm start`.\n',
125
+ )
126
+ .replace('FROM node:24-alpine', 'FROM oven/bun:1')
127
+ // Debian base: ca-certificates already present, no `apk`. Drop the alpine line.
128
+ .replace(
129
+ /# ca-certificates for outbound TLS \(e\.g\. a managed Postgres\)\. better-sqlite3\n# is a prebuilt native module, so no build toolchain is needed here\.\nRUN apk add --no-cache ca-certificates\n\n/,
130
+ '# The Debian-based oven/bun image ships ca-certificates for outbound TLS (e.g. a\n# managed Postgres). better-sqlite3 fetches its glibc prebuild on `bun install`\n# (gated by trustedDependencies), so no build toolchain is needed.\n\n',
131
+ )
132
+ // Lockfile + install (bun.lock, bun install).
133
+ .replace(
134
+ '# package-lock.json is optional (it\'s absent when the app was scaffolded with\n# --no-install); the glob keeps the COPY working with or without it.\nCOPY package.json package-lock.json* ./\nRUN npm install --no-audit --no-fund',
135
+ '# bun.lock is optional (absent when scaffolded with --no-install); the glob keeps\n# the COPY working with or without it. trustedDependencies in package.json lets\n# better-sqlite3\'s native-prebuild postinstall run (bun skips postinstalls).\nCOPY package.json bun.lock* ./\nRUN bun install',
136
+ )
137
+ // Healthcheck: the pure Bun image has no node; use `bun -e`. Keep the
138
+ // dependency-free-probe comment accurate (the probe runs under Bun now).
139
+ .replace("(Node 24's built-in fetch, no curl/wget)", "(the runtime's built-in fetch, no curl/wget)")
140
+ .replace('CMD ["node", "-e", "fetch(', 'CMD ["bun", "-e", "fetch(')
141
+ // Entrypoint: serve on Bun.
142
+ .replace(
143
+ /# `npm start` is a thin alias[\s\S]*?the migrate no longer depends on an npm `prestart` hook\.\nCMD \["npm", "start"\]/,
144
+ '# `bun --bun run start` runs the `start` script on Bun (the server serves via\n' +
145
+ '# Bun.serve). `webjs start` runs the `webjs.start.before` step (`webjs db migrate`,\n' +
146
+ '# which resolves drizzle-kit and runs it under Bun, no npx, #570), idempotent / a\n' +
147
+ '# no-op with no pending migrations, then serves on $PORT.\n' +
148
+ 'CMD ["bun", "--bun", "run", "start"]',
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Rewrite compose.yaml for the pure-Bun image (#595): its healthcheck runs in
154
+ * the `oven/bun:1` container (compose's healthcheck overrides the Dockerfile's),
155
+ * which has no `node`, so switch `node -e` to `bun -e`. compose otherwise builds
156
+ * from the Dockerfile and inherits its `bun --bun run start` CMD, so nothing
157
+ * else changes.
158
+ *
159
+ * @param {string} s
160
+ * @returns {string}
161
+ */
162
+ export function bunifyCompose(s) {
163
+ return s.replace('test: ["CMD", "node", "-e", "fetch(', 'test: ["CMD", "bun", "-e", "fetch(');
164
+ }
165
+
166
+ /**
167
+ * Rewrite the GitHub Actions CI workflow for Bun: ADD `oven-sh/setup-bun`
168
+ * alongside `actions/setup-node` (kept, because the `webjs` test/db/check
169
+ * tooling runs on Node), install with `bun install` (uses bun.lock), and run
170
+ * scripts with a plain `bun run` (the script bodies' `webjs ...` resolve to Node
171
+ * via the shebang, and dev/start are not run in CI). The `node -e` Chromium-path
172
+ * step stays (Node is present from setup-node).
173
+ *
174
+ * @param {string} s
175
+ * @returns {string}
176
+ */
177
+ export function bunifyCi(s) {
178
+ return s
179
+ // Keep the Node setup (the tooling needs it), add Bun for `bun install`.
180
+ // Drop `cache: npm` (the cache is bun's now; setup-bun caches by default).
181
+ .replaceAll(
182
+ "- uses: actions/setup-node@v6\n with:\n node-version: '24'\n cache: npm",
183
+ "- uses: actions/setup-node@v6\n with:\n node-version: '24'\n - uses: oven-sh/setup-bun@v2\n with:\n bun-version: latest",
184
+ )
185
+ .replaceAll('- run: npm ci', '- run: bun install')
186
+ .replaceAll('npm install --no-save ', 'bun add --no-save ')
187
+ .replaceAll('npm run ', 'bun run ')
188
+ .replaceAll('npx ', 'bunx ');
189
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { mkdir, writeFile, readFile } from 'node:fs/promises';
7
+ import { bunifyProse } from './runtime-rewrite.js';
7
8
  import { existsSync } from 'node:fs';
8
9
  import { join, resolve, dirname } from 'node:path';
9
10
  import { fileURLToPath } from 'node:url';
@@ -42,8 +43,10 @@ async function copyUiComponents(appDir, names) {
42
43
 
43
44
  /**
44
45
  * @param {string} appDir
46
+ * @param {{ runtime?: 'node'|'bun' }} [opts]
45
47
  */
46
- export async function writeSaasFiles(appDir) {
48
+ export async function writeSaasFiles(appDir, opts = {}) {
49
+ const isBun = opts.runtime === 'bun';
47
50
  // SaaS pages use auth forms, so copy the extra ui-* components on top of
48
51
  // the standard set the full-stack scaffold already wrote. Pre-importing
49
52
  // them in login/signup/dashboard pages below means the dev server will
@@ -145,6 +148,12 @@ export async function writeSaasFiles(appDir) {
145
148
  "",
146
149
  "import { auth } from '#lib/auth.server.ts';",
147
150
  "",
151
+ "// This read deliberately stays POST-default (no 'method' export). A GET",
152
+ "// server action (#488) is cacheable and SSR-seeded, which is wrong for a",
153
+ "// per-session read: the result differs per user and changes on sign-in /",
154
+ "// sign-out, so it must never be browser-cached or shared. Reserve GET +",
155
+ "// cache + tags for data identical for every visitor (see",
156
+ "// modules/users/queries/list-users.server.ts in the full-stack template).",
148
157
  "export async function currentUser() {",
149
158
  " const session = await auth();",
150
159
  " return session?.user ?? null;",
@@ -181,7 +190,10 @@ export async function writeSaasFiles(appDir) {
181
190
  // Until the users table exists those flows error, so the suite skips with a
182
191
  // clear message instead of crashing. After DB setup it runs for real.
183
192
  await mkdir(join(appDir, 'test', 'auth'), { recursive: true });
184
- await writeFile(join(appDir, 'test', 'auth', 'auth.test.ts'), [
193
+ // The generated comments reference `npm run db:*` setup; bun-ify them so a
194
+ // bun-flavored saas app reads `bun run db:*` (#541; db is Node tooling, so a
195
+ // plain `bun run`, not the --bun server form). The transform is a no-op on Node.
196
+ const authTest = [
185
197
  "import { test } from 'node:test';",
186
198
  "import assert from 'node:assert/strict';",
187
199
  "import { fileURLToPath } from 'node:url';",
@@ -260,7 +272,11 @@ export async function writeSaasFiles(appDir) {
260
272
  " assert.match(body, /Dashboard/, 'the dashboard content rendered');",
261
273
  "});",
262
274
  "",
263
- ].join('\n'));
275
+ ].join('\n');
276
+ await writeFile(
277
+ join(appDir, 'test', 'auth', 'auth.test.ts'),
278
+ isBun ? bunifyProse(authTest) : authTest,
279
+ );
264
280
 
265
281
  // app/api/auth/[...path]/route.ts
266
282
  await mkdir(join(appDir, 'app', 'api', 'auth', '[...path]'), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.19",
3
+ "version": "0.10.21",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -34,6 +34,13 @@ FIRST, before writing any code:
34
34
  - If on main/master: create a feature branch before editing.
35
35
  - If on a feature branch: verify it matches the current task.
36
36
  2. Sync: `git fetch origin && git rebase origin/main` if behind.
37
+ 3. If more than one agent may work this repo at once, use a DEDICATED git
38
+ worktree per task (`git worktree add -b <branch> ../<repo>-<slug> origin/main`,
39
+ `cd` in, work there, `git worktree remove` after merge), never a shared
40
+ checkout. Two agents in one directory collide: a `git checkout` in one moves
41
+ HEAD under the other, so commits land on the wrong branch. Git enforces
42
+ one-branch-per-worktree, so worktrees prevent it. A lone agent in a clean
43
+ checkout may use a plain branch.
37
44
 
38
45
  ## Autonomous mode (sandbox / no-prompt)
39
46
 
@@ -7,10 +7,10 @@ cover what you need, the full hosted docs are at **https://docs.webjs.com**.
7
7
 
8
8
  ## Persistence + scaffold rules (non-negotiable)
9
9
 
10
- - **Use Prisma + SQLite for data, never JSON files.** It's already wired up
11
- (`prisma/schema.prisma`, `lib/prisma.server.ts`, `npm run db:migrate`). For ANY
10
+ - **Use Drizzle + SQLite for data, never JSON files.** It's already wired up
11
+ (`db/schema.server.ts`, `db/connection.server.ts`, `npm run db:generate` + `npm run db:migrate`). For ANY
12
12
  data the app stores (todos, posts, messages, products, comments…),
13
- define a Prisma model. NEVER create `data/*.json`, `db.json`, or any
13
+ define a Drizzle table. NEVER create `data/*.json`, `db.json`, or any
14
14
  JSON file as a fake database. NEVER use module-scope arrays / Maps as
15
15
  a substitute. NEVER use localStorage for app data. These are project conventions in CONVENTIONS.md (a JSON file used as a
16
16
  database resets on reload and cannot scale).
@@ -35,6 +35,12 @@ FIRST, before writing any code:
35
35
  2. Sync with parent: `git fetch origin && git log HEAD..origin/main --oneline`
36
36
  - If upstream has new commits: `git rebase origin/main` before starting.
37
37
  - Resolve any conflicts before proceeding with the task.
38
+ 3. If more than one agent may work this repo at once, use a DEDICATED git
39
+ worktree per task, not a shared checkout: `git worktree add -b <branch>
40
+ ../<repo>-<slug> origin/main`, `cd` in, work there, `git worktree remove`
41
+ after merge. Two agents in one directory collide (a `git checkout` in one
42
+ moves HEAD under the other, so commits land on the wrong branch). A lone
43
+ agent in a clean checkout may use a plain branch.
38
44
 
39
45
  ## Autonomous mode (sandbox / no-prompt)
40
46
 
@@ -107,7 +113,7 @@ self-review loop.
107
113
  - Shadow-DOM components opt in with `static shadow = true` and use `static styles = css` for scoped CSS, not inline styles. That is the right home for scoped CSS.
108
114
  - One function per server action file (*.server.ts)
109
115
  - Components must call customElements.define('tag', Class)
110
- - Server-only code (@prisma/client, node:*, anything that needs Node APIs) goes only in .server.{js,ts} files, route.ts handlers, or middleware.ts. Never in pages, layouts, or components. Wrap the access in a .server.{js,ts} file; the framework rewrites that import into an RPC stub for the browser. lib/ holds both server-only infra (lib/prisma.server.ts) and browser-safe utilities (lib/utils/cn.ts with cn); follow the same rule per file: if a lib/ file needs Node APIs, only import it from server-only files.
116
+ - Server-only code (the DB driver `better-sqlite3` / `pg`, node:*, anything that needs Node APIs) goes only in .server.{js,ts} files, route.ts handlers, or middleware.ts. Never in pages, layouts, or components. Wrap the access in a .server.{js,ts} file; the framework rewrites that import into an RPC stub for the browser. The DB lives in db/*.server.ts (db/connection.server.ts); lib/ holds other server-only infra and browser-safe utilities (lib/utils/cn.ts with cn); follow the same rule per file: if a lib/ file needs Node APIs, only import it from server-only files.
111
117
  - Directives are deliberately minimal: only `unsafeHTML`, `live`, and `repeat` ship. Lit's `classMap` / `styleMap` / `ref` / `when` / `choose` / `guard` are NOT exported. Use plain template-literal expressions (`class=${cond ? 'a' : 'b'}`, `${cond ? a : b}`) and lifecycle hooks (`this.query('#el')` in `firstUpdated`) instead.
112
118
  - **Progressive enhancement is the default.** Pages AND every web component are SSR'd to real HTML. Write components so the first paint is the right content. Read SSR-meaningful defaults in `constructor()`. `connectedCallback` is never called on the server, so anything there only runs after hydration. Initial data for components comes from the page function (server-side fetch plus pass as attribute/property) OR from an `async render()` in the component itself (`const u = await getUser(this.uid)`, which SSR awaits so the data is in the first paint), NOT from `fetch` calls in `connectedCallback`. Prefer the co-located `async render()` over prop-drilling; `renderFallback()` is the optional re-fetch loading state (never first paint), and a `Task` is for genuinely client-only data. For write-paths, prefer `<form action=...>` plus server action over `fetch` plus click handler. The framework upgrades plain forms to partial-swap submissions automatically.
113
119
  - **Client navigation is auto-magic.** Real `<a href>` and `<form action>` get partial-swap behavior with no opt-in. Because layouts persist across navigation, put shared chrome (sidenav, header) in `layout.ts` and page-specific content in `page.ts`. For validation errors, return 4xx HTML from a `route.ts` POST handler; the router renders it in place preserving the user's input. For non-layout swap regions, wrap in `<webjs-frame id="...">`. See "Client navigation patterns" in AGENTS.md.
@@ -23,11 +23,12 @@ out
23
23
  .env
24
24
  !.env.example
25
25
 
26
- # Prisma local SQLite. Schema + migrations ship; the runtime volume owns the db.
27
- dev.db
28
- dev.db-journal
29
- prisma/dev.db
30
- prisma/dev.db-journal
26
+ # Local SQLite database (Drizzle, sqlite dialect). Schema + migrations ship;
27
+ # the runtime volume owns the db file. Matches the `db/dev.db*` rule the
28
+ # scaffold appends to `.gitignore` for the sqlite dialect (no-op for postgres).
29
+ db/dev.db
30
+ db/dev.db-journal
31
+ db/dev.db-*
31
32
 
32
33
  # logs
33
34
  *.log
@@ -32,6 +32,12 @@ FIRST, before writing any code:
32
32
  - If on main/master: create a feature branch before editing.
33
33
  - If on a feature branch: verify it matches the task at hand.
34
34
  2. Sync: `git fetch origin && git rebase origin/main` if behind.
35
+ 3. If more than one agent may work this repo at once, use a DEDICATED git
36
+ worktree per task (`git worktree add -b <branch> ../<repo>-<slug> origin/main`,
37
+ `cd` in, work there, `git worktree remove` after merge), never a shared
38
+ checkout. Two agents in one directory collide: a `git checkout` in one moves
39
+ HEAD under the other, so commits land on the wrong branch. A lone agent in a
40
+ clean checkout may use a plain branch.
35
41
 
36
42
  ## Autonomous mode (sandbox / no-prompt)
37
43
 
@@ -101,7 +107,7 @@ each change must include.
101
107
  - **Erasable TypeScript only.** The runtime strips types via `module.stripTypeScriptTypes` (Node's built-in, or `amaro` on Bun) (whitespace replacement, byte-exact position preservation, no sourcemap). The scaffold's tsconfig.json sets `erasableSyntaxOnly: true`, so the TS compiler rejects `enum`, `namespace` with values, constructor parameter properties, legacy decorators with `emitDecoratorMetadata`, and `import = require`. Use erasable equivalents: `const X = { ... } as const` plus a derived union type instead of `enum`; explicit fields plus constructor body assignments instead of parameter properties. If `erasableSyntaxOnly` is disabled and non-erasable syntax is used, the dev server fails at strip time and returns a 500 pointing at the `no-non-erasable-typescript` lint rule. webjs is buildless end-to-end and has no bundler fallback.
102
108
  - Tagged template: html`<div>${value}</div>` with css`...` for styles.
103
109
  - **Tailwind-first styling.** Tailwind utilities are the strong default for pages AND light-DOM components (the default DOM mode): layout, spacing, color (via `@theme` tokens), typography, borders, radius, shadows, interaction states. Light DOM does not scope, so utilities apply directly. The lit reflex to scope CSS (`static styles = css\`...\``) or write an inline `<style>` with semantic class names (`.hero`, `.card`) in a light-DOM component is wrong: the scoped block needs `static shadow = true`, and inline class names leak globally. When a utility bundle repeats, extract a `lib/utils/ui.ts` helper returning an `html` fragment, not a CSS class. Reserve raw CSS for the allowlist (design tokens / `@theme`, `@property` + `@keyframes`, `::-webkit-scrollbar`, `prefers-reduced-motion`, complex `color-mix()` / gradients); when unavoidable in a light-DOM component, prefix every class selector with the component tag. Shadow-DOM components (`static shadow = true`) legitimately author `static styles = css\`...\`` for scoped CSS; don't use inline `style="..."` there.
104
- - Components: extend WebComponent, declare `static properties` (and `static styles` for shadow-DOM components), call `Class.register('tag-name')` at the bottom of the file. The tag name is the argument to `.register()`, not a static field.
110
+ - Components: extend the `WebComponent({ ... })` factory to declare reactive properties (e.g. `extends WebComponent({ count: Number })`; per-prop options via `prop(Number, { reflect: true })`), add `static styles` for shadow-DOM components, call `Class.register('tag-name')` at the bottom of the file. The tag name is the argument to `.register()`, not a static field. A hand-written `static properties` throws at construction (`no-static-properties`); set defaults via the `default` option or the constructor, never a class-field initializer (`reactive-props-no-class-field`).
105
111
  - Async data in a component: prefer an `async render()` (`const u = await getUser(this.uid)`), which SSR awaits so the data is in the first paint, over prop-drilling from the page or fetching in `connectedCallback`. `renderFallback()` is the optional re-fetch loading state (never first paint); error isolation is automatic (`renderError()` customizes it); a `Task` is for genuinely client-only data.
106
112
  - Server actions: *.server.ts files with one exported async function each.
107
113
  - Server-only code (a DB driver like better-sqlite3/pg, node:*, anything needing Node APIs) goes only in .server.{js,ts} files, route.ts handlers, or middleware.ts. Never in pages, layouts, or components. Wrap in a .server.{js,ts} file; the framework rewrites that import to an RPC stub for the browser. The DB lives in db/*.server.ts; lib/ holds other server-only infra and browser-safe utilities (lib/utils/cn.ts with cn); apply the same rule per file.
@@ -109,5 +115,5 @@ each change must include.
109
115
  - Context: import { createContext, ContextProvider, ContextConsumer } from '@webjsdev/core/context'
110
116
  - Task: import { Task, TaskStatus } from '@webjsdev/core/task'
111
117
  - Routing: file-based under app/ (page.ts, layout.ts, route.ts, middleware.ts).
112
- - Component state lives in signals from @webjsdev/core. Module-scope signals share state across components; instance signals (created in the constructor) carry component-local state. Reactive properties (static properties + declare) are for HTML attributes and .prop=${...} hydration.
118
+ - Component state lives in signals from @webjsdev/core. Module-scope signals share state across components; instance signals (created in the constructor) carry component-local state. Reactive properties (declared via the `WebComponent({ ... })` factory) are for HTML attributes and .prop=${...} hydration.
113
119
  - Don't skip tests or documentation updates.
@@ -11,9 +11,20 @@
11
11
  # here, so a commit stays fast and the test gate cannot be skipped by a
12
12
  # local --no-verify. The CI workflow runs `webjs check` + `webjs test`
13
13
  # on every push and pull request.
14
+ #
15
+ # Running more than one AI agent on this repo at once? Give each task its own
16
+ # git worktree, not a shared checkout. Two agents in one working directory
17
+ # collide: a `git checkout` in one moves HEAD under the other, so the next
18
+ # commit lands on the wrong branch. Before committing, confirm the branch below
19
+ # is the one you intended; if it moved, you are sharing a checkout. Isolate:
20
+ # git worktree add -b <branch> ../<app>-<task> origin/main && cd ../<app>-<task>
14
21
 
15
22
  BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null)
16
23
 
24
+ # Surface the branch so a wrong-branch commit (a concurrent-agent HEAD move) is
25
+ # visible in the commit output rather than silent.
26
+ echo "[pre-commit] committing on branch: ${BRANCH:-<detached>}"
27
+
17
28
  if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
18
29
  echo ""
19
30
  echo "ERROR: Cannot commit directly to '$BRANCH'."
@@ -163,8 +163,9 @@ entry, its own template parser. Inside `` html`…` `` templates you get:
163
163
  - Binding-aware completions: reachable tag names after `<`, and
164
164
  prefix-keyed attributes (`.prop` property names, `?bool` / plain
165
165
  hyphenated attribute names).
166
- - Diagnostics: value type-checks against `declare propName: T`, unquoted
167
- `@`/`.`/`?` bindings, and expressionless `.prop` bindings.
166
+ - Diagnostics: value type-checks against the reactive props declared in
167
+ `WebComponent({ ... })`, unquoted `@`/`.`/`?` bindings, and
168
+ expressionless `.prop` bindings.
168
169
  - Hover showing the component class / declared member type.
169
170
 
170
171
  In VS Code / Cursor / Windsurf, the **`webjs` extension** bundles this
@@ -597,12 +598,13 @@ const secret = process.env.AUTH_SECRET; // undefined (fail-closed)
597
598
  ```ts
598
599
  import { WebComponent, html, css } from '@webjsdev/core';
599
600
 
600
- export class Counter extends WebComponent {
601
- static properties = { count: { type: Number } };
601
+ // Recommended declare-free base-class factory style
602
+ export class Counter extends WebComponent({
603
+ count: Number
604
+ }) {
602
605
  static styles = css`button { padding: 8px 12px; }`; // shadow-DOM only
603
606
  // static shadow = true; // opt into shadow DOM (default: light DOM)
604
607
  // static lazy = true; // download JS only when scrolled into view
605
- declare count: number; // TypeScript-only typed accessor
606
608
 
607
609
  constructor() {
608
610
  super();
@@ -629,8 +631,9 @@ the click handler is inert). Two consequences for how you write code:
629
631
  1. **Defaults for the first paint go in `constructor()`** (after
630
632
  `super()`), never as class-field initializers (which break
631
633
  reactivity) and never in `connectedCallback` (which the server
632
- doesn't run). For Web Component properties with `declare`, set the
633
- default in the constructor.
634
+ doesn't run). For reactive properties declared via the
635
+ `WebComponent({ ... })` factory, set the default in the constructor
636
+ or pass the `default` option (e.g. `prop(Number, { default: 0 })`).
634
637
  2. **`connectedCallback` is browser-only.** Use it for
635
638
  `localStorage`, viewport size, online status, or anything that
636
639
  genuinely can't be known on the server. Read the value, then
@@ -659,7 +662,8 @@ See [Progressive Enhancement](https://docs.webjs.dev/docs/progressive-enhancemen
659
662
  ## Lit muscle-memory gotchas (read if you have written lit before)
660
663
 
661
664
  Webjs's runtime API matches lit. The `WebComponent` base class,
662
- `static properties`, the lifecycle hooks, ReactiveControllers, the
665
+ reactive properties (declared via the `WebComponent({ ... })` factory),
666
+ the lifecycle hooks, ReactiveControllers, the
663
667
  directive set, `html` / `css` tagged templates. The **rendering
664
668
  model**, however, is different. Pure-lit patterns that work fine in a
665
669
  client-only lit app break in webjs's SSR pipeline or its reactivity
@@ -714,11 +718,12 @@ Practical consequences for agents writing webjs code.
714
718
  | Assuming an `async render()` always ships its module | A bare one (no other client signal) is ELIDED, so it costs zero JS and skips the on-hydration re-fetch, first paint unchanged | Rely on it for a fetch-and-display leaf. `static refresh = true` keeps the on-load refresh, `static shadow = true` always ships |
715
719
  | `window.X` / `document.X` in constructor or `render()` | SSR crash | Move to `connectedCallback` |
716
720
  | Top-level `import` of a browser-only library | SSR crash | Dynamic `import()` inside `connectedCallback` |
717
- | Class-field initializer for a reactive property (`student: Student = {...}`) | Silently breaks reactivity (overwrites the framework accessor) | `declare student: Student` plus constructor default |
718
- | `@property()` decorator | Banned by invariant 10 (erasable TS) | `static properties = { ... }` plus `declare` |
721
+ | Class-field initializer for a reactive property (`student: Student = {...}`) | Silently breaks reactivity (overwrites the framework accessor) | Pass the shape to the base-class factory `WebComponent({ student: Object })` and set the default in the constructor (flagged by `reactive-props-no-class-field`) |
722
+ | `@property()` decorator | Banned by invariant 10 (erasable TS) | Pass the shape to the base-class factory `WebComponent({ ... })` (the only supported form) |
723
+ | Hand-written `static properties = { ... }` | Throws at construction (the factory owns property setup) | Pass the same shape to the base-class factory `WebComponent({ ... })` (flagged by `no-static-properties`) |
719
724
  | Scoped `static styles = css` or an inline `<style>` with semantic class names (`.hero`, `.card`) in a light-DOM component | Scoped block does nothing without `static shadow = true`; inline `<style>` class names leak globally | Tailwind utilities (the light-DOM default); or `static shadow = true` for genuinely scoped CSS |
720
725
  | `willUpdate` computing SSR-visible derived state | Works (runs at SSR), but overriding it opts the component out of elision | Fine for interactive components; for display-only, derive inline in `render()` |
721
- | `this.hasAttribute` / `getAttribute` in `render()` | Works (server attribute shim backs the attribute methods at SSR) | Read attributes directly, or via a `static properties` + `declare` reactive prop |
726
+ | `this.hasAttribute` / `getAttribute` in `render()` | Works (server attribute shim backs the attribute methods at SSR) | Read attributes directly, or declare a reactive prop via the base-class factory `WebComponent({ ... })` |
722
727
  | `ContextProvider` for server-known data | Default value during SSR, content shift on hydration | Pass via props from the page function |
723
728
 
724
729
  The full annotated catalog with code examples lives in the framework
@@ -767,6 +772,19 @@ export async function createPost(input: { title: string; body: string }) {
767
772
  Import it from a client component. The framework rewrites it into a
768
773
  type-safe RPC stub automatically.
769
774
 
775
+ A server action is a POST by default, but reserved sibling exports change its
776
+ HTTP semantics without changing the call site (`await getUser(7)` stays the
777
+ same): `export const method = 'GET'` (a read; rides args in the URL, CSRF-exempt,
778
+ cacheable), `export const cache = 60` + `export const tags` (GET response
779
+ caching), `export const invalidates` (a mutation's tags to evict), `export const
780
+ middleware` (a per-action chain, `actionContext()`), and `export const validate`
781
+ (the boundary validator). One callable function per configured file. An action
782
+ that RETURNS a `ReadableStream` / async generator streams its chunks (consume
783
+ with `for await`); read the request `AbortSignal` via `actionSignal()` to cancel
784
+ on disconnect. **SAFETY:** a `cache` with `public: true` shares one response
785
+ across all users, so use it only for data identical for every visitor. Full
786
+ reference: https://docs.webjs.com/docs/server-actions
787
+
770
788
  ## Client navigation patterns (auto-magic)
771
789
 
772
790
  The client router enables itself when the scaffolded root layout imports
@@ -1205,7 +1223,14 @@ composition, so a nested shell ends up dropped by the HTML parser.
1205
1223
 
1206
1224
  ## Workflow expectations for AI agents
1207
1225
 
1208
- 1. Branch before editing. Never push to `main` directly.
1226
+ 1. Branch before editing. Never push to `main` directly. **If more than one
1227
+ agent may work this repo at once, give each task its own git worktree, not a
1228
+ shared checkout** (`git worktree add -b <branch> ../<repo>-<slug> origin/main`,
1229
+ `cd` in, work there, `git worktree remove` after merge). Two agents in one
1230
+ working directory collide: a `git checkout` in one moves `HEAD` under the
1231
+ other, so the next commit lands on the wrong branch. Git enforces
1232
+ one-branch-per-worktree, so worktrees prevent it; a lone agent in a clean
1233
+ checkout may use a plain branch.
1209
1234
  2. Every code change comes with a test, AGENTS.md / docs updates if the
1210
1235
  feature surface changed, `webjs check` passing. A unit test is not
1211
1236
  always enough: a component, hydration, the client router, or a server
@@ -60,6 +60,14 @@ even if the user doesn't explicitly ask.**
60
60
  3. If on a feature branch → verify it matches the current task
61
61
  4. Sync with parent: `git fetch origin && git rebase origin/main` if behind
62
62
  5. Don't mix unrelated work on the wrong branch
63
+ 6. **If more than one agent may work this repo at once, use a dedicated git
64
+ worktree per task, never a shared checkout.** Two agents in one working
65
+ directory collide: a `git checkout` in one moves `HEAD` under the other, so
66
+ the next commit lands on the wrong branch. Isolate each task:
67
+ `git worktree add -b <branch> ../<repo>-<slug> origin/main`, `cd` in, work
68
+ there, and `git worktree remove` after the PR merges. Git enforces
69
+ one-branch-per-worktree, so this makes the collision impossible. A lone agent
70
+ in a clean checkout may use a plain branch.
63
71
 
64
72
  ### After cloning: verify the toolchain
65
73
 
@@ -635,10 +643,11 @@ Any stateful behavior with a Tier-2 element uses the element.
635
643
  ```ts
636
644
  import { WebComponent, html } from '@webjsdev/core';
637
645
 
638
- export class MyWidget extends WebComponent {
639
- static properties = { label: { type: String }, count: { type: Number } };
640
- declare label: string;
641
- declare count: number;
646
+ // Recommended declare-free base-class factory style
647
+ export class MyWidget extends WebComponent({
648
+ label: String,
649
+ count: Number
650
+ }) {
642
651
  // Light DOM is the default; Tailwind utility classes apply directly.
643
652
 
644
653
  constructor() {
@@ -660,16 +669,7 @@ export class MyWidget extends WebComponent {
660
669
  MyWidget.register('my-widget');
661
670
  ```
662
671
 
663
- `static properties` is the runtime declaration (reactive accessor,
664
- attribute coercion, reflection). `declare` types the field for
665
- TypeScript without emitting a class-field initializer that would
666
- clobber the reactive accessor at construction time. The two
667
- declarations together give you full intelligence in any tsserver-backed
668
- editor. See the Editor Setup docs for the standalone `@webjsdev/intellisense`
669
- (no Lit dependency) that extends this to tag / attribute intelligence
670
- inside `html\`…\`` templates (go-to-definition, binding-aware completions,
671
- value/binding diagnostics, hover); in VS Code / Cursor / Windsurf the
672
- `webjs` extension bundles it automatically.
672
+ Reactive properties are declared one way: pass the properties shape directly to the base-class factory `WebComponent({ ... })` (e.g. `label: String`). The property types flow to `this.<prop>` with no `declare` lines needed, and the factory installs the reactive accessors so a class-field initializer can never clobber them. For per-property options use the `prop()` helper inside the shape (`count: prop(Number, { reflect: true })`, `mode: prop({ state: true })`); narrow a type with `prop<Student>(Object)`. Set defaults via the `default` option (`prop(Number, { default: 0 })`) or by assigning in the constructor after `super()`. A hand-written `static properties = { ... }` THROWS at construction (`no-static-properties`). The factory gives you full intelligence in any tsserver-backed editor. See the Editor Setup docs for the standalone `@webjsdev/intellisense` (no Lit dependency) that extends this to tag / attribute intelligence inside `html\`…\`` templates (go-to-definition, binding-aware completions, value/binding diagnostics, hover); in VS Code / Cursor / Windsurf the `webjs` extension bundles it automatically.
673
673
 
674
674
  **Rules:**
675
675
  - One component per file
@@ -680,15 +680,8 @@ value/binding diagnostics, hover); in VS Code / Cursor / Windsurf the
680
680
  - `my-widget .body`, `my-widget .title` (descendant selector)
681
681
  - Tag name must contain a hyphen (HTML spec)
682
682
  - Always call `Class.register('tag')`. That's the standard DOM API.
683
- - **Reactive props use `declare propName: Type` (no value) plus a default in `constructor()` after `super()`.** Never write `propName = value` or `propName: Type = value` as a class-field initializer. It compiles to `Object.defineProperty(this, …)` after `super()` and clobbers the framework's reactive accessor, silently breaking re-renders. `webjs check` flags this via the `reactive-props-use-declare` rule.
684
- - Component state lives in signals. Import `signal` from
685
- `@webjsdev/core`, read via `signal.get()` inside `render()`, write
686
- via `signal.set(value)`. Module-scope signals share state across
687
- components; instance signals (created in the constructor) carry
688
- component-local state. Reactive properties (`static properties =
689
- { foo: { type: ... } }` with a sibling `declare foo: T`) wrap HTML
690
- attributes, attribute reflection, and `.prop=${value}` SSR
691
- hydration.
683
+ - **Reactive props are declared via the base-class factory `WebComponent({ ... })`.** A hand-written `static properties = { ... }` throws at construction (`webjs check` flags it via `no-static-properties`). Never write `propName = value` or `propName: Type = value` as a class-field initializer on a reactive prop. It compiles to `Object.defineProperty(this, …)` after `super()` and clobbers the framework's reactive accessor, silently breaking re-renders (`webjs check` flags this via `reactive-props-no-class-field`). Set defaults via the `default` option or in the constructor.
684
+ - Component state lives in signals. Import `signal` from `@webjsdev/core`, read via `signal.get()` inside `render()`, write via `signal.set(value)`. Module-scope signals share state across components; instance signals (created in the constructor) carry component-local state. Reactive properties (declared via the factory) wrap HTML attributes, attribute reflection, and `.prop=${value}` SSR hydration.
692
685
  - Use lifecycle hooks (`firstUpdated`, `updated`) only when needed
693
686
 
694
687
  ---
@@ -997,7 +990,8 @@ component, applies its attributes, runs `willUpdate` and controllers'
997
990
  `firstUpdated`, `updated`, or any other browser-only lifecycle hook.
998
991
  Whatever state should appear on first paint MUST be set in the
999
992
  constructor (after `super()`), derived in `willUpdate`, or derivable
1000
- from `static properties` + attributes on the rendered tag. Reading
993
+ from the factory-declared reactive props + attributes on the rendered
994
+ tag. Reading
1001
995
  `this.getAttribute` / `hasAttribute` in `render()` works server-side (a
1002
996
  server attribute shim backs the attribute methods), but a `Task`'s
1003
997
  fetch still runs only on the client.