@webjsdev/cli 0.10.6 → 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,7 +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)
45
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)
46
48
  webjs create <name> [--template full-stack|api|saas] [--no-install] Scaffold a new webjs app
47
49
  (only 3 templates exist. default: full-stack with Prisma+SQLite)
48
50
  Auto-runs the detected package manager's install in the new dir
@@ -278,6 +280,38 @@ async function main() {
278
280
  }
279
281
  break;
280
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
+ }
281
315
  case 'types': {
282
316
  // Generate `.webjs/routes.d.ts` from the app's `app/` routes (#258),
283
317
  // narrowing the @webjsdev/core `Route` href union + per-route `params`.
@@ -299,6 +333,32 @@ async function main() {
299
333
  );
300
334
  break;
301
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
+ }
302
362
  case 'create': {
303
363
  const name = rest[0];
304
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',
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.6",
3
+ "version": "0.10.7",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -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`.