@webjsdev/cli 0.9.1 → 0.10.1

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/README.md CHANGED
@@ -66,7 +66,7 @@ the CLI gives you `webjs ui` automatically. See
66
66
  The scaffold seeds opinionated defaults so AI agents produce consistent code:
67
67
 
68
68
  - `AGENTS.md` + `CONVENTIONS.md` (the machine-readable contract)
69
- - `.claude/`, `.cursorrules`, `.windsurfrules`, `.github/copilot-instructions.md`
69
+ - `.claude/`, `.cursorrules`, `.agents/rules/workflow.md` (Antigravity), `.github/copilot-instructions.md`
70
70
  - `test/<feature>/` (with optional `browser/` / `e2e/` subfolders per kind) with example tests
71
71
  - Tailwind CSS via CLI (no browser runtime at build time)
72
72
  - TypeScript, `.editorconfig`, `.gitignore`
package/bin/webjs.js CHANGED
@@ -8,7 +8,7 @@ const [cmd, ...rest] = process.argv.slice(2);
8
8
 
9
9
  // Exactly three scaffolds exist. Keep this list as the single source of
10
10
  // truth. AI-agent docs in README.md / AGENTS.md / .cursorrules /
11
- // .windsurfrules / .github/copilot-instructions.md mirror it.
11
+ // .agents/rules/workflow.md / .github/copilot-instructions.md mirror it.
12
12
  const TEMPLATES = ['full-stack', 'api', 'saas'];
13
13
 
14
14
  const USAGE = `webjs commands:
@@ -26,6 +26,11 @@ const USAGE = `webjs commands:
26
26
  webjs ui <subcmd> AI-first component library CLI
27
27
  (init / add / list / view / diff / info)
28
28
  Requires @webjsdev/ui installed in the project
29
+ webjs vendor pin [--download] Pin client-side npm packages to .webjs/vendor/importmap.json
30
+ Default: writes jspm.io URLs (browser fetches from CDN)
31
+ --download: also downloads bundles for offline production
32
+ webjs vendor unpin <pkg> Remove a specific package from the pin file
33
+ webjs vendor list Show pinned packages with versions and URLs
29
34
  webjs help Show this help`;
30
35
 
31
36
  /** @param {string[]} args */
@@ -266,6 +271,241 @@ Full docs: https://docs.webjs.com`);
266
271
  await scaffoldApp(name, process.cwd(), { template, install: !noInstall });
267
272
  break;
268
273
  }
274
+ case 'vendor': {
275
+ const sub = rest[0];
276
+ const args = rest.slice(1);
277
+ const appDir = process.cwd();
278
+ const { pinAll, unpinPackage, listPinned, auditPinned, findOutdated, updatePinned, readPinFile, SUPPORTED_PROVIDERS } = await import('@webjsdev/server');
279
+
280
+ // Parse `--from <provider>` once at the top so subcommands share it.
281
+ // Mirrors importmap-rails's `bin/importmap pin foo --from jsdelivr`.
282
+ let from = 'jspm';
283
+ const fromIdx = args.indexOf('--from');
284
+ if (fromIdx !== -1) {
285
+ from = args[fromIdx + 1];
286
+ if (!from || !SUPPORTED_PROVIDERS.has(from)) {
287
+ console.error(
288
+ `Unknown --from provider '${from || ''}'. Supported: ${[...SUPPORTED_PROVIDERS].join(', ')}.`,
289
+ );
290
+ process.exit(1);
291
+ }
292
+ // Strip --from + its argument so downstream flag checks like
293
+ // `args.includes('--download')` aren't confused.
294
+ args.splice(fromIdx, 2);
295
+ }
296
+
297
+ if (sub === 'pin') {
298
+ const download = args.includes('--download');
299
+ // Same precedence rule as `vendor update`: explicit --from
300
+ // wins; otherwise pinAll reads the pin file's persisted
301
+ // provider so a user who pinned via jsdelivr stays on it.
302
+ // Pass undefined (not the parsed 'jspm' default) when no
303
+ // --from to engage the pin-file fallback. Peek at the pin
304
+ // file here to compute the log line before pinAll runs.
305
+ const explicitFrom = fromIdx !== -1 ? from : undefined;
306
+ const existing = await readPinFile(appDir);
307
+ const usedFrom = explicitFrom || existing?.provider || 'jspm';
308
+ console.log(
309
+ `Pinning vendor packages from ${appDir}` +
310
+ (usedFrom !== 'jspm' ? ` via ${usedFrom}` : '') +
311
+ (download ? ' (downloading bundles)' : '') + '...',
312
+ );
313
+ const result = await pinAll(appDir, { download, from: explicitFrom });
314
+ if (result.noBareImports) {
315
+ // Scanner found zero bare-specifier imports in client-
316
+ // reachable source. Without this branch pinAll would write
317
+ // `{ imports: {} }`, which readPinFile then rejects as empty,
318
+ // leaving a useless file behind in whatever cwd.
319
+ console.error(
320
+ `Pin: no bare-specifier npm imports found in client code under ${appDir}. ` +
321
+ `Nothing to pin (no pin file written). Add a bare import like ` +
322
+ `\`import x from 'pkg-name'\` to a page or component, then rerun.`,
323
+ );
324
+ process.exit(1);
325
+ }
326
+ if (result.failed) {
327
+ // pinAll refused to write the pin file because every install
328
+ // failed to resolve via the chosen resolver (jspm.io's
329
+ // Generator API powers all providers; the failure mode is
330
+ // typically a brand-new published version not yet on the
331
+ // CDN, a network outage, or a provider-side 5xx). Surface
332
+ // the failure with the actual provider in the message so
333
+ // the user can fix the cause before shipping.
334
+ const provider = result.provider || 'jspm.io';
335
+ console.error(
336
+ `Pin FAILED: every package failed to resolve via ${provider}. No pin file written ` +
337
+ `(would shadow the live-API fallback with an empty importmap and break the browser).`,
338
+ );
339
+ console.error(`Attempted installs:`);
340
+ for (const i of result.attemptedInstalls) console.error(` ${i}`);
341
+ console.error(
342
+ `Possible causes: the package version is too new for ${provider}'s CDN to have indexed yet; ` +
343
+ `network outage; ${provider} is down. Try again in a few minutes, or pin an older version.`,
344
+ );
345
+ process.exit(1);
346
+ }
347
+ const { pins, pruned, downloaded } = result;
348
+ for (const p of pins) {
349
+ const sizeStr = p.bytes != null ? ` ${(p.bytes / 1024).toFixed(1)} KB` : '';
350
+ console.log(` ${(p.pkg + '@' + p.version).padEnd(40)}${sizeStr}`);
351
+ }
352
+ for (const f of pruned) {
353
+ console.log(` ${f.padEnd(40)} REMOVED (orphan)`);
354
+ }
355
+ const pinMsg = `Pinned ${pins.length} package${pins.length === 1 ? '' : 's'}, wrote .webjs/vendor/importmap.json` +
356
+ (downloaded ? ` + ${downloaded} bundle${downloaded === 1 ? '' : 's'}` : '') + '.';
357
+ const pruneMsg = pruned.length ? ` Pruned ${pruned.length} orphan${pruned.length === 1 ? '' : 's'}.` : '';
358
+ console.log(pinMsg + pruneMsg);
359
+ break;
360
+ }
361
+
362
+ if (sub === 'unpin') {
363
+ if (args.length === 0) {
364
+ console.error('Usage: webjs vendor unpin <pkg>');
365
+ process.exit(1);
366
+ }
367
+ let unpinFailed = false;
368
+ for (const pkg of args) {
369
+ const r = await unpinPackage(appDir, pkg);
370
+ if (!r.removed) {
371
+ console.error(` ${pkg.padEnd(40)} not in pin file`);
372
+ unpinFailed = true;
373
+ continue;
374
+ }
375
+ const extra = r.deletedFile ? ` (also deleted ${r.deletedFile})` : '';
376
+ console.log(` ${pkg.padEnd(40)} unpinned${extra}`);
377
+ }
378
+ // Exit non-zero if ANY of the requested packages weren't in
379
+ // the pin file. Scripts wrapping the CLI rely on the exit
380
+ // code to detect "nothing was removed"; printing the message
381
+ // alone wasn't enough.
382
+ if (unpinFailed) process.exit(1);
383
+ break;
384
+ }
385
+
386
+ if (sub === 'list') {
387
+ const entries = await listPinned(appDir);
388
+ if (entries.length === 0) {
389
+ console.log('No pin file. Run "webjs vendor pin" to create .webjs/vendor/importmap.json.');
390
+ break;
391
+ }
392
+ console.log(`Pinned packages from ${appDir}/.webjs/vendor/importmap.json:`);
393
+ for (const e of entries) {
394
+ const sizeStr = e.bytes != null ? ` ${(e.bytes / 1024).toFixed(1)} KB` : '';
395
+ console.log(` ${(e.pkg + '@' + e.version).padEnd(40)}${sizeStr}`);
396
+ console.log(` ${e.url}`);
397
+ }
398
+ console.log(`${entries.length} package${entries.length === 1 ? '' : 's'} pinned.`);
399
+ break;
400
+ }
401
+
402
+ if (sub === 'audit') {
403
+ // npm bulk-advisories check against pinned versions. Mirrors
404
+ // bin/importmap audit. Exits non-zero when any vulnerability
405
+ // is found so CI can gate on it.
406
+ const { vulnerable, totalChecked, errored } = await auditPinned(appDir);
407
+ if (totalChecked === 0) {
408
+ console.log('No pinned packages to audit. Run "webjs vendor pin" first.');
409
+ break;
410
+ }
411
+ if (errored) {
412
+ console.error(
413
+ `Could not reach registry.npmjs.org for security advisories ` +
414
+ `(network failure, timeout, or 5xx). Retry when connectivity is back.`,
415
+ );
416
+ process.exit(1);
417
+ }
418
+ if (vulnerable.length === 0) {
419
+ console.log(`No vulnerable packages found (${totalChecked} checked).`);
420
+ break;
421
+ }
422
+ console.log(`Package Severity Vulnerable versions Title`);
423
+ for (const v of vulnerable) {
424
+ console.log(
425
+ ` ${v.name.padEnd(38)} ${v.severity.padEnd(10)} ${v.vulnerableVersions.padEnd(25)} ${v.title}`,
426
+ );
427
+ }
428
+ const bySeverity = vulnerable.reduce((acc, v) => {
429
+ acc[v.severity] = (acc[v.severity] || 0) + 1;
430
+ return acc;
431
+ }, /** @type {Record<string,number>} */ ({}));
432
+ const summary = Object.entries(bySeverity)
433
+ .sort((a, b) => b[1] - a[1])
434
+ .map(([sev, n]) => `${n} ${sev}`).join(', ');
435
+ console.error(
436
+ ` ${vulnerable.length} vulnerabilit${vulnerable.length === 1 ? 'y' : 'ies'} found: ${summary}`,
437
+ );
438
+ process.exit(1);
439
+ }
440
+
441
+ if (sub === 'outdated') {
442
+ // npm registry latest-version check against pinned versions.
443
+ // Mirrors bin/importmap outdated. Exits non-zero when any
444
+ // package is outdated so CI / Renovate-style automation can
445
+ // detect it.
446
+ const outdated = await findOutdated(appDir);
447
+ if (outdated.length === 0) {
448
+ console.log('No outdated packages found.');
449
+ break;
450
+ }
451
+ console.log(`Package Current Latest`);
452
+ for (const o of outdated) {
453
+ console.log(` ${o.pkg.padEnd(38)} ${o.current.padEnd(21)} ${o.latest}`);
454
+ }
455
+ console.error(
456
+ ` ${outdated.length} outdated package${outdated.length === 1 ? '' : 's'} found.`,
457
+ );
458
+ process.exit(1);
459
+ }
460
+
461
+ if (sub === 'update') {
462
+ // Re-pin outdated packages to latest. Mirrors bin/importmap
463
+ // update. Does NOT modify package.json or node_modules; the
464
+ // user should run `npm install <pkg>@<latest>` afterward to
465
+ // keep the local install in sync.
466
+ //
467
+ // Provider precedence: explicit --from CLI flag wins. Without
468
+ // it, updatePinned reads the pin file's persisted provider so
469
+ // a user who pinned via jsdelivr stays on jsdelivr after
470
+ // update. Pass `undefined` (not the parsed `from = 'jspm'`
471
+ // default) when no --from was given so updatePinned's
472
+ // pin-file fallback engages.
473
+ const explicitFrom = fromIdx !== -1 ? from : undefined;
474
+ const existing = await readPinFile(appDir);
475
+ const usedFrom = explicitFrom || existing?.provider || 'jspm';
476
+ console.log(`Updating outdated vendor pins in ${appDir}${usedFrom !== 'jspm' ? ` via ${usedFrom}` : ''}...`);
477
+ const result = await updatePinned(appDir, { from: explicitFrom });
478
+ if (result.noOutdated) {
479
+ console.log('No outdated packages found.');
480
+ break;
481
+ }
482
+ if (result.updated.length === 0) {
483
+ console.error('No packages were updated (jspm.io may have failed to resolve any of the new versions).');
484
+ process.exit(1);
485
+ }
486
+ for (const u of result.updated) {
487
+ console.log(` ${u.pkg.padEnd(38)} ${u.from} → ${u.to}`);
488
+ }
489
+ console.log(
490
+ `Updated ${result.updated.length} package${result.updated.length === 1 ? '' : 's'}. ` +
491
+ `Run \`npm install ${result.updated.map(u => `${u.pkg}@${u.to}`).join(' ')}\` to ` +
492
+ `sync your node_modules.`,
493
+ );
494
+ break;
495
+ }
496
+
497
+ console.error(`Unknown vendor subcommand: ${sub || '(none)'}\n` +
498
+ `Usage:\n` +
499
+ ` webjs vendor pin [--from PROVIDER] [--download] Pin packages to .webjs/vendor/importmap.json\n` +
500
+ ` webjs vendor unpin <pkg> Remove a package from the pin file\n` +
501
+ ` webjs vendor list Show pinned packages with versions and URLs\n` +
502
+ ` webjs vendor audit Run a security audit against pinned versions\n` +
503
+ ` webjs vendor outdated Check pinned packages for newer versions\n` +
504
+ ` webjs vendor update [--from PROVIDER] Re-pin outdated packages to latest\n` +
505
+ `\n` +
506
+ ` --from PROVIDER CDN to resolve through. One of: ${[...SUPPORTED_PROVIDERS].join(', ')}. Default: jspm.`);
507
+ process.exit(1);
508
+ }
269
509
  case 'help':
270
510
  case undefined:
271
511
  console.log(USAGE);
package/lib/create.js CHANGED
@@ -373,6 +373,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
373
373
  '.claude/hooks/block-prose-punctuation.sh',
374
374
  '.claude/hooks/guard-branch-context.sh',
375
375
  '.claude/hooks/nudge-uncommitted.sh',
376
+ '.claude/hooks/require-tests-with-src.sh',
376
377
  // Gemini CLI config + hooks
377
378
  '.gemini/settings.json',
378
379
  '.gemini/hooks/nudge-uncommitted.sh',
@@ -381,9 +382,14 @@ export async function scaffoldApp(name, cwd, opts = {}) {
381
382
  '.cursor/hooks/nudge-uncommitted.sh',
382
383
  // OpenCode plugins (loaded as TS by Bun at runtime)
383
384
  '.opencode/plugins/nudge-uncommitted.ts',
385
+ // Antigravity workspace rules (Google's documented convention is
386
+ // `.agents/rules/*.md`, lowercase, per the Codelab
387
+ // "Build Autonomous Developer Pipelines using agents.md and skills.md
388
+ // in Antigravity"). Replaced the legacy `.windsurfrules` ship when
389
+ // Windsurf was acquired by Google.
390
+ '.agents/rules/workflow.md',
384
391
  // Cross-agent config files
385
392
  '.cursorrules',
386
- '.windsurfrules',
387
393
  '.github/copilot-instructions.md',
388
394
  '.github/pull_request_template.md',
389
395
  '.editorconfig',
@@ -400,7 +406,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
400
406
 
401
407
  // Make hook scripts executable
402
408
  const { chmod } = await import('node:fs/promises');
403
- for (const hook of ['block-prose-punctuation.sh', 'guard-branch-context.sh', 'nudge-uncommitted.sh']) {
409
+ for (const hook of ['block-prose-punctuation.sh', 'guard-branch-context.sh', 'nudge-uncommitted.sh', 'require-tests-with-src.sh']) {
404
410
  const hookPath = join(appDir, '.claude', 'hooks', hook);
405
411
  if (existsSync(hookPath)) await chmod(hookPath, 0o755);
406
412
  }
@@ -606,7 +612,7 @@ export type ActionResult<T> =
606
612
  .replace(/`/g, '\\`')
607
613
  .replace(/\$\{/g, '\\${');
608
614
 
609
- await writeFile(join(appDir, 'app', 'layout.ts'), `import { html } from '@webjsdev/core';
615
+ await writeFile(join(appDir, 'app', 'layout.ts'), `import { html, cspNonce } from '@webjsdev/core';
610
616
  import '@webjsdev/core/client-router';
611
617
  import '../components/theme-toggle.ts';
612
618
  // Webjs UI components are tiered:
@@ -638,8 +644,13 @@ const navLink = (href: string, label: string) => html\`
638
644
  \`;
639
645
 
640
646
  export default function RootLayout({ children }: { children: unknown }) {
647
+ // Read the in-flight request's CSP nonce so the theme-detection
648
+ // inline script below passes strict CSP (script-src 'nonce-...').
649
+ // Returns '' when no CSP nonce is set, in which case the attribute
650
+ // is empty and the browser ignores it.
651
+ const nonce = cspNonce();
641
652
  return html\`
642
- <script>
653
+ <script nonce="\${nonce}">
643
654
  (function(){
644
655
  try {
645
656
  var mq = window.matchMedia('(prefers-color-scheme: light)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.9.1",
3
+ "version": "0.10.1",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "README.md"
14
14
  ],
15
15
  "dependencies": {
16
- "@webjsdev/server": "^0.7.2",
16
+ "@webjsdev/server": "^0.8.0",
17
17
  "@webjsdev/ui": "^0.3.1"
18
18
  },
19
19
  "publishConfig": {
@@ -0,0 +1,149 @@
1
+ # Antigravity Workspace Rules: webjs app
2
+
3
+ You are working on a webjs app, an AI-first, no-build, web-components-first
4
+ framework. Read AGENTS.md for the full API reference and CONVENTIONS.md for
5
+ project-specific conventions before writing any code. When AGENTS.md does not
6
+ cover what you need, the full hosted docs are at **https://docs.webjs.com**.
7
+
8
+ ## Persistence + scaffold rules (non-negotiable)
9
+
10
+ - **Use Prisma + SQLite for data, never JSON files.** It is already wired up
11
+ (`prisma/schema.prisma`, `lib/prisma.server.ts`, `npm run db:migrate`). For
12
+ ANY data the app stores (todos, posts, messages, products, comments), define
13
+ a Prisma model. NEVER create `data/*.json`, `db.json`, or any JSON file as a
14
+ fake database. NEVER use module-scope arrays / Maps as a substitute. NEVER
15
+ use localStorage for app data. `webjs check`'s `no-json-data-files` rule
16
+ will fail the build if you do.
17
+ - **The scaffold is reference, not the final product.** Replace `app/page.ts`,
18
+ the example `User` model, the example users module, etc. with the app the
19
+ user actually asked for. Do not ship "Hello from <app-name>" as the
20
+ deliverable.
21
+ - **Only three templates exist:** `webjs create <name>` (default full-stack),
22
+ `--template api`, `--template saas`. The CLI rejects any other `--template`
23
+ value. Pick:
24
+ - Any product UI (todo, blog, dashboard, marketplace, social) goes through
25
+ the default template.
26
+ - HTTP/JSON API only, no UI, uses `--template api`.
27
+ - Auth / login / signup / SaaS uses `--template saas`.
28
+
29
+ ## Before starting ANY work
30
+
31
+ FIRST, before writing any code:
32
+ 1. Check `git branch --show-current`.
33
+ - If on main/master: create a feature branch before editing.
34
+ - If on a feature branch: verify it matches the current task.
35
+ 2. Sync: `git fetch origin && git rebase origin/main` if behind.
36
+
37
+ ## Autonomous mode (sandbox / no-prompt)
38
+
39
+ If running without interactive approval, auto-decide:
40
+ - On main? Auto-create feature/<task-slug> branch.
41
+ - Parent behind? Auto-rebase. Merge? Auto-merge + delete feature branches.
42
+ - Auto-generate commit messages. Fix failing tests and violations.
43
+ Quality bar stays the same, no blocking on questions.
44
+
45
+ ## Mandatory workflow (never skip)
46
+
47
+ Every code change must include:
48
+ 1. Server tests in `test/<feature>/*.test.ts` (node:test).
49
+ 2. Browser tests in `test/<feature>/browser/*.test.js` (WTR + Playwright, real Chromium).
50
+ 3. Documentation updates. Walk every surface in the **Definition of done**
51
+ section of CONVENTIONS.md (AGENTS.md, CONVENTIONS.md, README.md, docs/,
52
+ website/, scaffold scripts) and either update it or write
53
+ "N/A because <reason>" in the PR body. Docs land on the same PR as the
54
+ code, never as a follow-up.
55
+ 4. Convention check: `webjs check` must pass.
56
+ 5. Pre-merge self-review loop. Before saying the PR is ready for merge, run
57
+ fresh-context review rounds until one round finds zero issues. Antigravity
58
+ primitive: open a new Cascade thread or a fresh side-panel session for
59
+ each round so the reviewer has no prior context on the implementation
60
+ decisions. Minimum two rounds; rotate focus each round. Skip the loop
61
+ only for one-line trivial changes; skipping on a change that touches
62
+ logic, public surface, build, security, or multiple files is the exact
63
+ failure mode the loop exists to prevent. The full rule, prompt template,
64
+ and reporting contract live in the **Pre-merge self-review loop** section
65
+ of CONVENTIONS.md.
66
+
67
+ The user should never have to ask for tests, documentation, or the
68
+ self-review loop.
69
+
70
+ ## Git rules
71
+
72
+ - COMMIT AND PUSH PER LOGICAL UNIT, NOT AT THE END. One feature, one fix, one
73
+ rename, one doc rewrite per commit. Always `git push` after committing.
74
+ The user should never have to ask for a commit.
75
+ - HARD LIMIT: if you have 5+ unstaged files spanning different concerns,
76
+ commit before continuing. The Claude Code hook at
77
+ `.claude/hooks/nudge-uncommitted.sh` fires at threshold 4. Antigravity
78
+ users should self-enforce the same rule. Batching multiple logical units
79
+ into one commit is the failure mode this rule exists to prevent.
80
+ - Meaningful commit messages: what changed and why.
81
+ - NEVER add Co-Authored-By or AI attribution trailers to commits.
82
+ - NEVER use em-dashes (U+2014), a hyphen-as-pause (` - `), or a
83
+ semicolon-as-pause (` ; `) in commit messages or anywhere else. Rewrite the
84
+ sentence so no pause-punctuation crutch is needed. Use a period, comma,
85
+ colon, parentheses, or a restructured phrasing. Plain hyphens stay fine in
86
+ compound words, CLI flags, filenames, and ranges. Semicolons stay fine
87
+ inside code.
88
+ - Work on feature branches, never push directly to main.
89
+ - Create pull requests for review.
90
+ - NEVER merge any branch without explicit user permission. Always ask:
91
+ "Ready to merge <branch> into <target>? Delete or keep <branch> after?"
92
+ Wait for approval AND the delete/keep preference. Applies to ALL merges.
93
+ - Run `webjs test` before every commit.
94
+
95
+ ## Framework rules
96
+
97
+ - No build step: ES modules served directly.
98
+ - **Erasable TypeScript only.** Node 24+ strips types via
99
+ `module.stripTypeScriptTypes` (whitespace replacement, byte-exact position
100
+ preservation, no sourcemap). The scaffold's `tsconfig.json` sets
101
+ `erasableSyntaxOnly: true`, so the TS compiler rejects `enum`, `namespace`
102
+ with values, constructor parameter properties, legacy decorators with
103
+ `emitDecoratorMetadata`, and `import = require`. Use erasable equivalents:
104
+ `const X = { ... } as const` plus a derived union type instead of `enum`;
105
+ explicit fields plus constructor body assignments instead of parameter
106
+ properties. If `erasableSyntaxOnly` is disabled and non-erasable syntax is
107
+ used, the dev server fails at strip time and returns a 500 pointing at the
108
+ `no-non-erasable-typescript` lint rule. webjs is buildless end-to-end and
109
+ has no bundler fallback.
110
+ - Web components render into light DOM by default (so Tailwind / global CSS
111
+ apply directly). Opt in to shadow DOM per component with
112
+ `static shadow = true` when you need scoped styles (via
113
+ `static styles = css\`...\``) or third-party-embed isolation. `<slot>`
114
+ projection works identically in both modes (named slots, fallback content,
115
+ `assignedNodes` / `slotchange`, first-wins resolution).
116
+ - Custom-element tag names are passed to `.register('tag-name')`. They are NOT
117
+ a static field on the class.
118
+ - One function per server action file (`*.server.ts`).
119
+ - Server-only code (`@prisma/client`, `node:*`, anything that needs Node APIs)
120
+ goes only in `.server.{js,ts}` files, `route.ts` handlers, or
121
+ `middleware.ts`. Never in pages, layouts, or components. Wrap the access in
122
+ a `.server.{js,ts}` file; the framework rewrites that import into an RPC
123
+ stub for the browser. `lib/` holds both server-only infra
124
+ (`lib/prisma.server.ts`) and browser-safe utilities (`lib/utils/cn.ts` with
125
+ `cn`); follow the same rule per file.
126
+ - Directives are deliberately minimal: only `unsafeHTML`, `live`, and `repeat`
127
+ ship. Use plain template-literal expressions
128
+ (`class=${active ? 'btn active' : 'btn'}`, `style=${'color:' + color}`,
129
+ `${cond ? a : b}`) and lifecycle hooks (`this.query('#el')` in
130
+ `firstUpdated`) instead of Lit's `classMap` / `styleMap` / `ref` / `when` /
131
+ `choose` / `guard`.
132
+ - Use Context for cross-component data, Task for async data in components.
133
+ - **Progressive enhancement is the default.** Pages AND every web component
134
+ are SSR'd to real HTML. Write components so the first paint is the right
135
+ content. Read SSR-meaningful defaults in `constructor()`. `connectedCallback`
136
+ is never called on the server, so anything there only runs after
137
+ hydration. Initial data for components comes from the page function
138
+ (server-side fetch plus pass as attribute/property), NOT from `fetch` calls
139
+ in `connectedCallback`. For write-paths, prefer `<form action=...>` plus
140
+ server action over `fetch` plus click handler. The framework upgrades plain
141
+ forms to partial-swap submissions automatically.
142
+ - **Client navigation is auto-magic.** Real `<a href>` and `<form action>`
143
+ get partial-swap behavior with no opt-in. Because layouts persist across
144
+ navigation, put shared chrome (sidenav, header) in `layout.ts` and
145
+ page-specific content in `page.ts`. For validation errors, return 4xx HTML
146
+ from a `route.ts` POST handler; the router renders it in place preserving
147
+ the user's input. For non-layout swap regions, wrap in `<webjs-frame id="...">`. See "Client
148
+ navigation patterns" in AGENTS.md.
149
+ - Full API reference in AGENTS.md.
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # PreToolUse hook (scaffolded by `webjs create`): block a `git commit`
4
+ # that adds or changes application code without any accompanying test.
5
+ #
6
+ # webjs is AI-first: most apps are built with an AI agent, and the
7
+ # easiest corner to cut is shipping a feature with no test. This gate
8
+ # makes "every change ships with a test" a hard floor, not a suggestion.
9
+ #
10
+ # What a hook CANNOT do: judge WHICH test layer a change needs (a unit
11
+ # test vs a browser/e2e test is a judgement call). So it enforces the
12
+ # floor (some real test must accompany app code) and reminds you to add
13
+ # browser/e2e coverage for interactive surfaces. `webjs test` runs the
14
+ # actual suite in the commit hook.
15
+ #
16
+ # Scope: fires only on `git commit`. Inspects the STAGED diff.
17
+ #
18
+ # Blocks (exit 2) when the staged diff changes app code (app/, modules/,
19
+ # components/, lib/) but stages no test (test/** or *.test.* / *.spec.*).
20
+ # Allowed: commits with no app-code change, commits that stage a test
21
+ # alongside, and WEBJS_NO_TEST_GATE=1 for a genuine non-code commit.
22
+ #
23
+ # Bypass (humans, emergencies): git commit --no-verify.
24
+
25
+ set -euo pipefail
26
+
27
+ if [ "${WEBJS_NO_TEST_GATE:-}" = "1" ]; then
28
+ exit 0
29
+ fi
30
+
31
+ payload=$(cat)
32
+ cmd=$(printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
33
+ if [ -z "$cmd" ]; then exit 0; fi
34
+ # Match `git commit` as a whole word so sibling subcommands
35
+ # (git commit-graph, git commit-tree) and string mentions do not trip it.
36
+ if ! printf '%s' "$cmd" | grep -Eq '(^|[^[:alnum:]-])git commit([^[:alnum:]-]|$)'; then
37
+ exit 0
38
+ fi
39
+
40
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 0; fi
41
+
42
+ staged=$(git diff --cached --name-only 2>/dev/null || true)
43
+ if [ -z "$staged" ]; then exit 0; fi
44
+
45
+ # App code lives under app/, modules/, components/, lib/. A `.server.*`
46
+ # file is still app code. Match source extensions only (skip .css, .md).
47
+ app_code=$(printf '%s\n' "$staged" \
48
+ | grep -E '^(app|modules|components|lib)/.*\.([mc]?[jt]sx?)$' || true)
49
+ if [ -z "$app_code" ]; then exit 0; fi
50
+
51
+ test_staged=$(printf '%s\n' "$staged" \
52
+ | grep -E '(^|/)test/|\.test\.[mc]?[jt]sx?$|\.spec\.[mc]?[jt]sx?$' || true)
53
+
54
+ if [ -z "$test_staged" ]; then
55
+ cat >&2 <<'EOF'
56
+ BLOCKED: this commit changes app code but stages no test.
57
+
58
+ You staged application code (app/, modules/, components/, lib/) with no
59
+ accompanying test. Every change ships with a test. Add or update the test
60
+ that proves the new behaviour, then `git add` it.
61
+
62
+ Pick the layer the change needs (a unit test is not always enough):
63
+ - logic / actions / queries / utils -> a unit test
64
+ - a component, hydration, a server action called from the client, the
65
+ router, anything interactive -> a browser or e2e test that asserts the
66
+ real behaviour in a browser, not just the function in isolation.
67
+
68
+ See `webjs test` and the testing guide. Genuine non-code commit (docs,
69
+ config) that needs no test? Re-run with WEBJS_NO_TEST_GATE=1.
70
+
71
+ Hook: .claude/hooks/require-tests-with-src.sh
72
+ EOF
73
+ exit 2
74
+ fi
75
+
76
+ # Reminder for interactive surfaces: a unit test alone rarely covers them.
77
+ interactive=$(printf '%s\n' "$app_code" | grep -E '^components/|/components/' || true)
78
+ if [ -n "$interactive" ]; then
79
+ jq -n --arg ctx "Reminder: this commit changes component code. A unit test alone usually is not enough for an interactive component; add a browser test (webjs test --browser) that asserts the rendered/hydrated behaviour." '{
80
+ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: $ctx }
81
+ }'
82
+ fi
83
+ exit 0
@@ -18,6 +18,15 @@
18
18
  "command": ".claude/hooks/guard-branch-context.sh"
19
19
  }
20
20
  ]
21
+ },
22
+ {
23
+ "matcher": "Bash",
24
+ "hooks": [
25
+ {
26
+ "type": "command",
27
+ "command": ".claude/hooks/require-tests-with-src.sh"
28
+ }
29
+ ]
21
30
  }
22
31
  ],
23
32
  "PostToolUse": [