@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 +60 -0
- package/lib/create.js +10 -0
- package/lib/doctor.js +534 -0
- package/lib/saas-template.js +89 -40
- package/package.json +1 -1
- package/templates/CONVENTIONS.md +51 -0
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
|
+
}
|
package/lib/saas-template.js
CHANGED
|
@@ -173,58 +173,107 @@ export async function writeSaasFiles(appDir) {
|
|
|
173
173
|
"",
|
|
174
174
|
].join('\n'));
|
|
175
175
|
|
|
176
|
-
// test/
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
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
|
-
"
|
|
188
|
-
"",
|
|
189
|
-
"
|
|
190
|
-
"
|
|
191
|
-
"
|
|
192
|
-
"
|
|
193
|
-
"}
|
|
194
|
-
"",
|
|
195
|
-
"
|
|
196
|
-
"
|
|
197
|
-
"
|
|
198
|
-
"
|
|
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('
|
|
205
|
-
" const
|
|
206
|
-
"
|
|
207
|
-
"
|
|
208
|
-
"
|
|
209
|
-
"
|
|
210
|
-
"
|
|
211
|
-
"
|
|
212
|
-
"
|
|
213
|
-
"
|
|
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
|
-
"//
|
|
218
|
-
"
|
|
219
|
-
"
|
|
220
|
-
"//
|
|
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 '
|
|
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/
|
|
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
package/templates/CONVENTIONS.md
CHANGED
|
@@ -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`.
|