@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.
- package/bin/webjs.js +1 -1
- package/lib/api-gallery.js +229 -0
- package/lib/create.js +462 -161
- package/lib/saas-template.js +39 -15
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +78 -1
- package/templates/.claude/hooks/check-server-imports.mjs +86 -0
- package/templates/.claude/hooks/check-server-imports.sh +26 -0
- package/templates/.claude/hooks/cleanup-merged-worktree.sh +129 -0
- package/templates/.claude/hooks/commit-before-stop.sh +52 -0
- package/templates/.claude/settings.json +28 -0
- package/templates/.cursorrules +50 -1
- package/templates/.github/copilot-instructions.md +50 -1
- package/templates/AGENTS.md +180 -13
- package/templates/CLAUDE.md +22 -0
- package/templates/CONVENTIONS.md +165 -12
- package/templates/gallery/app/examples/todo/page.ts +34 -0
- package/templates/gallery/app/features/async-render/page.ts +14 -0
- package/templates/gallery/app/features/broadcast/feed/route.ts +19 -0
- package/templates/gallery/app/features/broadcast/page.ts +24 -0
- package/templates/gallery/app/features/caching/page.ts +39 -0
- package/templates/gallery/app/features/client-router/page.ts +34 -0
- package/templates/gallery/app/features/client-router/second/page.ts +20 -0
- package/templates/gallery/app/features/components/page.ts +14 -0
- package/templates/gallery/app/features/directives/page.ts +14 -0
- package/templates/gallery/app/features/env/page.ts +36 -0
- package/templates/gallery/app/features/file-storage/file/[key]/route.ts +19 -0
- package/templates/gallery/app/features/file-storage/page.ts +62 -0
- package/templates/gallery/app/features/forms/page.ts +78 -0
- package/templates/gallery/app/features/metadata/page.ts +55 -0
- package/templates/gallery/app/features/optimistic-ui/page.ts +14 -0
- package/templates/gallery/app/features/rate-limit/page.ts +29 -0
- package/templates/gallery/app/features/rate-limit/ping/middleware.ts +8 -0
- package/templates/gallery/app/features/rate-limit/ping/route.ts +7 -0
- package/templates/gallery/app/features/route-handler/data/route.ts +8 -0
- package/templates/gallery/app/features/route-handler/page.ts +13 -0
- package/templates/gallery/app/features/routing/[id]/page.ts +25 -0
- package/templates/gallery/app/features/routing/page.ts +47 -0
- package/templates/gallery/app/features/server-actions/page.ts +14 -0
- package/templates/gallery/app/features/service-worker/page.ts +36 -0
- package/templates/gallery/app/features/websockets/echo/route.ts +19 -0
- package/templates/gallery/app/features/websockets/page.ts +25 -0
- package/templates/gallery/modules/async-render/components/server-clock.ts +26 -0
- package/templates/gallery/modules/async-render/queries/server-greeting.server.ts +9 -0
- package/templates/gallery/modules/broadcast/components/broadcast-feed.ts +61 -0
- package/templates/gallery/modules/components/components/browser/counter-card.test.js +36 -0
- package/templates/gallery/modules/components/components/counter-card.ts +35 -0
- package/templates/gallery/modules/directives/components/directive-demo.ts +53 -0
- package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +18 -0
- package/templates/gallery/modules/optimistic-ui/actions/like-post.server.ts +9 -0
- package/templates/gallery/modules/optimistic-ui/components/like-button.ts +35 -0
- package/templates/gallery/modules/rate-limit/components/rate-probe.ts +49 -0
- package/templates/gallery/modules/server-actions/actions/greet.server.ts +12 -0
- package/templates/gallery/modules/server-actions/components/greeter.ts +30 -0
- package/templates/gallery/modules/server-actions/utils/format.server.ts +8 -0
- package/templates/gallery/modules/todo/actions/create-todo.server.ts +16 -0
- package/templates/gallery/modules/todo/actions/delete-todo.server.ts +15 -0
- package/templates/gallery/modules/todo/actions/toggle-todo.server.ts +21 -0
- package/templates/gallery/modules/todo/components/todo-app.ts +140 -0
- package/templates/gallery/modules/todo/queries/list-todos.server.ts +25 -0
- package/templates/gallery/modules/todo/types.ts +12 -0
- package/templates/gallery/modules/websockets/components/ws-echo.ts +62 -0
- package/templates/lib/utils/ui.ts +4 -4
- package/templates/test/hello/browser/hello.test.js +6 -0
- package/templates/web-test-runner.config.js +9 -1
package/lib/saas-template.js
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
140
|
-
" return
|
|
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
|
|
256
|
-
" //
|
|
257
|
-
" assert.ok([
|
|
258
|
-
" if (signupRes.status
|
|
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
|
-
"
|
|
361
|
-
" //
|
|
362
|
-
"
|
|
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, ${
|
|
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>${
|
|
495
|
+
" <dd>${user?.email}</dd>",
|
|
472
496
|
" <dt class=\"text-muted-foreground\">Name</dt>",
|
|
473
|
-
" <dd>${
|
|
497
|
+
" <dd>${user?.name || 'Not set'}</dd>",
|
|
474
498
|
" </dl>",
|
|
475
499
|
" </div>",
|
|
476
500
|
" </div>",
|
package/package.json
CHANGED
|
@@ -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.
|
|
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
|
}
|