@webjsdev/cli 0.10.5 → 0.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/webjs.js CHANGED
@@ -42,6 +42,9 @@ const USAGE = `webjs commands:
42
42
  webjs start [--port 8080] Start production server (serves source directly, no build step)
43
43
  webjs test [--server|--browser] Run server + browser tests
44
44
  webjs check Run correctness checks on the app
45
+ webjs doctor Verify project health (Node, tsconfig, env, vendor pins, @webjsdev versions, git hook)
46
+ webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
47
+ webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors)
45
48
  webjs create <name> [--template full-stack|api|saas] [--no-install] Scaffold a new webjs app
46
49
  (only 3 templates exist. default: full-stack with Prisma+SQLite)
47
50
  Auto-runs the detected package manager's install in the new dir
@@ -277,6 +280,85 @@ async function main() {
277
280
  }
278
281
  break;
279
282
  }
283
+ case 'doctor': {
284
+ // Project-health checklist (#266). The checks are PURE (in lib/doctor.js);
285
+ // this branch only renders them and owns the exit code: non-zero iff any
286
+ // HARD check FAILS, so CI can gate on it. Warns are informational and do
287
+ // NOT fail the exit (env drift / pin staleness / version drift are the
288
+ // app's concern, not a broken toolchain).
289
+ const { runDoctorChecks } = await import('../lib/doctor.js');
290
+ const results = await runDoctorChecks(process.cwd());
291
+ const marker = { pass: '[pass]', warn: '[warn]', fail: '[fail]' };
292
+ console.log('webjs doctor: project-health checklist\n');
293
+ for (const r of results) {
294
+ console.log(` ${marker[r.status]} ${r.name}`);
295
+ console.log(` ${r.message}`);
296
+ if (r.fix && r.status !== 'pass') console.log(` Fix: ${r.fix}`);
297
+ console.log();
298
+ }
299
+ const counts = results.reduce((acc, r) => {
300
+ acc[r.status] = (acc[r.status] || 0) + 1;
301
+ return acc;
302
+ }, /** @type {Record<string, number>} */ ({}));
303
+ const pass = counts.pass || 0;
304
+ const warn = counts.warn || 0;
305
+ const fail = counts.fail || 0;
306
+ console.log(` ${pass} passed, ${warn} warning(s), ${fail} failed.`);
307
+ if (fail > 0) {
308
+ console.error(
309
+ `\nwebjs doctor: ${fail} hard check(s) failed. Fix the toolchain issue(s) above.`,
310
+ );
311
+ process.exit(1);
312
+ }
313
+ break;
314
+ }
315
+ case 'types': {
316
+ // Generate `.webjs/routes.d.ts` from the app's `app/` routes (#258),
317
+ // narrowing the @webjsdev/core `Route` href union + per-route `params`.
318
+ // Opt-in codegen: the static types in @webjsdev/core work without it
319
+ // (un-generated apps see `Route = string`).
320
+ const { generateRouteTypes } = await import('@webjsdev/server');
321
+ const { mkdir, writeFile } = await import('node:fs/promises');
322
+ const appDir = process.cwd();
323
+ const text = await generateRouteTypes(appDir);
324
+ const outDir = join(appDir, '.webjs');
325
+ await mkdir(outDir, { recursive: true });
326
+ const outFile = join(outDir, 'routes.d.ts');
327
+ await writeFile(outFile, text);
328
+ // Count the typed routes (each `WebjsRoutes` key is one route literal).
329
+ const count = (text.match(/^\s+".*": true;$/gm) || []).length;
330
+ console.log(
331
+ `webjs types: wrote .webjs/routes.d.ts (${count} route${count === 1 ? '' : 's'} typed). ` +
332
+ `Ensure tsconfig "include" lists ".webjs/routes.d.ts" so tsserver picks it up.`,
333
+ );
334
+ break;
335
+ }
336
+ case 'typecheck': {
337
+ // Type-check the app with the project's OWN tsc (it reads the app's
338
+ // tsconfig: strict + noEmit + erasableSyntaxOnly). The framework runs the
339
+ // standard compiler, it does not embed one. Extra args after `typecheck`
340
+ // pass through (e.g. `webjs typecheck --watch`). Exits non-zero on a type
341
+ // error, so it works as a CI gate and the scaffolded `typecheck` script.
342
+ const cwd = process.cwd();
343
+ const { createRequire } = await import('node:module');
344
+ let tscPath;
345
+ try {
346
+ const req = createRequire(join(cwd, 'package.json'));
347
+ tscPath = req.resolve('typescript/bin/tsc');
348
+ } catch {
349
+ console.error(
350
+ 'webjs typecheck: TypeScript is not installed in this project.\n' +
351
+ 'Install it with `npm install -D typescript`, then re-run `webjs typecheck`.',
352
+ );
353
+ process.exit(1);
354
+ }
355
+ const child = spawn(process.execPath, [tscPath, '--noEmit', ...rest], {
356
+ stdio: 'inherit',
357
+ cwd,
358
+ });
359
+ child.on('exit', (code) => process.exit(code ?? 1));
360
+ break;
361
+ }
280
362
  case 'create': {
281
363
  const name = rest[0];
282
364
  if (!name || name.startsWith('-')) {
package/lib/create.js CHANGED
@@ -286,6 +286,12 @@ export async function scaffoldApp(name, cwd, opts = {}) {
286
286
  'test:server': 'webjs test --server',
287
287
  'test:browser': 'webjs test --browser',
288
288
  check: 'webjs check',
289
+ typecheck: 'webjs typecheck',
290
+ // Onboarding/setup-verify: a contributor runs `npm run doctor` after
291
+ // cloning to assert the toolchain (Node floor, tsconfig flag, env drift,
292
+ // vendor pins, @webjsdev versions, git hook). Local tool, NOT a CI gate
293
+ // (its env-drift + network pin-freshness checks would make CI flaky).
294
+ doctor: 'webjs doctor',
289
295
  'db:migrate': 'prisma migrate dev',
290
296
  'db:generate': 'prisma generate',
291
297
  'db:studio': 'prisma studio',
@@ -298,6 +304,10 @@ export async function scaffoldApp(name, cwd, opts = {}) {
298
304
  },
299
305
  devDependencies: {
300
306
  prisma: '^6.0.0',
307
+ // The TypeScript compiler, for `npm run typecheck` (webjs typecheck runs
308
+ // tsc --noEmit). Not needed at runtime (Node strips types in place), only
309
+ // to type-check the app.
310
+ typescript: '^5.6.0',
301
311
  '@web/test-runner': '^0.20.0',
302
312
  '@web/test-runner-playwright': '^0.11.0',
303
313
  'playwright': '^1.59.0',
@@ -346,6 +356,22 @@ export async function scaffoldApp(name, cwd, opts = {}) {
346
356
  { name: '@webjsdev/ts-plugin' },
347
357
  ],
348
358
  },
359
+ // `.webjs/routes.d.ts` is the OPT-IN generated route-types overlay (#258):
360
+ // run `webjs types` (or `webjs dev`, which emits it) to narrow the
361
+ // @webjsdev/core `Route` href union + per-route `params`. Listed in
362
+ // `include` so tsserver picks it up; it is gitignored (regenerated per
363
+ // machine), so a fresh clone runs `webjs dev` / `webjs types` to recreate
364
+ // it, and the static @webjsdev/core types work even when it is absent.
365
+ include: [
366
+ 'app/**/*',
367
+ 'components/**/*',
368
+ 'modules/**/*',
369
+ 'lib/**/*',
370
+ 'middleware.js',
371
+ 'middleware.ts',
372
+ '.webjs/routes.d.ts',
373
+ ],
374
+ exclude: ['node_modules', '.webjs/vendor', 'prisma/migrations'],
349
375
  }, null, 2) + '\n');
350
376
 
351
377
  // --- Templates (AGENTS.md, CONVENTIONS.md, CLAUDE.md, test files, Claude hooks) ---
@@ -397,6 +423,10 @@ export async function scaffoldApp(name, cwd, opts = {}) {
397
423
  // to main, mirroring the webjs framework's own CI.
398
424
  '.github/workflows/ci.yml',
399
425
  '.editorconfig',
426
+ // VS Code: associate the published webjs-config JSON Schema with the
427
+ // package.json `webjs` block, so an unknown / typo'd key (#259) is
428
+ // flagged natively in the editor instead of silently dropped.
429
+ '.vscode/settings.json',
400
430
  // Production / deploy scaffolding. `docker compose up --build` runs
401
431
  // the app locally with the same Dockerfile production builds from.
402
432
  'Dockerfile',
package/lib/doctor.js ADDED
@@ -0,0 +1,534 @@
1
+ /**
2
+ * `webjs doctor`: a project-health checklist runner (issue #266).
3
+ *
4
+ * webjs has unusually many fragile preconditions, each an independent failure
5
+ * mode a contributor onboarding to an existing repo only hits at runtime: the
6
+ * Node 24+ strip-types floor, the `erasableSyntaxOnly` TS flag, importmap pin
7
+ * freshness, env drift vs `.env.example`, `@webjsdev/*` version coherence, and
8
+ * the git pre-commit hook activation. `webjs doctor` verifies each one up front
9
+ * and prints pass/warn/fail with an actionable fix line.
10
+ *
11
+ * This module is PURE: `runDoctorChecks(appDir, opts?)` reads files (and, for
12
+ * the pin check, optionally the network), but NEVER calls `process.exit` and
13
+ * NEVER prints. The CLI (`bin/webjs.js`, `case 'doctor'`) renders the results
14
+ * and owns the exit code, which is what makes every check unit-testable in
15
+ * isolation against a tmp fixture appDir.
16
+ *
17
+ * HARD-FAIL vs WARN split (the CLI exits non-zero on any 'fail'):
18
+ *
19
+ * - 'fail' is reserved for a genuinely-broken TOOLCHAIN that would crash or
20
+ * 500 at runtime, so CI can gate on it. Two checks can fail:
21
+ * * Node version below the required major (the strip-types floor).
22
+ * * `erasableSyntaxOnly` missing/false in an EXISTING tsconfig (non-erasable
23
+ * TS would fail at strip time with a 500).
24
+ * - 'warn' is for drift / preferences / best-effort signals that are the
25
+ * app's own runtime concern, never a doctor hard-fail: a missing tsconfig
26
+ * (a JS-only app legitimately has none), env drift, an outdated or
27
+ * unverifiable vendor pin, a `@webjsdev/*` version drift or missing install,
28
+ * and a missing/non-executable git hook.
29
+ * - 'pass' is the green path.
30
+ *
31
+ * Every NETWORK touch (only the vendor-pin freshness check) is BEST-EFFORT: a
32
+ * fetch failure is a WARN ("could not check, network"), never a hard fail and
33
+ * never a throw that crashes the command. Network is flaky, and a doctor that
34
+ * fails CI because npm was briefly unreachable is worse than useless.
35
+ */
36
+
37
+ import { existsSync, statSync } from 'node:fs';
38
+ import { readFile } from 'node:fs/promises';
39
+ import { join } from 'node:path';
40
+ import { checkNodeInline } from './node-preflight.js';
41
+
42
+ /**
43
+ * @typedef {'pass' | 'warn' | 'fail'} DoctorStatus
44
+ * @typedef {{ name: string, status: DoctorStatus, message: string, fix?: string }} DoctorResult
45
+ */
46
+
47
+ /**
48
+ * Read the CLI package's own `engines.node` so the required Node major lives in
49
+ * one place (mirrors how `bin/webjs.js` sources it). Falls back to `>=24.0.0`.
50
+ * @param {string} cliDir directory of THIS file's package (lib/ -> package root)
51
+ * @returns {Promise<string>}
52
+ */
53
+ async function readEngines(cliDir) {
54
+ try {
55
+ const pkg = JSON.parse(await readFile(join(cliDir, '..', 'package.json'), 'utf8'));
56
+ return pkg?.engines?.node || '>=24.0.0';
57
+ } catch {
58
+ return '>=24.0.0';
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Strip `//` line comments, block comments, and trailing commas from a JSONC
64
+ * string so a tsconfig (which permits all three) parses with `JSON.parse`.
65
+ * Deliberately simple: it does not honor comment-looking sequences inside
66
+ * string values, which is acceptable for a tsconfig (paths rarely contain `//`
67
+ * or block-comment markers, and the worst case is a parse failure the caller
68
+ * already degrades to a WARN).
69
+ * @param {string} text
70
+ * @returns {string}
71
+ */
72
+ function stripJsonc(text) {
73
+ let out = '';
74
+ let inString = false;
75
+ let stringQuote = '';
76
+ for (let i = 0; i < text.length; i++) {
77
+ const ch = text[i];
78
+ const next = text[i + 1];
79
+ if (inString) {
80
+ out += ch;
81
+ if (ch === '\\') {
82
+ // Copy the escaped char verbatim so an escaped quote does not end the string.
83
+ out += text[i + 1] || '';
84
+ i++;
85
+ } else if (ch === stringQuote) {
86
+ inString = false;
87
+ }
88
+ continue;
89
+ }
90
+ if (ch === '"' || ch === "'") {
91
+ inString = true;
92
+ stringQuote = ch;
93
+ out += ch;
94
+ continue;
95
+ }
96
+ if (ch === '/' && next === '/') {
97
+ while (i < text.length && text[i] !== '\n') i++;
98
+ out += '\n';
99
+ continue;
100
+ }
101
+ if (ch === '/' && next === '*') {
102
+ i += 2;
103
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++;
104
+ i++; // land on the '/'
105
+ continue;
106
+ }
107
+ out += ch;
108
+ }
109
+ // Drop trailing commas before } or ].
110
+ return out.replace(/,(\s*[}\]])/g, '$1');
111
+ }
112
+
113
+ /**
114
+ * Parse a `.env`-style file into the SET of KEY names it declares. A simple
115
+ * `KEY=value` line parse: comments (`#`) and blank lines are skipped, and only
116
+ * the key before the first `=` is taken (the value is irrelevant for drift).
117
+ * @param {string} text
118
+ * @returns {Set<string>}
119
+ */
120
+ function parseEnvKeys(text) {
121
+ const keys = new Set();
122
+ for (const raw of text.split(/\r?\n/)) {
123
+ const line = raw.trim();
124
+ if (!line || line.startsWith('#')) continue;
125
+ const eq = line.indexOf('=');
126
+ if (eq <= 0) continue;
127
+ let key = line.slice(0, eq).trim();
128
+ // Tolerate a leading `export ` (a common .env.example convention).
129
+ if (key.startsWith('export ')) key = key.slice('export '.length).trim();
130
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) keys.add(key);
131
+ }
132
+ return keys;
133
+ }
134
+
135
+ /**
136
+ * CHECK 1, Node version. HARD-FAIL when the running major is below the required
137
+ * major (the strip-types + recursive fs.watch floor). `opts.nodeVersion` lets a
138
+ * test inject the running version so the fail case is assertable without being
139
+ * on old Node.
140
+ * @param {string} cliDir
141
+ * @param {{ nodeVersion?: string }} opts
142
+ * @returns {Promise<DoctorResult>}
143
+ */
144
+ async function checkNode(cliDir, opts) {
145
+ const engines = await readEngines(cliDir);
146
+ const current = opts.nodeVersion || process.versions.node;
147
+ const r = checkNodeInline(current, engines);
148
+ if (r.ok) {
149
+ return {
150
+ name: 'node-version',
151
+ status: 'pass',
152
+ message: `Node ${r.current} satisfies the required Node ${r.requiredMajor}+.`,
153
+ };
154
+ }
155
+ return {
156
+ name: 'node-version',
157
+ status: 'fail',
158
+ message:
159
+ `Node ${r.current} is below the required Node ${r.requiredMajor}+. ` +
160
+ `webjs is buildless and relies on Node ${r.requiredMajor}'s built-in TypeScript ` +
161
+ `strip and recursive fs.watch.`,
162
+ fix: `Upgrade to Node ${r.requiredMajor}+ (see https://nodejs.org).`,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * CHECK 2, tsconfig erasableSyntaxOnly. PASS when `true`; WARN when no tsconfig
168
+ * (a JS-only app legitimately has none) or the file is unparseable; HARD-FAIL
169
+ * when the file EXISTS but the flag is missing/false (non-erasable TS 500s at
170
+ * strip time).
171
+ * @param {string} appDir
172
+ * @returns {Promise<DoctorResult>}
173
+ */
174
+ async function checkTsconfig(appDir) {
175
+ const path = join(appDir, 'tsconfig.json');
176
+ if (!existsSync(path)) {
177
+ return {
178
+ name: 'tsconfig-erasable',
179
+ status: 'warn',
180
+ message: 'No tsconfig.json found. A JS-only app needs none; a TypeScript app requires one.',
181
+ fix: 'If this app uses TypeScript, add a tsconfig.json with "erasableSyntaxOnly": true.',
182
+ };
183
+ }
184
+ let parsed;
185
+ try {
186
+ parsed = JSON.parse(stripJsonc(await readFile(path, 'utf8')));
187
+ } catch {
188
+ return {
189
+ name: 'tsconfig-erasable',
190
+ status: 'warn',
191
+ message: 'tsconfig.json could not be parsed (even after stripping comments + trailing commas).',
192
+ fix: 'Fix the tsconfig.json syntax, then ensure "compilerOptions.erasableSyntaxOnly": true.',
193
+ };
194
+ }
195
+ const flag = parsed?.compilerOptions?.erasableSyntaxOnly;
196
+ if (flag === true) {
197
+ return {
198
+ name: 'tsconfig-erasable',
199
+ status: 'pass',
200
+ message: 'tsconfig.json sets "erasableSyntaxOnly": true.',
201
+ };
202
+ }
203
+ return {
204
+ name: 'tsconfig-erasable',
205
+ status: 'fail',
206
+ message:
207
+ 'tsconfig.json is missing "compilerOptions.erasableSyntaxOnly": true. ' +
208
+ 'Non-erasable TypeScript (enum, namespace, parameter properties, ...) 500s at strip time.',
209
+ fix: 'Set "compilerOptions": { "erasableSyntaxOnly": true } in tsconfig.json.',
210
+ };
211
+ }
212
+
213
+ /**
214
+ * CHECK 3, .env presence + drift vs .env.example. WARN-level only (a missing
215
+ * env var is the app's runtime problem, not a toolchain crash). When no
216
+ * `.env.example`, PASS (nothing to compare). When `.env.example` exists but
217
+ * `.env` is absent, WARN to copy it. Otherwise WARN listing any example key
218
+ * missing from `.env`, else PASS.
219
+ * @param {string} appDir
220
+ * @returns {Promise<DoctorResult>}
221
+ */
222
+ async function checkEnv(appDir) {
223
+ const examplePath = join(appDir, '.env.example');
224
+ if (!existsSync(examplePath)) {
225
+ return {
226
+ name: 'env-drift',
227
+ status: 'pass',
228
+ message: 'No .env.example to compare against.',
229
+ };
230
+ }
231
+ const exampleKeys = parseEnvKeys(await readFile(examplePath, 'utf8'));
232
+ const envPath = join(appDir, '.env');
233
+ if (!existsSync(envPath)) {
234
+ return {
235
+ name: 'env-drift',
236
+ status: 'warn',
237
+ message: '.env.example exists but .env does not.',
238
+ fix: 'Copy it: cp .env.example .env (then fill in the values).',
239
+ };
240
+ }
241
+ const envKeys = parseEnvKeys(await readFile(envPath, 'utf8'));
242
+ const missing = [...exampleKeys].filter((k) => !envKeys.has(k));
243
+ if (missing.length === 0) {
244
+ return {
245
+ name: 'env-drift',
246
+ status: 'pass',
247
+ message: `.env has all ${exampleKeys.size} key(s) declared in .env.example.`,
248
+ };
249
+ }
250
+ return {
251
+ name: 'env-drift',
252
+ status: 'warn',
253
+ message: `.env is missing ${missing.length} key(s) from .env.example: ${missing.join(', ')}.`,
254
+ fix: 'Add the missing key(s) to .env (see .env.example for the expected names).',
255
+ };
256
+ }
257
+
258
+ /**
259
+ * CHECK 4, vendor pin freshness. Applies ONLY when a pin file exists. PASS/skip
260
+ * for an unpinned app (it resolves live, which is fine in dev). BEST-EFFORT +
261
+ * NETWORK-TOLERANT: any error (network, timeout) is a WARN "could not check",
262
+ * never a hard fail and never a throw. PASS when all pins current, WARN listing
263
+ * outdated packages otherwise.
264
+ *
265
+ * The vendor functions are injected via `opts.vendor` so a test can supply a
266
+ * stub without a real network call; absent the override, they are dynamically
267
+ * imported from `@webjsdev/server`.
268
+ * @param {string} appDir
269
+ * @param {{ vendor?: { hasVendorPin: (d: string) => boolean, findOutdated: (d: string) => Promise<Array<{ pkg: string, current: string, latest: string }>> } }} opts
270
+ * @returns {Promise<DoctorResult>}
271
+ */
272
+ async function checkVendorPin(appDir, opts) {
273
+ let vendor = opts.vendor;
274
+ if (!vendor) {
275
+ try {
276
+ const mod = await import('@webjsdev/server');
277
+ vendor = { hasVendorPin: mod.hasVendorPin, findOutdated: mod.findOutdated };
278
+ } catch {
279
+ return {
280
+ name: 'vendor-pin',
281
+ status: 'warn',
282
+ message: 'Could not load the vendor toolchain to check pin freshness.',
283
+ fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.',
284
+ };
285
+ }
286
+ }
287
+ let pinned = false;
288
+ try {
289
+ pinned = vendor.hasVendorPin(appDir);
290
+ } catch {
291
+ pinned = false;
292
+ }
293
+ if (!pinned) {
294
+ return {
295
+ name: 'vendor-pin',
296
+ status: 'pass',
297
+ message: 'No vendor pin file; the app resolves vendor imports live (fine in dev).',
298
+ };
299
+ }
300
+ let outdated;
301
+ try {
302
+ outdated = await vendor.findOutdated(appDir);
303
+ } catch {
304
+ // findOutdated is built to swallow fetch errors and return [], but guard
305
+ // anyway: a network check must NEVER throw out of doctor.
306
+ return {
307
+ name: 'vendor-pin',
308
+ status: 'warn',
309
+ message: 'Could not check pin freshness (network unreachable or registry error).',
310
+ fix: 'Re-run `webjs doctor` when connectivity is back, or run `webjs vendor outdated`.',
311
+ };
312
+ }
313
+ if (!Array.isArray(outdated) || outdated.length === 0) {
314
+ return {
315
+ name: 'vendor-pin',
316
+ status: 'pass',
317
+ message: 'All vendor pins are current.',
318
+ };
319
+ }
320
+ const list = outdated.map((o) => `${o.pkg} (${o.current} -> ${o.latest})`).join(', ');
321
+ return {
322
+ name: 'vendor-pin',
323
+ status: 'warn',
324
+ message: `${outdated.length} pinned package(s) are outdated: ${list}.`,
325
+ fix: 'Run `webjs vendor update` to re-pin to the latest versions.',
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Compare an installed version against a semver range PRAGMATICALLY (no semver
331
+ * dependency). Supports the common scaffold shapes: `latest` / `*` / `workspace:*`
332
+ * (any installed version satisfies), an exact `1.2.3`, and a caret `^1.2.3`
333
+ * (installed must be >= the floor AND share the same major, with major 0 also
334
+ * pinning the minor, matching npm caret semantics). An unrecognized range is
335
+ * treated as "cannot statically verify" (returns null), so the caller does not
336
+ * warn on a shape it does not understand.
337
+ * @param {string} installed
338
+ * @param {string} range
339
+ * @returns {boolean | null}
340
+ */
341
+ function satisfiesRange(installed, range) {
342
+ if (!installed) return null;
343
+ const r = String(range).trim();
344
+ if (r === 'latest' || r === '*' || r === '' || r.startsWith('workspace:')) return true;
345
+ const parse = (v) => {
346
+ const m = String(v).match(/(\d+)\.(\d+)\.(\d+)/);
347
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
348
+ };
349
+ const inst = parse(installed);
350
+ if (!inst) return null;
351
+ if (/^\d+\.\d+\.\d+$/.test(r)) {
352
+ const exact = parse(r);
353
+ return exact ? inst[0] === exact[0] && inst[1] === exact[1] && inst[2] === exact[2] : null;
354
+ }
355
+ if (r.startsWith('^')) {
356
+ const floor = parse(r);
357
+ if (!floor) return null;
358
+ if (inst[0] !== floor[0]) return false;
359
+ // For 0.x, caret pins the minor too (^0.7.0 allows 0.7.x, not 0.8.0).
360
+ if (floor[0] === 0 && inst[1] !== floor[1]) return false;
361
+ const cmp =
362
+ inst[0] !== floor[0] ? inst[0] - floor[0] :
363
+ inst[1] !== floor[1] ? inst[1] - floor[1] :
364
+ inst[2] - floor[2];
365
+ return cmp >= 0;
366
+ }
367
+ return null;
368
+ }
369
+
370
+ /**
371
+ * CHECK 5, @webjsdev/* version coherence. WARN-level only (a version drift is
372
+ * not a crash). Reads the app package.json `@webjsdev/*` ranges across
373
+ * dependencies + devDependencies, then for each reads the INSTALLED version from
374
+ * `node_modules/@webjsdev/<pkg>/package.json` and checks it satisfies the
375
+ * declared range. PASS when every @webjsdev dep is present + satisfied; WARN on
376
+ * a missing install or a range drift.
377
+ * @param {string} appDir
378
+ * @returns {Promise<DoctorResult>}
379
+ */
380
+ async function checkWebjsVersions(appDir) {
381
+ const pkgPath = join(appDir, 'package.json');
382
+ if (!existsSync(pkgPath)) {
383
+ return {
384
+ name: 'webjs-versions',
385
+ status: 'warn',
386
+ message: 'No package.json found in this directory.',
387
+ fix: 'Run `webjs doctor` from the app root (where package.json lives).',
388
+ };
389
+ }
390
+ let pkg;
391
+ try {
392
+ pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
393
+ } catch {
394
+ return {
395
+ name: 'webjs-versions',
396
+ status: 'warn',
397
+ message: 'package.json could not be parsed.',
398
+ fix: 'Fix the package.json syntax.',
399
+ };
400
+ }
401
+ const ranges = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
402
+ const webjsDeps = Object.keys(ranges).filter((n) => n.startsWith('@webjsdev/'));
403
+ if (webjsDeps.length === 0) {
404
+ return {
405
+ name: 'webjs-versions',
406
+ status: 'warn',
407
+ message: 'No @webjsdev/* dependencies declared in package.json.',
408
+ fix: 'A webjs app depends on @webjsdev/core + @webjsdev/server (+ @webjsdev/cli).',
409
+ };
410
+ }
411
+ const missing = [];
412
+ const drift = [];
413
+ for (const dep of webjsDeps) {
414
+ const installedPkg = join(appDir, 'node_modules', dep, 'package.json');
415
+ if (!existsSync(installedPkg)) {
416
+ missing.push(dep);
417
+ continue;
418
+ }
419
+ let installedVersion = '';
420
+ try {
421
+ installedVersion = JSON.parse(await readFile(installedPkg, 'utf8')).version || '';
422
+ } catch {
423
+ missing.push(dep);
424
+ continue;
425
+ }
426
+ const ok = satisfiesRange(installedVersion, ranges[dep]);
427
+ // null = a range shape we cannot statically verify; do not warn on it.
428
+ if (ok === false) drift.push(`${dep}@${installedVersion} does not satisfy "${ranges[dep]}"`);
429
+ }
430
+ if (missing.length > 0) {
431
+ return {
432
+ name: 'webjs-versions',
433
+ status: 'warn',
434
+ message: `${missing.length} @webjsdev/* dependency not installed: ${missing.join(', ')}.`,
435
+ fix: 'Run `npm install` to install the declared dependencies.',
436
+ };
437
+ }
438
+ if (drift.length > 0) {
439
+ return {
440
+ name: 'webjs-versions',
441
+ status: 'warn',
442
+ message: `@webjsdev version drift: ${drift.join('; ')}.`,
443
+ fix: 'Run `npm install` to reconcile node_modules with the declared ranges.',
444
+ };
445
+ }
446
+ return {
447
+ name: 'webjs-versions',
448
+ status: 'pass',
449
+ message: `All ${webjsDeps.length} @webjsdev/* dependency satisfy their declared ranges.`,
450
+ };
451
+ }
452
+
453
+ /**
454
+ * CHECK 6 (optional), git pre-commit hook installed + executable. WARN when the
455
+ * repo is a git checkout but `.git/hooks/pre-commit` is absent or
456
+ * non-executable, since the test-gate / changelog hook would not fire. PASS when
457
+ * present + executable, or skip (PASS) when this is not a git checkout at all
458
+ * (an exported tarball, a non-repo dir). Respects a configured `core.hooksPath`
459
+ * is OUT of scope here: the common scaffold installs into `.git/hooks`, so this
460
+ * checks the default location and a configured path is the user's own concern.
461
+ * @param {string} appDir
462
+ * @returns {DoctorResult}
463
+ */
464
+ function checkGitHook(appDir) {
465
+ const gitDir = join(appDir, '.git');
466
+ if (!existsSync(gitDir)) {
467
+ return {
468
+ name: 'git-hook',
469
+ status: 'pass',
470
+ message: 'Not a git checkout; no pre-commit hook expected.',
471
+ };
472
+ }
473
+ const hook = join(gitDir, 'hooks', 'pre-commit');
474
+ if (!existsSync(hook)) {
475
+ return {
476
+ name: 'git-hook',
477
+ status: 'warn',
478
+ message: 'No .git/hooks/pre-commit hook installed.',
479
+ fix: 'Install the project hooks (e.g. `npm install` runs the prepare step that wires them).',
480
+ };
481
+ }
482
+ let executable = false;
483
+ try {
484
+ // Owner-execute bit. On a checkout without exec bits (some Windows / CI
485
+ // setups) the hook will not run, so flag it.
486
+ executable = (statSync(hook).mode & 0o100) !== 0;
487
+ } catch {
488
+ executable = false;
489
+ }
490
+ if (!executable) {
491
+ return {
492
+ name: 'git-hook',
493
+ status: 'warn',
494
+ message: '.git/hooks/pre-commit exists but is not executable.',
495
+ fix: 'chmod +x .git/hooks/pre-commit',
496
+ };
497
+ }
498
+ return {
499
+ name: 'git-hook',
500
+ status: 'pass',
501
+ message: '.git/hooks/pre-commit is installed and executable.',
502
+ };
503
+ }
504
+
505
+ /**
506
+ * Run every doctor check against `appDir` and return the results. PURE: no
507
+ * printing, no `process.exit`; the CLI renders + decides the exit code.
508
+ *
509
+ * @param {string} appDir the app directory to check (usually `process.cwd()`)
510
+ * @param {{
511
+ * nodeVersion?: string,
512
+ * cliDir?: string,
513
+ * vendor?: { hasVendorPin: (d: string) => boolean, findOutdated: (d: string) => Promise<Array<{ pkg: string, current: string, latest: string }>> },
514
+ * }} [opts] test-injection seams:
515
+ * - `nodeVersion`: override the running Node version (asserts the fail case
516
+ * without being on old Node);
517
+ * - `cliDir`: directory of the CLI package whose `engines.node` sources the
518
+ * required major (defaults to THIS module's package);
519
+ * - `vendor`: inject the `{ hasVendorPin, findOutdated }` pair so the pin check
520
+ * runs against a stub instead of a real network call.
521
+ * @returns {Promise<DoctorResult[]>}
522
+ */
523
+ export async function runDoctorChecks(appDir, opts = {}) {
524
+ const cliDir = opts.cliDir || new URL('.', import.meta.url).pathname;
525
+ const results = await Promise.all([
526
+ checkNode(cliDir, opts),
527
+ checkTsconfig(appDir),
528
+ checkEnv(appDir),
529
+ checkVendorPin(appDir, opts),
530
+ checkWebjsVersions(appDir),
531
+ Promise.resolve(checkGitHook(appDir)),
532
+ ]);
533
+ return results;
534
+ }
@@ -173,58 +173,107 @@ export async function writeSaasFiles(appDir) {
173
173
  "",
174
174
  ].join('\n'));
175
175
 
176
- // test/unit/auth.test.ts: minimal stub so the scaffold passes
177
- // `webjs test` runs cleanly out of the
178
- // box. The signup/current-user functions import from lib/prisma.server.ts
179
- // and lib/auth.server.ts, both of which need `prisma generate` to have run before
180
- // they can be imported, so we deliberately test only the runtime-
181
- // dependency-free types.ts here. Replace with real tests once Prisma
182
- // is set up (run `npm install && npx prisma migrate dev --name init`).
183
- await writeFile(join(appDir, 'test', 'unit', 'auth.test.ts'), [
176
+ // test/auth/auth.test.ts: a REAL auth-flow test driven through the framework
177
+ // request pipeline with the @webjsdev/server test harness (createRequestHandler
178
+ // + the handle() helpers from @webjsdev/server/testing). It lives under the
179
+ // documented test/<feature>/ convention (test/auth/), not the old test/unit/
180
+ // path.
181
+ //
182
+ // Two layers, by DB availability:
183
+ // - The protected-route gate (unauthenticated /dashboard -> 302 /login) runs
184
+ // ALWAYS once the app modules import: auth() only reads a cookie, no DB
185
+ // query. This is the headline security assertion and it is REAL.
186
+ // - The signup -> login -> protected-route flow writes + reads a user, so it
187
+ // needs Prisma generated AND migrated (`npm run db:generate` +
188
+ // `npm run db:migrate`). When the Prisma client is not yet generated the
189
+ // app modules can't import at all, so the whole suite skips with a clear
190
+ // message instead of crashing. After you set up the DB it runs for real.
191
+ await mkdir(join(appDir, 'test', 'auth'), { recursive: true });
192
+ await writeFile(join(appDir, 'test', 'auth', 'auth.test.ts'), [
184
193
  "import { test } from 'node:test';",
185
194
  "import assert from 'node:assert/strict';",
195
+ "import { fileURLToPath } from 'node:url';",
196
+ "import { dirname, resolve } from 'node:path';",
197
+ "",
198
+ "import { createRequestHandler } from '@webjsdev/server';",
199
+ "import { testRequest, loginAndGetCookies, withSessionCookie } from '@webjsdev/server/testing';",
200
+ "",
201
+ "const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');",
202
+ "",
203
+ "// The auth pages + dashboard middleware import lib/prisma.server.ts, which",
204
+ "// imports @prisma/client. Until `npm run db:generate` (prisma generate) has",
205
+ "// run, that import is missing, so a request hitting those modules 500s. We",
206
+ "// detect that at the RESPONSE level (a 5xx on the dashboard) and SKIP with a",
207
+ "// clear message rather than reporting a misleading failure. After you run",
208
+ "// npm install && npm run db:generate && npm run db:migrate",
209
+ "// every assertion below runs for real.",
210
+ "process.env.DATABASE_URL ||= 'file:./dev.db';",
211
+ "process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!';",
212
+ "",
213
+ "function makeHandler() {",
214
+ " // createRequestHandler builds lazily, so it succeeds even before prisma is",
215
+ " // generated; the missing dependency only surfaces when a request reaches",
216
+ " // the prisma-importing module. That is why readiness is probed per-response.",
217
+ " return createRequestHandler({ appDir, dev: true });",
218
+ "}",
186
219
  "",
187
- "import type { User, ActionResult } from '../../modules/auth/types.ts';",
188
- "",
189
- "test('User shape: id is numeric, email is required', () => {",
190
- " const u: User = { id: 1, name: 'Test', email: 'test@example.com' };",
191
- " assert.equal(typeof u.id, 'number');",
192
- " assert.equal(typeof u.email, 'string');",
193
- "});",
194
- "",
195
- "test('ActionResult: success envelope carries data', () => {",
196
- " const r: ActionResult<User> = {",
197
- " success: true,",
198
- " data: { id: 1, name: 'Test', email: 'test@example.com' },",
199
- " };",
200
- " assert.equal(r.success, true);",
201
- " if (r.success) assert.equal(r.data.email, 'test@example.com');",
220
+ "test('protected route redirects to /login when unauthenticated', async (t) => {",
221
+ " const app = await makeHandler();",
222
+ " const res = await testRequest(app.handle, '/dashboard');",
223
+ " if (res.status >= 500) {",
224
+ " t.skip('app deps not ready (run `npm run db:generate` + `npm run db:migrate`)');",
225
+ " return;",
226
+ " }",
227
+ " // The dashboard middleware calls auth(); with no session cookie it 302s to",
228
+ " // /login. This needs no DB row, only a cookie read, so it is always real",
229
+ " // once the modules import.",
230
+ " assert.equal(res.status, 302, 'unauthenticated dashboard is gated');",
231
+ " assert.equal(res.headers.get('location'), '/login');",
202
232
  "});",
203
233
  "",
204
- "test('ActionResult: failure envelope carries error + status', () => {",
205
- " const r: ActionResult<User> = {",
206
- " success: false,",
207
- " error: 'Email already registered',",
208
- " status: 409,",
209
- " };",
210
- " assert.equal(r.success, false);",
211
- " if (!r.success) {",
212
- " assert.equal(r.status, 409);",
213
- " assert.ok(r.error.length > 0);",
234
+ "test('signup -> login -> dashboard renders for the authenticated user', async (t) => {",
235
+ " const app = await makeHandler();",
236
+ " // Probe readiness: a 5xx on the dashboard means deps/DB are not set up.",
237
+ " const probe = await testRequest(app.handle, '/dashboard');",
238
+ " if (probe.status >= 500) { t.skip('app deps not ready; run `npm run db:generate` + `npm run db:migrate`'); return; }",
239
+ "",
240
+ " const email = `harness+${Date.now()}@example.com`;",
241
+ " const password = 'password123';",
242
+ "",
243
+ " // Real signup through the page server action (the no-JS form write-path).",
244
+ " let canSignup = true;",
245
+ " try {",
246
+ " const signupRes = await testRequest(app.handle, '/signup', {",
247
+ " method: 'POST',",
248
+ " headers: { 'content-type': 'application/x-www-form-urlencoded' },",
249
+ " body: new URLSearchParams({ name: 'Harness', email, password }).toString(),",
250
+ " });",
251
+ " // Success is a 303 PRG to /login; a 422 means validation failed (still a",
252
+ " // real response, just not the happy path). Either way the action ran.",
253
+ " assert.ok([303, 422].includes(signupRes.status), 'signup action ran');",
254
+ " if (signupRes.status !== 303) canSignup = false;",
255
+ " } catch {",
256
+ " // No migrated DB table -> the action throws. Skip the DB-backed assertions.",
257
+ " canSignup = false;",
214
258
  " }",
215
- "});",
259
+ " if (!canSignup) { t.skip('no migrated DB; run `npm run db:migrate` to enable the full flow'); return; }",
216
260
  "",
217
- "// TODO: once you've run `npm install && npx prisma migrate dev` you can",
218
- "// import { signup } from '../../modules/auth/actions/signup.server.ts'",
219
- "// and { currentUser } from '../../modules/auth/queries/current-user.server.ts'",
220
- "// and write real integration tests against a test SQLite DB.",
261
+ " // Real login captures the genuine signed session cookie.",
262
+ " const { cookies } = await loginAndGetCookies(app.handle, { email, password });",
263
+ "",
264
+ " // With the session cookie the protected route now renders (200).",
265
+ " const dash = await testRequest(app.handle, '/dashboard', withSessionCookie({}, cookies));",
266
+ " assert.equal(dash.status, 200, 'the session cookie unlocks the dashboard');",
267
+ " const body = await dash.text();",
268
+ " assert.match(body, /Dashboard/, 'the dashboard content rendered');",
269
+ "});",
221
270
  "",
222
271
  ].join('\n'));
223
272
 
224
273
  // app/api/auth/[...path]/route.ts
225
274
  await mkdir(join(appDir, 'app', 'api', 'auth', '[...path]'), { recursive: true });
226
275
  await writeFile(join(appDir, 'app', 'api', 'auth', '[...path]', 'route.ts'), [
227
- "import { handlers } from '../../../../../lib/auth.server.ts';",
276
+ "import { handlers } from '../../../../lib/auth.server.ts';",
228
277
  "export const GET = handlers.GET;",
229
278
  "export const POST = handlers.POST;",
230
279
  "",
@@ -250,7 +299,7 @@ export async function writeSaasFiles(appDir) {
250
299
  " <p class=${cardDescriptionClass()}>Welcome back: log in to continue.</p>",
251
300
  " </div>",
252
301
  " <div class=${cardContentClass()}>",
253
- " <form method=\"POST\" action=\"/api/auth/callback/credentials\" class=\"flex flex-col gap-4\">",
302
+ " <form method=\"POST\" action=\"/api/auth/signin/credentials\" class=\"flex flex-col gap-4\">",
254
303
  " <div class=\"flex flex-col gap-1.5\">",
255
304
  " <label class=${labelClass()} for=\"email\">Email</label>",
256
305
  " <input class=${inputClass()} id=\"email\" name=\"email\" type=\"email\" required>",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.5",
3
+ "version": "0.10.7",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -0,0 +1,15 @@
1
+ {
2
+ "json.schemas": [
3
+ {
4
+ "fileMatch": ["/package.json"],
5
+ "schema": {
6
+ "type": "object",
7
+ "properties": {
8
+ "webjs": {
9
+ "$ref": "./node_modules/@webjsdev/server/webjs-config.schema.json"
10
+ }
11
+ }
12
+ }
13
+ }
14
+ ]
15
+ }
@@ -112,6 +112,15 @@ layered on top:
112
112
  See [docs.webjs.com → Editor setup](https://docs.webjs.com/docs/editor-setup)
113
113
  for the full walkthrough.
114
114
 
115
+ **Config validation in `package.json`.** The scaffold ships
116
+ `.vscode/settings.json`, which associates the published webjs-config JSON
117
+ Schema (`@webjsdev/server/webjs-config.schema.json`) with the `webjs` block
118
+ of `package.json`. In VS Code an unknown / typo'd `webjs.*` key (`redirect`
119
+ for `redirects`, say) is then flagged inline instead of silently dropped to
120
+ the default. The same shape is typed by the `WebjsConfig` type from
121
+ `@webjsdev/core` (`import type { WebjsConfig } from '@webjsdev/core'`) for a
122
+ typed reference.
123
+
115
124
  ## UI components: Webjs UI (preinstalled)
116
125
 
117
126
  This scaffold ships with the standard Webjs UI component kit
@@ -267,6 +276,28 @@ test/<feature>/ feature-scoped tests, one folder per concern
267
276
  middleware.ts root middleware (optional, outermost)
268
277
  ```
269
278
 
279
+ ### Typed page / layout / route-handler props
280
+
281
+ Type page / layout / route-handler arguments with the exported helpers so a
282
+ param typo is a compile-time error:
283
+
284
+ ```ts
285
+ import type { PageProps, LayoutProps, RouteHandlerContext } from '@webjsdev/core';
286
+
287
+ export default function Post({ params }: PageProps<'/blog/[slug]'>) {
288
+ return html`<h1>${params.slug}</h1>`; // params typed { slug: string }
289
+ }
290
+ export default function RootLayout({ children }: LayoutProps) { /* ... */ }
291
+ export async function GET(req: Request, ctx: RouteHandlerContext) { /* ctx.params */ }
292
+ ```
293
+
294
+ Run `webjs types` once (and ensure `tsconfig.json` `include` lists
295
+ `.webjs/routes.d.ts`, the scaffold already does) to generate the route union:
296
+ `PageProps<'/blog/[slug]'>['params']` then narrows to `{ slug: string }` and
297
+ `navigate()` only accepts real app routes. `webjs dev` regenerates the file on
298
+ startup, so it stays current. Without it, `params` is `Record<string, string>`
299
+ and `navigate()` accepts any string (non-breaking).
300
+
270
301
  ## Database (Prisma + SQLite by default)
271
302
 
272
303
  Every scaffold includes a Prisma setup pointed at a local SQLite file.
@@ -709,8 +740,13 @@ return html`
709
740
  ```
710
741
 
711
742
  The router's `closest('webjs-frame')` detection takes precedence over
712
- layout markers. Only the frame's content swaps. Use this sparingly -
713
- folder-based layouts handle 99% of cases.
743
+ layout markers. Only the frame's content swaps. Use this sparingly,
744
+ folder-based layouts handle 99% of cases. When a frame nav's response
745
+ lacks the matching `<webjs-frame id>` (e.g. an auth redirect), the router
746
+ fires a cancelable, bubbling `webjs:frame-missing` event (detail
747
+ `{ frameId, url, document }`) and leaves the frame unchanged rather than
748
+ silently swapping the whole page; call `preventDefault()` to take over
749
+ the outcome (e.g. `location.assign(e.detail.url)`).
714
750
 
715
751
  ### 5. `loading.ts` for per-segment skeletons
716
752
 
@@ -61,6 +61,17 @@ even if the user doesn't explicitly ask.**
61
61
  4. Sync with parent: `git fetch origin && git rebase origin/main` if behind
62
62
  5. Don't mix unrelated work on the wrong branch
63
63
 
64
+ ### After cloning: verify the toolchain
65
+
66
+ Run `npm run doctor` (which runs `webjs doctor`) once after cloning to assert
67
+ the project is set up correctly: the Node major (the strip-types floor), the
68
+ tsconfig `erasableSyntaxOnly` flag, `.env` drift vs `.env.example`, vendor-pin
69
+ freshness, `@webjsdev/*` version coherence, and the git pre-commit hook. It
70
+ prints `[pass]` / `[warn]` / `[fail]` per check with an actionable fix line and
71
+ exits non-zero only on a hard fail (a broken toolchain), so a green run means
72
+ `npm run dev` will boot. It is a local onboarding/setup-verify tool, not a CI
73
+ gate (its env-drift + network pin-freshness checks would make CI flaky).
74
+
64
75
  ### Every code change must include:
65
76
 
66
77
  1. **Commit and push per logical unit, not at the end.** A logical unit is
@@ -86,6 +97,12 @@ even if the user doesn't explicitly ask.**
86
97
  4. **Convention check.** Run `webjs check` after changes and fix
87
98
  any violations before reporting the task as done.
88
99
 
100
+ 5. **Type check.** Run `npm run typecheck` (which runs `webjs typecheck`,
101
+ a `tsc --noEmit` over the app) and fix any type errors. `webjs check` is
102
+ correctness-only and does NOT type-check, so this is the separate
103
+ is-my-TypeScript-valid gate. It exits non-zero on a type error, so add it
104
+ to CI once the app type-checks cleanly.
105
+
89
106
  ### Definition of done (MUST be addressed BEFORE opening the PR)
90
107
 
91
108
  This is the per-PR contract. Before running `gh pr create`, walk through
@@ -433,6 +450,40 @@ test/
433
450
  - `webjs test --browser` (or `npx wtr`) runs the browser tests.
434
451
  - `WEBJS_E2E=1 webjs test` adds the e2e tests.
435
452
 
453
+ ### The handle() test harness (full-pipeline node tests)
454
+
455
+ For a node test that needs the REAL request pipeline (middleware, routing,
456
+ SSR, page actions, server-action RPC, auth + CSRF), drive
457
+ `createRequestHandler({ appDir }).handle(request)` and assert on the
458
+ `Response`. `@webjsdev/server/testing` ships thin builders over it:
459
+
460
+ ```ts
461
+ import { createRequestHandler } from '@webjsdev/server';
462
+ import { testRequest, getCsrf, invokeActionForTest, loginAndGetCookies, withSessionCookie }
463
+ from '@webjsdev/server/testing';
464
+
465
+ const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
466
+
467
+ // fire a request, assert the response
468
+ const res = await testRequest(app.handle, '/about');
469
+
470
+ // real login, reuse the captured session cookie on a protected route
471
+ const { cookies } = await loginAndGetCookies(app.handle, { email, password });
472
+ const dash = await testRequest(app.handle, '/dashboard', withSessionCookie({}, cookies));
473
+
474
+ // round-trip a server action through the REAL /__webjs/action/<hash>/<fn> path
475
+ const out = await invokeActionForTest(app, 'modules/posts/actions/create.server.ts', 'createPost', [input]);
476
+ ```
477
+
478
+ Prefer `invokeActionForTest` over a direct import of the action when you want
479
+ to verify the production contract: it exercises the wire serializer (a `Date` /
480
+ `Map` arg survives), CSRF, and prod error sanitization, which a direct call
481
+ bypasses. The saas template's `test/auth/auth.test.ts` is a worked example.
482
+
483
+ This is also why the auth test lives at `test/auth/auth.test.ts` (the
484
+ feature-folder convention), NOT `test/unit/auth.test.ts`. Test KIND is a
485
+ subfolder inside a feature, never the top level.
486
+
436
487
  **Every change ships with a test.** For Claude Code, a commit that
437
488
  stages app code (`app/`, `modules/`, `components/`, `lib/`) without
438
489
  staging a test is blocked by `.claude/hooks/require-tests-with-src.sh`.