@webjsdev/cli 0.10.30 → 0.10.32

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.
Files changed (65) hide show
  1. package/bin/webjs.js +1 -1
  2. package/lib/api-gallery.js +229 -0
  3. package/lib/create.js +462 -161
  4. package/lib/saas-template.js +39 -15
  5. package/package.json +1 -1
  6. package/templates/.agents/rules/workflow.md +78 -1
  7. package/templates/.claude/hooks/check-server-imports.mjs +86 -0
  8. package/templates/.claude/hooks/check-server-imports.sh +26 -0
  9. package/templates/.claude/hooks/cleanup-merged-worktree.sh +129 -0
  10. package/templates/.claude/hooks/commit-before-stop.sh +52 -0
  11. package/templates/.claude/settings.json +28 -0
  12. package/templates/.cursorrules +50 -1
  13. package/templates/.github/copilot-instructions.md +50 -1
  14. package/templates/AGENTS.md +180 -13
  15. package/templates/CLAUDE.md +22 -0
  16. package/templates/CONVENTIONS.md +165 -12
  17. package/templates/gallery/app/examples/todo/page.ts +34 -0
  18. package/templates/gallery/app/features/async-render/page.ts +14 -0
  19. package/templates/gallery/app/features/broadcast/feed/route.ts +19 -0
  20. package/templates/gallery/app/features/broadcast/page.ts +24 -0
  21. package/templates/gallery/app/features/caching/page.ts +39 -0
  22. package/templates/gallery/app/features/client-router/page.ts +34 -0
  23. package/templates/gallery/app/features/client-router/second/page.ts +20 -0
  24. package/templates/gallery/app/features/components/page.ts +14 -0
  25. package/templates/gallery/app/features/directives/page.ts +14 -0
  26. package/templates/gallery/app/features/env/page.ts +36 -0
  27. package/templates/gallery/app/features/file-storage/file/[key]/route.ts +19 -0
  28. package/templates/gallery/app/features/file-storage/page.ts +62 -0
  29. package/templates/gallery/app/features/forms/page.ts +78 -0
  30. package/templates/gallery/app/features/metadata/page.ts +55 -0
  31. package/templates/gallery/app/features/optimistic-ui/page.ts +14 -0
  32. package/templates/gallery/app/features/rate-limit/page.ts +29 -0
  33. package/templates/gallery/app/features/rate-limit/ping/middleware.ts +8 -0
  34. package/templates/gallery/app/features/rate-limit/ping/route.ts +7 -0
  35. package/templates/gallery/app/features/route-handler/data/route.ts +8 -0
  36. package/templates/gallery/app/features/route-handler/page.ts +13 -0
  37. package/templates/gallery/app/features/routing/[id]/page.ts +25 -0
  38. package/templates/gallery/app/features/routing/page.ts +47 -0
  39. package/templates/gallery/app/features/server-actions/page.ts +14 -0
  40. package/templates/gallery/app/features/service-worker/page.ts +36 -0
  41. package/templates/gallery/app/features/websockets/echo/route.ts +19 -0
  42. package/templates/gallery/app/features/websockets/page.ts +25 -0
  43. package/templates/gallery/modules/async-render/components/server-clock.ts +26 -0
  44. package/templates/gallery/modules/async-render/queries/server-greeting.server.ts +9 -0
  45. package/templates/gallery/modules/broadcast/components/broadcast-feed.ts +61 -0
  46. package/templates/gallery/modules/components/components/browser/counter-card.test.js +36 -0
  47. package/templates/gallery/modules/components/components/counter-card.ts +35 -0
  48. package/templates/gallery/modules/directives/components/directive-demo.ts +53 -0
  49. package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +18 -0
  50. package/templates/gallery/modules/optimistic-ui/actions/like-post.server.ts +9 -0
  51. package/templates/gallery/modules/optimistic-ui/components/like-button.ts +35 -0
  52. package/templates/gallery/modules/rate-limit/components/rate-probe.ts +49 -0
  53. package/templates/gallery/modules/server-actions/actions/greet.server.ts +12 -0
  54. package/templates/gallery/modules/server-actions/components/greeter.ts +30 -0
  55. package/templates/gallery/modules/server-actions/utils/format.server.ts +8 -0
  56. package/templates/gallery/modules/todo/actions/create-todo.server.ts +16 -0
  57. package/templates/gallery/modules/todo/actions/delete-todo.server.ts +15 -0
  58. package/templates/gallery/modules/todo/actions/toggle-todo.server.ts +21 -0
  59. package/templates/gallery/modules/todo/components/todo-app.ts +140 -0
  60. package/templates/gallery/modules/todo/queries/list-todos.server.ts +25 -0
  61. package/templates/gallery/modules/todo/types.ts +12 -0
  62. package/templates/gallery/modules/websockets/components/ws-echo.ts +62 -0
  63. package/templates/lib/utils/ui.ts +4 -4
  64. package/templates/test/hello/browser/hello.test.js +6 -0
  65. package/templates/web-test-runner.config.js +9 -1
@@ -106,7 +106,7 @@ export async function writeSaasFiles(appDir, opts = {}) {
106
106
  // add passwordHash (the column auth needs). Drizzle, dialect-agnostic.
107
107
  await writeFile(join(appDir, 'db', 'schema.server.ts'), [
108
108
  "import { defineRelations } from 'drizzle-orm';",
109
- "import { table, pk, text, createdAt } from './columns.server.ts';",
109
+ "import { table, pk, uuidPk, text, bool, createdAt } from './columns.server.ts';",
110
110
  "",
111
111
  "export const users = table('users', {",
112
112
  " id: pk(),",
@@ -116,7 +116,16 @@ export async function writeSaasFiles(appDir, opts = {}) {
116
116
  " createdAt: createdAt(),",
117
117
  "});",
118
118
  "",
119
- "export const relations = defineRelations({ users }, () => ({}));",
119
+ "// Backs the example-gallery /examples/todo route (modules/todo). Delete it",
120
+ "// with the gallery when you prune the examples you do not use.",
121
+ "export const todos = table('todos', {",
122
+ " id: uuidPk(),",
123
+ " title: text().notNull(),",
124
+ " completed: bool().notNull().default(false),",
125
+ " createdAt: createdAt(),",
126
+ "});",
127
+ "",
128
+ "export const relations = defineRelations({ users, todos }, () => ({}));",
120
129
  "",
121
130
  "export type User = typeof users.$inferSelect;",
122
131
  "",
@@ -132,12 +141,19 @@ export async function writeSaasFiles(appDir, opts = {}) {
132
141
  "import { db } from '#db/connection.server.ts';",
133
142
  "import { users } from '#db/schema.server.ts';",
134
143
  "import { hash } from '#lib/password.server.ts';",
135
- "",
144
+ "import { signIn } from '#lib/auth.server.ts';",
145
+ "",
146
+ "// Creates the account, then signs the new user in and lands on the",
147
+ "// dashboard. signIn returns a 302 Response carrying the session cookie; the",
148
+ "// signup page action returns that Response as-is (a page action may return a",
149
+ "// Response). signIn lives in the server-only auth module, imported here",
150
+ "// server-to-server, so it never reaches the browser (the signup page only",
151
+ "// imports this action's RPC stub).",
136
152
  "export async function signup(input: { name: string; email: string; password: string }) {",
137
153
  " const exists = await db.query.users.findFirst({ where: { email: input.email }, columns: { id: true } });",
138
154
  " if (exists) return { success: false as const, error: 'Email already registered', status: 409 };",
139
- " const [user] = await db.insert(users).values({ name: input.name, email: input.email, passwordHash: await hash(input.password) }).returning();",
140
- " return { success: true as const, data: { id: user.id, name: user.name, email: user.email } };",
155
+ " await db.insert(users).values({ name: input.name, email: input.email, passwordHash: await hash(input.password) });",
156
+ " return signIn('credentials', { email: input.email, password: input.password }, { redirectTo: '/dashboard' });",
141
157
  "}",
142
158
  "",
143
159
  ].join('\n'));
@@ -252,10 +268,11 @@ export async function writeSaasFiles(appDir, opts = {}) {
252
268
  " headers: { 'content-type': 'application/x-www-form-urlencoded' },",
253
269
  " body: new URLSearchParams({ name: 'Harness', email, password }).toString(),",
254
270
  " });",
255
- " // Success is a 303 PRG to /login; a 422 means validation failed (still a",
256
- " // real response, just not the happy path). Either way the action ran.",
257
- " assert.ok([303, 422].includes(signupRes.status), 'signup action ran');",
258
- " if (signupRes.status !== 303) canSignup = false;",
271
+ " // Success auto-logs-in and 302s to /dashboard (carrying the session",
272
+ " // cookie); a 422 means validation failed. Either way the action ran.",
273
+ " assert.ok([302, 422].includes(signupRes.status), 'signup action ran');",
274
+ " if (signupRes.status === 302) assert.equal(signupRes.headers.get('location'), '/dashboard', 'signup lands on the dashboard');",
275
+ " if (signupRes.status !== 302) canSignup = false;",
259
276
  " } catch {",
260
277
  " // No migrated DB table -> the action throws. Skip the DB-backed assertions.",
261
278
  " canSignup = false;",
@@ -270,6 +287,10 @@ export async function writeSaasFiles(appDir, opts = {}) {
270
287
  " assert.equal(dash.status, 200, 'the session cookie unlocks the dashboard');",
271
288
  " const body = await dash.text();",
272
289
  " assert.match(body, /Dashboard/, 'the dashboard content rendered');",
290
+ " // The greeting interpolates the real user, so the name renders and the",
291
+ " // literal template source never leaks (a counterfactual for the escaping bug).",
292
+ " assert.match(body, /Harness/, 'the dashboard greets the signed-in user by name');",
293
+ " assert.ok(!body.includes('${user'), 'the greeting interpolation is not a literal string');",
273
294
  "});",
274
295
  "",
275
296
  ].join('\n');
@@ -308,6 +329,8 @@ export async function writeSaasFiles(appDir, opts = {}) {
308
329
  " </div>",
309
330
  " <div class=${cardContentClass()}>",
310
331
  " <form method=\"POST\" action=\"/api/auth/signin/credentials\" class=\"flex flex-col gap-4\">",
332
+ " <!-- createAuth reads redirectTo from the posted form and 302s there after a successful signin. -->",
333
+ " <input type=\"hidden\" name=\"redirectTo\" value=\"/dashboard\">",
311
334
  " <div class=\"flex flex-col gap-1.5\">",
312
335
  " <label class=${labelClass()} for=\"email\">Email</label>",
313
336
  " <input class=${inputClass()} id=\"email\" name=\"email\" type=\"email\" required>",
@@ -357,9 +380,10 @@ export async function writeSaasFiles(appDir, opts = {}) {
357
380
  " if (password.length < 8) fieldErrors.password = 'At least 8 characters';",
358
381
  " if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 };",
359
382
  " const result = await signup({ name, email, password });",
360
- " if (!result.success) return { success: false, fieldErrors: { email: result.error }, values, status: result.status };",
361
- " // Account created. Redirect to login via PRG so a reload will not resubmit.",
362
- " return { success: true, redirect: '/login' };",
383
+ " // On success signup returns signIn's 302 Response (auto-login -> /dashboard);",
384
+ " // a page action may return a Response, so pass it straight through.",
385
+ " if (result instanceof Response) return result;",
386
+ " return { success: false, fieldErrors: { email: result.error }, values, status: result.status };",
363
387
  "}",
364
388
  "",
365
389
  "export default function SignupPage({ actionData }: { actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> } }) {",
@@ -436,7 +460,7 @@ export async function writeSaasFiles(appDir, opts = {}) {
436
460
  " </div>",
437
461
  " <div class=${cardClass()}>",
438
462
  " <div class=${cardHeaderClass()}>",
439
- " <h3 class=${cardTitleClass()}>Welcome, ${`\\$\\{user?.name || user?.email\\}`}!</h3>",
463
+ " <h3 class=${cardTitleClass()}>Welcome, ${user?.name || user?.email}!</h3>",
440
464
  " <p class=${cardDescriptionClass()}>You're authenticated. Replace this scaffold with your real app.</p>",
441
465
  " </div>",
442
466
  " <div class=${cardContentClass()}>",
@@ -468,9 +492,9 @@ export async function writeSaasFiles(appDir, opts = {}) {
468
492
  " <div class=${cardContentClass()}>",
469
493
  " <dl class=\"grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm\">",
470
494
  " <dt class=\"text-muted-foreground\">Email</dt>",
471
- " <dd>${`\\$\\{user?.email\\}`}</dd>",
495
+ " <dd>${user?.email}</dd>",
472
496
  " <dt class=\"text-muted-foreground\">Name</dt>",
473
- " <dd>${`\\$\\{user?.name || 'Not set'\\}`}</dd>",
497
+ " <dd>${user?.name || 'Not set'}</dd>",
474
498
  " </dl>",
475
499
  " </div>",
476
500
  " </div>",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.30",
3
+ "version": "0.10.32",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -3,7 +3,7 @@
3
3
  You are working on a webjs app, an AI-first, no-build, web-components-first
4
4
  framework. Read AGENTS.md for the full API reference and CONVENTIONS.md for
5
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**.
6
+ cover what you need, the full hosted docs are at **https://docs.webjs.dev**.
7
7
 
8
8
  ## Persistence + scaffold rules (non-negotiable)
9
9
 
@@ -19,6 +19,50 @@ cover what you need, the full hosted docs are at **https://docs.webjs.com**.
19
19
  the example `User` model, the example users module, etc. with the app the
20
20
  user actually asked for. Do not ship "Hello from <app-name>" as the
21
21
  deliverable.
22
+ - **Study the gallery first, prune it second.** The full-stack and saas scaffolds
23
+ ship single-feature demos under `app/features/` plus one whole example app under
24
+ `app/examples/` (logic in `modules/`). It is your PRIMARY webjs reference: read
25
+ every demo end to end (code AND comments) to learn the idioms BEFORE you write
26
+ or delete anything. Only after internalising the patterns should you prune,
27
+ keeping and adapting what you need and deleting the rest (the
28
+ `app/features/<name>` or `app/examples/<name>` route AND its `modules/<name>`;
29
+ for the todo app, also the `todos` table). Never delete blindly up front. Each
30
+ route page has a `webjs-scaffold-placeholder` marker so `webjs check` fails
31
+ until you resolve it. Delete now-empty directories after pruning. The `api`
32
+ template ships a BACKEND-features showcase instead (endpoints under
33
+ `app/api/features/`: the `route()` adapter + validation, rate limiting,
34
+ streaming, file storage, WebSockets + broadcast, plus `env.ts` validation),
35
+ listed in the root `app/route.ts` index; prune it the same way.
36
+ - **Prune what the app does not use.** Keep the infrastructure the app USES
37
+ and delete the rest (files AND folders). No persistence means delete `db/`,
38
+ `drizzle.config.ts`, and the `db:*` scripts. No UI kit used means delete
39
+ `components/ui/`, `components.json`, and `lib/utils/cn.ts`. No PWA means
40
+ delete `public/sw.js` and `offline.html`. Always KEEP the durable knowledge
41
+ (`AGENTS.md`, `CONVENTIONS.md`, the rule files, the MCP), never prune it, so
42
+ removing example code never removes your context. Prune AFTER using the
43
+ features and examples as reference, never blindly up front. A no-op for the `api` template
44
+ (no UI kit, no PWA files).
45
+ - **`app/` is routing-only.** Only routing files live in `app/` (page, layout,
46
+ route, middleware, metadata routes). CSS, helpers, and constants do NOT:
47
+ `globals.css` is at `styles/`, browser-safe helpers at `lib/utils/`, feature
48
+ logic in `modules/`.
49
+ - **Use a unique design, and redesign means more than recolor (UI apps).** Give
50
+ the app a design of its own (palette, typography, LAYOUT, and chrome) chosen
51
+ from what the app IS. Recoloring the scaffold and swapping the logo while
52
+ keeping its skeleton (a fixed top header with a Home link and a theme toggle,
53
+ the centered ~760px reading column, the "Built with webjs" footer) is NOT a
54
+ unique design. Decide from scratch whether this app even needs a header or
55
+ footer, what nav (if any), and what layout fits (a centered board, a full-bleed
56
+ dashboard, a split, a single card). The scaffold ships a
57
+ `webjs-scaffold-placeholder` marker on its footer, so `webjs check` fails until
58
+ you remove or replace the "Built with webjs" branding. Self-audit before
59
+ finishing: nothing should read as the scaffold example (no "Built with webjs"
60
+ footer, no leftover example nav, no default reading column unless it truly
61
+ fits). Keep only the design TOKENS and theme wiring in `app/layout.ts`
62
+ (infrastructure the ui kit reads) and restyle on top. Style with Tailwind
63
+ utilities wherever they reach, and use custom CSS only for what utilities
64
+ cannot express (@theme tokens, @keyframes, scrollbar, complex color-mix or
65
+ gradients). The `api` template has no UI, so this does not apply there.
22
66
  - **Only three templates exist:** `webjs create <name>` (default full-stack),
23
67
  `--template api`, `--template saas`. The CLI rejects any other `--template`
24
68
  value. Pick:
@@ -135,10 +179,37 @@ self-review loop.
135
179
  gradients); when unavoidable in a light-DOM component, prefix every class
136
180
  selector with the component tag. Shadow-DOM components legitimately use
137
181
  `static styles = css\`...\`` for scoped CSS.
182
+ - **One theme, canonical tokens.** The app has a SINGLE theme, defined once in
183
+ `app/layout.ts` using the standard `@webjsdev/ui` (shadcn-compatible)
184
+ semantic tokens set to the brand palette. Use the canonical utility names
185
+ everywhere, in the page chrome AND inside components: `bg-background`,
186
+ `text-foreground`, `bg-card`, `bg-muted`, `text-muted-foreground`,
187
+ `bg-primary`, `text-primary-foreground`, `bg-accent`, `text-accent-foreground`,
188
+ `border-border`, `ring-ring`. These are exactly the tokens a component copied
189
+ in by `webjs ui add <name>` reads, so a scaffolded page and a later-added ui
190
+ component share one theme with no wiring. NEVER invent a parallel token
191
+ vocabulary (`--fg`, `--bg`, `text-fg`, `bg-elev`, a separate `--brand`): it
192
+ collides with the ui tokens (the accent once flipped to neutral on navigation
193
+ for exactly this reason) and diverges from the shadcn conventions the kit and
194
+ AI agents expect. Reach for opacity modifiers (`bg-primary/10`,
195
+ `hover:bg-primary/90`) before adding a token; add one the canonical way (a
196
+ `--x` var plus a `--color-x: var(--x)` line in `@theme inline`).
138
197
  - Custom-element tag names are passed to `.register('tag-name')`. They are NOT
139
198
  a static field on the class.
140
199
  - **Never extend raw HTMLElement directly for app components.** Always subclass `WebComponent` (or the factory form `WebComponent({...})`) to hook into SSR, lifecycle, elision, and the reactive property system. Extend raw HTMLElement only for rare native-API edge cases (like form-associated `ElementInternals` or customized built-in elements), and add a `webjs-allow-htmlelement: <reason>` comment to acknowledge the exception.
141
200
  - One function per server action file (`*.server.ts`).
201
+ - **`.server.ts` vs `'use server'`, the decision.** Will the client call it?
202
+ Add `'use server'` and the file becomes an RPC action (browser import
203
+ rewritten to a typed stub). Is it server-only infra instead (a DB driver,
204
+ secrets, `node:*`)? Use NO directive, and NEVER import it into a
205
+ page/layout/component. Reach it from a `'use server'` action, `route.ts`, or
206
+ `middleware.ts`. A `.server.ts` WITHOUT the directive is a server-only utility
207
+ whose browser import throws at module load.
208
+ - **Label every interactive control.** Give each control an accessible name, and
209
+ make clickable text a `<label for="control-id">` (or the control itself) so a
210
+ text click activates the control on BOTH the JS path and the no-JS
211
+ form-submit path. Use `aria-label` and `aria-pressed` on icon-only controls.
212
+ `assertNoA11yViolations(el)` in a browser test catches missing labels.
142
213
  - Server-only code (a DB driver like `pg`, `node:*`, anything that needs Node APIs)
143
214
  goes only in `.server.{js,ts}` files, `route.ts` handlers, or
144
215
  `middleware.ts`. Never in pages, layouts, or components. Wrap the access in
@@ -182,6 +253,12 @@ self-review loop.
182
253
  paint), NOT from `fetch` calls in `connectedCallback`. For write-paths, prefer `<form action=...>` plus
183
254
  server action over `fetch` plus click handler. The framework upgrades plain
184
255
  forms to partial-swap submissions automatically.
256
+ - **Default to optimistic UI for feasible mutations.** Use `optimistic()` from
257
+ `@webjsdev/core` for create/toggle/like/reorder so the UI updates instantly
258
+ and rolls back on failure (no hand-written try-catch or temp-id bookkeeping).
259
+ Do NOT use it where it hurts: unpredictable or server-computed results,
260
+ side-effectful or OAuth/payment mutations, and destructive irreversible
261
+ actions (confirm-first instead).
185
262
  - **Client navigation is auto-magic.** Real `<a href>` and `<form action>`
186
263
  get partial-swap behavior with no opt-in. Because layouts persist across
187
264
  navigation, put shared chrome (sidenav, header) in `layout.ts` and
@@ -0,0 +1,86 @@
1
+ // Node walker for check-server-imports.sh (#804). Reads the PreToolUse JSON
2
+ // payload (arg or stdin), extracts the file being edited and its proposed
3
+ // content, and warns when a browser-facing app module adds an import of a
4
+ // server-only `.server.*` utility (no `'use server'`). WARN by default; a
5
+ // clean edit prints nothing and exits 0.
6
+ import { readFileSync, existsSync } from 'node:fs';
7
+ import { dirname, resolve, join } from 'node:path';
8
+
9
+ function readPayload() {
10
+ const arg = process.argv[2];
11
+ if (arg && arg.trim().startsWith('{')) return arg;
12
+ try { return readFileSync(0, 'utf8'); } catch { return ''; }
13
+ }
14
+
15
+ let data;
16
+ try { data = JSON.parse(readPayload() || '{}'); } catch { process.exit(0); }
17
+ const input = data.tool_input || {};
18
+ const filePath = input.file_path || input.filePath || '';
19
+ if (!filePath) process.exit(0);
20
+
21
+ // Only browser-facing app modules matter. A `.server.*` file (the boundary) or a
22
+ // route.ts / middleware.ts (never shipped) is allowed to import server code.
23
+ const rel = filePath.replace(/\\/g, '/');
24
+ const isAppModule = /\/(app|components|modules|lib)\/.*\.(ts|js|mts|mjs)$/.test('/' + rel) || /(^|\/)(app|components|modules|lib)\//.test(rel);
25
+ if (!isAppModule) process.exit(0);
26
+ if (/\.server\.(ts|js|mts|mjs)$/.test(rel)) process.exit(0);
27
+ if (/(^|\/)(route|middleware)\.(ts|js|mts|mjs)$/.test(rel)) process.exit(0);
28
+
29
+ // Proposed content: Write has `content`; Edit has `new_string`; else read disk.
30
+ let content = input.content ?? input.new_string ?? '';
31
+ if (!content && existsSync(filePath)) { try { content = readFileSync(filePath, 'utf8'); } catch { /* ignore */ } }
32
+ if (!content) process.exit(0);
33
+
34
+ // Find the app root (walks up for a package.json with a `#*` imports map or a db/ dir).
35
+ function findAppRoot(start) {
36
+ let dir = dirname(resolve(start));
37
+ for (let i = 0; i < 8; i++) {
38
+ if (existsSync(join(dir, 'package.json')) && (existsSync(join(dir, 'app')) || existsSync(join(dir, 'db')))) return dir;
39
+ const up = dirname(dir);
40
+ if (up === dir) break;
41
+ dir = up;
42
+ }
43
+ return dirname(resolve(start));
44
+ }
45
+ const appRoot = findAppRoot(filePath);
46
+
47
+ // Collect import specifiers, skipping `import type` (erased by the stripper).
48
+ const specs = [];
49
+ const re = /(?:^|\n)\s*import\s+(type\s+)?[^;'"]*?from\s*['"]([^'"]+)['"]/g;
50
+ let m;
51
+ while ((m = re.exec(content))) { if (!m[1]) specs.push(m[2]); }
52
+
53
+ function resolveSpec(spec) {
54
+ if (spec.startsWith('#')) return join(appRoot, spec.slice(1).replace(/^\//, ''));
55
+ if (spec.startsWith('.')) return resolve(dirname(filePath), spec);
56
+ return null; // bare npm specifier
57
+ }
58
+
59
+ const offenders = [];
60
+ for (const spec of specs) {
61
+ if (!/\.server\.(ts|js|mts|mjs)$/.test(spec)) continue;
62
+ const abs = resolveSpec(spec);
63
+ if (!abs || !existsSync(abs)) continue;
64
+ let src = '';
65
+ try { src = readFileSync(abs, 'utf8'); } catch { continue; }
66
+ const head = src.split('\n').slice(0, 5).join('\n');
67
+ const hasUseServer = /^\s*(['"])use server\1\s*;?\s*$/m.test(head);
68
+ if (!hasUseServer) offenders.push(spec);
69
+ }
70
+
71
+ if (offenders.length === 0) process.exit(0);
72
+
73
+ const msg =
74
+ `A browser-facing module (${rel}) imports a server-only utility: ${offenders.join(', ')}. ` +
75
+ `A .server.{ts,js} file with NO 'use server' directive throws at load in the browser, ` +
76
+ `so this would crash the page (webjs check flags it as no-server-import-in-browser-module). ` +
77
+ `Fix: add 'use server' to make it an RPC action, or reach it from a 'use server' action / route.ts / ` +
78
+ `middleware.ts, or share only a type via 'import type'. See agent-docs/types-and-mutations.md.`;
79
+
80
+ if (process.env.WEBJS_SERVER_IMPORT_GATE === 'block') {
81
+ process.stderr.write(`BLOCKED: ${msg}\n`);
82
+ process.exit(2);
83
+ }
84
+ // WARN: surface as additionalContext, allow the edit.
85
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: msg } }) + '\n');
86
+ process.exit(0);
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # PreToolUse hook (scaffolded by `webjs create`): WARN, at write time, when an
4
+ # Edit/Write to a browser-facing app module (a page / layout / component under
5
+ # app/ or components/ or modules/, NOT a `.server.*` file) adds an import of a
6
+ # server-only `.server.{ts,js}` utility (a `.server.*` file with NO `'use server'`
7
+ # directive). In the browser that import resolves to a throw-at-load stub, so the
8
+ # module crashes the moment it loads. This is the #804 first-pass iteration loop:
9
+ # `webjs check`'s `no-server-import-in-browser-module` catches it AFTER the file
10
+ # lands; this hook surfaces it BEFORE, so the agent never writes the wrong shape.
11
+ #
12
+ # WARN, not block (the convention-vs-check principle in this app's AGENTS.md):
13
+ # `webjs check` is the authoritative gate, and a pre-edit static peek cannot see
14
+ # the full elision verdict, so a hard block could false-positive on a display-only
15
+ # page the framework would elide. So this emits a loud reminder and allows the
16
+ # edit. Set WEBJS_SERVER_IMPORT_GATE=block to hard-block instead; set
17
+ # WEBJS_NO_SERVER_IMPORT_GATE=1 to skip.
18
+ #
19
+ # A `'use server'` action import is fine (it becomes a working RPC stub), and a
20
+ # `import type { ... } from './x.server.ts'` is fine (the stripper erases it), so
21
+ # both are ignored.
22
+
23
+ [ "$WEBJS_NO_SERVER_IMPORT_GATE" = "1" ] && exit 0
24
+
25
+ payload="$(cat)"
26
+ node "$(dirname "$0")/check-server-imports.mjs" "$payload"
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Claude Code PostToolUse hook (matcher: Bash).
4
+ #
5
+ # After a `gh pr merge`, sweep the repo's git worktrees and REMOVE the ones
6
+ # whose work has already landed, so a merged branch's worktree does not leak.
7
+ # Accumulated stale worktrees (a session that merged but never cleaned up, or
8
+ # crashed mid-task) are exactly what this closes: the webjs-start-work skill
9
+ # already says "after the PR merges, git worktree remove", but as guidance it
10
+ # gets skipped, so this makes the cleanup deterministic.
11
+ #
12
+ # CONSERVATIVE BY DESIGN. A worktree is removed ONLY when ALL hold:
13
+ # * it is a LINKED worktree, not the primary checkout;
14
+ # * it is NOT the current directory (you cannot remove the one you are in);
15
+ # * its branch is not main/master;
16
+ # * its branch is MERGED (an ancestor of the base ref, OR a merged GitHub PR
17
+ # for that head branch, which is how squash-merges are detected);
18
+ # * its working tree is CLEAN apart from untracked node_modules / .webjs.
19
+ # Anything with uncommitted or unpushed-looking work is KEPT and reported, so
20
+ # the hook can never destroy in-flight work.
21
+ #
22
+ # It never blocks the tool (always exits 0) and reports what it did back to the
23
+ # model via hookSpecificOutput.additionalContext. Disable with
24
+ # WEBJS_NO_WORKTREE_CLEANUP=1.
25
+ #
26
+ # Rule: AGENTS.md "One task per git worktree" + the webjs-start-work skill.
27
+
28
+ set -uo pipefail
29
+
30
+ # Read the whole payload first so we always honour the hook contract.
31
+ payload=$(cat 2>/dev/null || true)
32
+
33
+ if [ "${WEBJS_NO_WORKTREE_CLEANUP:-}" = "1" ]; then exit 0; fi
34
+
35
+ cmd=$(printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
36
+ if [ -z "$cmd" ]; then exit 0; fi
37
+
38
+ # Only act after a `gh pr merge` (whole word, not `gh pr merge-queue` typos etc.).
39
+ if ! printf '%s' "$cmd" | grep -Eq '(^|[^[:alnum:]-])gh pr merge([^[:alnum:]-]|$)'; then
40
+ exit 0
41
+ fi
42
+
43
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 0; fi
44
+
45
+ # The base ref merged branches land on. Prefer origin/main; fall back to a
46
+ # local main/master (the test harness has no remote).
47
+ base=""
48
+ for ref in origin/main origin/master main master; do
49
+ if git rev-parse --verify --quiet "$ref" >/dev/null 2>&1; then base="$ref"; break; fi
50
+ done
51
+ [ -z "$base" ] && exit 0
52
+
53
+ here=$(git rev-parse --show-toplevel 2>/dev/null || printf '%s' "$PWD")
54
+ # The primary worktree is the first entry of `git worktree list`.
55
+ primary=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')
56
+
57
+ is_merged() {
58
+ local br="$1"
59
+ # Ancestor of the base ref (fast-forward / rebase merges, and the real
60
+ # merges the test harness makes).
61
+ if git merge-base --is-ancestor "refs/heads/$br" "$base" 2>/dev/null; then return 0; fi
62
+ # A merged GitHub PR for this head branch (squash merges, which are NOT an
63
+ # ancestor of base). Network; skipped when gh is absent or unauthenticated.
64
+ if command -v gh >/dev/null 2>&1; then
65
+ local n
66
+ n=$(gh pr list --state merged --head "$br" --json number --jq '.[0].number' 2>/dev/null || true)
67
+ [ -n "$n" ] && return 0
68
+ fi
69
+ return 1
70
+ }
71
+
72
+ # Clean = nothing in `git status` except untracked node_modules / .webjs caches.
73
+ is_clean() {
74
+ local wt="$1" dirty
75
+ dirty=$(git -C "$wt" status --porcelain 2>/dev/null \
76
+ | grep -vE '(^|/)(node_modules|\.webjs)(/|$)' || true)
77
+ [ -z "$dirty" ]
78
+ }
79
+
80
+ removed=()
81
+ kept=()
82
+
83
+ # Parse worktree path + branch pairs.
84
+ wt=""
85
+ while IFS= read -r line; do
86
+ case "$line" in
87
+ worktree\ *) wt="${line#worktree }" ;;
88
+ branch\ *)
89
+ br="${line#branch refs/heads/}"
90
+ # Skip the primary checkout and main/master lines.
91
+ if [ "$wt" = "$primary" ] || [ "$br" = "main" ] || [ "$br" = "master" ]; then wt=""; continue; fi
92
+ # Never remove the worktree we are currently in.
93
+ if [ "$wt" = "$here" ]; then
94
+ kept+=("$wt (current directory; cd out then \`git worktree remove\`)")
95
+ wt=""; continue
96
+ fi
97
+ if ! is_clean "$wt"; then
98
+ kept+=("$wt (uncommitted changes)"); wt=""; continue
99
+ fi
100
+ if ! is_merged "$br"; then
101
+ kept+=("$wt (branch $br not merged yet)"); wt=""; continue
102
+ fi
103
+ if git worktree remove --force "$wt" >/dev/null 2>&1; then
104
+ removed+=("$wt ($br)")
105
+ else
106
+ kept+=("$wt (git worktree remove failed)")
107
+ fi
108
+ wt="" ;;
109
+ "") wt="" ;;
110
+ esac
111
+ done < <(git worktree list --porcelain 2>/dev/null)
112
+
113
+ git worktree prune >/dev/null 2>&1 || true
114
+
115
+ # Report nothing if there was nothing to do.
116
+ if [ "${#removed[@]}" -eq 0 ] && [ "${#kept[@]}" -eq 0 ]; then exit 0; fi
117
+
118
+ msg="Worktree cleanup after \`gh pr merge\`:"
119
+ for r in "${removed[@]:-}"; do [ -n "$r" ] && msg="$msg"$'\n'" removed $r (merged, clean)"; done
120
+ for k in "${kept[@]:-}"; do [ -n "$k" ] && msg="$msg"$'\n'" kept $k"; done
121
+
122
+ jq -n --arg ctx "$msg" '{
123
+ hookSpecificOutput: {
124
+ hookEventName: "PostToolUse",
125
+ additionalContext: $ctx
126
+ }
127
+ }' 2>/dev/null || true
128
+
129
+ exit 0
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Claude Code Stop hook.
4
+ #
5
+ # The commit-per-logical-unit rule (CLAUDE.md + AGENTS.md "Git workflow") is
6
+ # easy for an agent to defer to "the end", and then the end arrives with the
7
+ # whole feature done and ZERO commits, which is the worst outcome: git history,
8
+ # the user's revert and cherry-pick safety net, is empty. The PostToolUse
9
+ # `nudge-uncommitted.sh` reminds DURING work but is only a soft context nudge an
10
+ # agent can ignore. This Stop hook is the backstop at the END of a turn: if you
11
+ # try to finish with a pile of uncommitted work on a feature branch, it blocks
12
+ # the stop once and tells you to commit the completed unit first.
13
+ #
14
+ # Loop-safe: when `stop_hook_active` is already true (this hook fired and the
15
+ # agent is continuing because of it), it does NOT block again, so it nags at
16
+ # most once per stop and can never trap the agent in a loop.
17
+ #
18
+ # Skipped on main/master (you must not commit there anyway) and outside a git
19
+ # work tree. Threshold via WEBJS_COMMIT_STOP_THRESHOLD (default 2). Disable
20
+ # entirely with WEBJS_NO_COMMIT_STOP=1.
21
+
22
+ set -uo pipefail
23
+
24
+ payload=$(cat 2>/dev/null || true)
25
+
26
+ if [ "${WEBJS_NO_COMMIT_STOP:-}" = "1" ]; then exit 0; fi
27
+
28
+ # Loop guard: if we already blocked once this stop-cycle, let the agent stop.
29
+ active=$(printf '%s' "$payload" | jq -r '.stop_hook_active // false' 2>/dev/null || echo false)
30
+ if [ "$active" = "true" ]; then exit 0; fi
31
+
32
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 0; fi
33
+
34
+ branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")
35
+ if [ -z "$branch" ] || [ "$branch" = "main" ] || [ "$branch" = "master" ]; then exit 0; fi
36
+
37
+ threshold="${WEBJS_COMMIT_STOP_THRESHOLD:-2}"
38
+
39
+ # Count real changes: tracked modifications + staged + untracked, minus the
40
+ # noise the agent should never commit (node_modules, the sqlite db, caches).
41
+ changed=$(git status --porcelain 2>/dev/null \
42
+ | grep -vE '(^|/)(node_modules|\.webjs)(/|$)|dev\.db($|-journal)' \
43
+ | grep -c . || true)
44
+
45
+ if [ -z "$changed" ] || [ "$changed" -lt "$threshold" ]; then exit 0; fi
46
+
47
+ reason="You are ending the turn with ${changed} uncommitted changes on '${branch}'. This project OVERRIDES Claude Code's never-commit default: commit per logical unit (see CLAUDE.md and AGENTS.md \"Git workflow\"). Before you stop, group the completed work into a meaningful commit ('git add' the related files, 'git commit' with an imperative subject under 72 chars) and push. If the work is genuinely mid-change and not yet a coherent unit, commit what IS complete, or explain in your final message why it cannot be committed yet. To relax this backstop set WEBJS_COMMIT_STOP_THRESHOLD, or disable it with WEBJS_NO_COMMIT_STOP=1."
48
+
49
+ jq -n --arg r "$reason" '{decision: "block", reason: $r}' 2>/dev/null \
50
+ || printf '{"decision":"block","reason":%s}\n' "$(printf '%s' "$reason" | jq -Rs . 2>/dev/null || echo '""')"
51
+
52
+ exit 0
@@ -10,6 +10,15 @@
10
10
  }
11
11
  ]
12
12
  },
13
+ {
14
+ "matcher": "Write|Edit",
15
+ "hooks": [
16
+ {
17
+ "type": "command",
18
+ "command": ".claude/hooks/check-server-imports.sh"
19
+ }
20
+ ]
21
+ },
13
22
  {
14
23
  "matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash",
15
24
  "hooks": [
@@ -47,6 +56,25 @@
47
56
  "command": ".claude/hooks/nudge-uncommitted.sh"
48
57
  }
49
58
  ]
59
+ },
60
+ {
61
+ "matcher": "Bash",
62
+ "hooks": [
63
+ {
64
+ "type": "command",
65
+ "command": ".claude/hooks/cleanup-merged-worktree.sh"
66
+ }
67
+ ]
68
+ }
69
+ ],
70
+ "Stop": [
71
+ {
72
+ "hooks": [
73
+ {
74
+ "type": "command",
75
+ "command": ".claude/hooks/commit-before-stop.sh"
76
+ }
77
+ ]
50
78
  }
51
79
  ]
52
80
  }