@webjsdev/cli 0.10.50 → 0.10.52
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/README.md +3 -1
- package/bin/webjs.js +437 -32
- package/lib/api-gallery.js +6 -7
- package/lib/app-name.js +208 -0
- package/lib/create.js +37 -9
- package/lib/doctor.js +566 -21
- package/package.json +2 -2
- package/templates/.agents/rules/workflow.md +9 -1
- package/templates/.agents/skills/webjs/SKILL.md +26 -11
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +2 -2
- package/templates/.agents/skills/webjs/references/built-ins.md +26 -7
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +16 -2
- package/templates/.agents/skills/webjs/references/components.md +59 -2
- package/templates/.agents/skills/webjs/references/data-and-actions.md +92 -7
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +75 -19
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +35 -14
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +34 -15
- package/templates/.agents/skills/webjs/references/runtime.md +5 -1
- package/templates/.agents/skills/webjs/references/styling.md +1 -1
- package/templates/.agents/skills/webjs/references/testing.md +80 -3
- package/templates/.agents/skills/webjs/references/typescript.md +71 -2
- package/templates/.agents/skills/webjs/references/ui-kit.md +5 -2
- package/templates/.github/pull_request_template.md +1 -0
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/AGENTS.md +31 -5
- package/templates/CONVENTIONS.md +4 -1
- package/templates/gallery/app/examples/layout.ts +2 -1
- package/templates/gallery/app/examples/todo/page.ts +3 -16
- package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
- package/templates/gallery/app/features/auth/signup/page.ts +4 -23
- package/templates/gallery/app/features/caching/page.ts +6 -6
- package/templates/gallery/app/features/file-storage/page.ts +8 -19
- package/templates/gallery/app/features/forms/page.ts +12 -38
- package/templates/gallery/app/features/layout.ts +6 -2
- package/templates/gallery/app/features/route-handler/data/route.ts +2 -1
- package/templates/gallery/app/features/view-transitions/page.ts +1 -1
- package/templates/gallery/app/global-error.ts +7 -4
- package/templates/gallery/modules/async-render/components/server-clock.ts +3 -2
- package/templates/gallery/modules/auth/actions/signup.server.ts +27 -11
- package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +21 -10
- package/templates/gallery/modules/forms/actions/send-message.server.ts +34 -0
- package/templates/gallery/modules/gallery/nav.ts +1 -1
- package/templates/gallery/modules/server-actions/actions/greet.test.ts +6 -5
- package/templates/gallery/modules/todo/actions/submit-todo.server.ts +33 -0
- package/templates/gallery/modules/todo/components/todo-app.ts +8 -5
- package/templates/gallery/modules/todo/types.ts +15 -10
- package/templates/gallery/test/auth/auth.test.ts +31 -16
- package/templates/partials/agents-playbook-api.md +5 -0
- package/templates/partials/agents-playbook-fullstack.md +5 -0
- package/templates/scripts/clear-gallery.mjs +5 -4
- package/templates/test/hello/e2e/hello.test.ts +18 -1
package/README.md
CHANGED
|
@@ -39,12 +39,14 @@ Both `webjs create` and `create-webjs-app` auto-install dependencies in the new
|
|
|
39
39
|
|
|
40
40
|
```sh
|
|
41
41
|
webjs create <name> # scaffold a full-stack app (default; auth ships as a gallery card)
|
|
42
|
+
# <name> must be a valid package name: letters, digits,
|
|
43
|
+
# and - . _ , starting with a letter or a digit
|
|
42
44
|
webjs create <name> --template api # backend-only API app (routes + modules + Drizzle)
|
|
43
45
|
|
|
44
46
|
webjs dev # dev server with live reload (runs webjs.dev.before, e.g. webjs db migrate, then serves; npm run dev is a thin alias)
|
|
45
47
|
webjs start # production server (no build step, serves source directly)
|
|
46
48
|
webjs check # validate source-code conventions (CI gate)
|
|
47
|
-
webjs doctor # verify the project/toolchain setup (
|
|
49
|
+
webjs doctor # verify the project/toolchain setup (per-check severity via webjs.doctor.gate, so CI can gate a subset)
|
|
48
50
|
webjs test # run server + browser tests
|
|
49
51
|
webjs vendor pin [--download] # pin client deps to a committable importmap (offline/reproducible)
|
|
50
52
|
webjs db <generate|migrate|push|studio|seed> # drizzle-kit passthrough (+ seed)
|
package/bin/webjs.js
CHANGED
|
@@ -8,6 +8,7 @@ import { dbGenerateTtyHint } from '../lib/db-hints.js';
|
|
|
8
8
|
import { checkNodeInline, nodeInlineMessage } from '../lib/node-preflight.js';
|
|
9
9
|
import { loadAppEnv, resolvePort } from '../lib/port.js';
|
|
10
10
|
import { planDevSupervisor } from '../lib/dev-supervisor.js';
|
|
11
|
+
import { checkAppName, appNameErrorMessage } from '../lib/app-name.js';
|
|
11
12
|
|
|
12
13
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
14
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
@@ -48,6 +49,40 @@ if (cmd !== 'help' && cmd !== undefined && !wantsHelp && !wantsVersion) {
|
|
|
48
49
|
// .agents/rules/workflow.md / .github/copilot-instructions.md mirror it.
|
|
49
50
|
const TEMPLATES = ['full-stack', 'api'];
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The one thing `webjs elision --verify` must say about its own boundary, in
|
|
54
|
+
* the command's own output rather than only in the docs. The differential masks
|
|
55
|
+
* the JS-loaded set by construction, so it can only ever see the DANGEROUS
|
|
56
|
+
* direction (elision changed the served bytes); a wrongly dropped module shows
|
|
57
|
+
* up as a dead click, which is bytes-identical.
|
|
58
|
+
*/
|
|
59
|
+
const VERIFY_CAVEAT = `
|
|
60
|
+
This proves elision did not change the bytes your app serves. It does NOT prove
|
|
61
|
+
post-hydration behaviour: a wrongly dropped module shows up as a dead click, not
|
|
62
|
+
as different bytes. Run your browser or e2e suite twice to cover that half:
|
|
63
|
+
WEBJS_ELIDE=1 <your e2e command>
|
|
64
|
+
WEBJS_ELIDE=0 <your e2e command>`;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `--routes /a,/b` or `--routes=/a,/b` -> ['/a', '/b']. Every value is
|
|
68
|
+
* normalized to a leading slash; empties drop.
|
|
69
|
+
* @param {string[]} argv
|
|
70
|
+
* @returns {string[]}
|
|
71
|
+
*/
|
|
72
|
+
function parseRoutesFlag(argv) {
|
|
73
|
+
/** @type {string[]} */
|
|
74
|
+
const raw = [];
|
|
75
|
+
for (let i = 0; i < argv.length; i++) {
|
|
76
|
+
if (argv[i] === '--routes') { if (argv[i + 1]) raw.push(argv[i + 1]); i++; }
|
|
77
|
+
else if (argv[i].startsWith('--routes=')) raw.push(argv[i].slice('--routes='.length));
|
|
78
|
+
}
|
|
79
|
+
return raw
|
|
80
|
+
.flatMap((v) => v.split(','))
|
|
81
|
+
.map((v) => v.trim())
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.map((v) => (v.startsWith('/') ? v : '/' + v));
|
|
84
|
+
}
|
|
85
|
+
|
|
51
86
|
const USAGE = `webjs commands:
|
|
52
87
|
webjs dev [--port 8080] [--no-hot] Start dev server with live reload
|
|
53
88
|
(--no-hot: run in-process, no hot-reload supervisor)
|
|
@@ -55,12 +90,17 @@ const USAGE = `webjs commands:
|
|
|
55
90
|
webjs test [--server|--browser] Run server + browser tests
|
|
56
91
|
webjs check [--json] Run correctness checks (--json emits structured violations)
|
|
57
92
|
webjs routes [--json|--table] [--no-headers] Print the route table (path / owner file / methods). Default tree; --json matches the MCP list_routes shape; --no-headers drops the --table header
|
|
58
|
-
webjs
|
|
59
|
-
|
|
60
|
-
|
|
93
|
+
webjs elision [--json] [--verify] Report which component modules are elided and why each shipped one ships;
|
|
94
|
+
--verify diffs SSR output with elision on vs off (exits non-zero on a divergence)
|
|
95
|
+
webjs mcp Start the read-only MCP server (routes / actions / components / elision / check)
|
|
96
|
+
webjs doctor [--json] [--strict] Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision, component elision, un-versioned stylesheet links).
|
|
97
|
+
--json emits the structured results (with stable codes). --strict additionally fails on every remaining warning.
|
|
98
|
+
Per-check severity is CONFIG: map a code to off/warn/error under "webjs": { "doctor": { "gate": {...} } }
|
|
99
|
+
in package.json, so CI gates on a chosen subset without every warning becoming fatal
|
|
61
100
|
webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
|
|
62
101
|
webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors)
|
|
63
102
|
webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install] Scaffold a new webjs app
|
|
103
|
+
<name> must be a valid package name (letters, digits, - . _, starts with a letter or digit)
|
|
64
104
|
(only 2 templates exist. default: full-stack, Drizzle, --db sqlite, --runtime node)
|
|
65
105
|
--runtime bun emits a Bun-flavored app (bun.lock, bun Dockerfile/CI, bun docs);
|
|
66
106
|
also auto-detected when run via "bun create webjs".
|
|
@@ -140,12 +180,39 @@ const HELP = {
|
|
|
140
180
|
],
|
|
141
181
|
examples: ['webjs routes', 'webjs routes --table', 'webjs routes --table --no-headers', 'webjs routes --json'],
|
|
142
182
|
},
|
|
183
|
+
elision: {
|
|
184
|
+
usage: 'webjs elision [--json] [--verify] [--routes <paths>]',
|
|
185
|
+
summary:
|
|
186
|
+
'Report the elision verdict: which component modules the browser never downloads, and why each one that ships does.',
|
|
187
|
+
options: [
|
|
188
|
+
{ flag: '--json', description: 'Emit the verdict as JSON (byte-identical to the MCP list_elision tool).' },
|
|
189
|
+
{ flag: '--verify', description: 'Render every static page route with elision on and off and diff the observable SSR bytes. Exits non-zero on a divergence.' },
|
|
190
|
+
{ flag: '--routes <paths>', description: 'Comma-separated URL paths to add to the --verify corpus (the only way to cover a dynamic route).' },
|
|
191
|
+
],
|
|
192
|
+
notesTitle: 'What --verify proves',
|
|
193
|
+
notes: [
|
|
194
|
+
'--verify proves elision did not change the bytes your app serves. It does NOT',
|
|
195
|
+
'prove post-hydration behaviour: a wrongly dropped module shows up as a dead',
|
|
196
|
+
'click, not as different bytes. Run your browser or e2e suite twice',
|
|
197
|
+
'(WEBJS_ELIDE=1 then WEBJS_ELIDE=0) to cover that half.',
|
|
198
|
+
],
|
|
199
|
+
examples: ['webjs elision', 'webjs elision --json', 'webjs elision --verify', 'webjs elision --verify --routes /,/blog/hello'],
|
|
200
|
+
},
|
|
143
201
|
doctor: {
|
|
144
202
|
usage: 'webjs doctor [--json] [--strict]',
|
|
145
203
|
summary: 'Verify project health. Each result carries a stable code so an agent branches on the failure kind.',
|
|
146
204
|
options: [
|
|
147
|
-
{ flag: '--json', description: 'Emit the DoctorResult[] (with stable codes) + a summary as JSON.' },
|
|
148
|
-
{ flag: '--strict', description: 'Also fail the exit on
|
|
205
|
+
{ flag: '--json', description: 'Emit the DoctorResult[] (with stable codes + severities) + a summary as JSON.' },
|
|
206
|
+
{ flag: '--strict', description: 'Also fail the exit on EVERY remaining warning, not just hard failures and gated errors.' },
|
|
207
|
+
],
|
|
208
|
+
notes: [
|
|
209
|
+
'Per-check severity is CONFIG, not a flag. Declare it in package.json under',
|
|
210
|
+
'"webjs": { "doctor": { "gate": { "<CODE>": "off" | "warn" | "error" } } }, so CI',
|
|
211
|
+
'gates on a chosen subset without --strict making every warning fatal. A malformed',
|
|
212
|
+
'gate exits 1 naming it (an unknown code, a bad severity, a non-object doctor/gate,',
|
|
213
|
+
'or a misspelled sibling like "gates"); under --json those come back as a',
|
|
214
|
+
'configErrors array with results empty. A "could not check" result (a network or',
|
|
215
|
+
'toolchain outage) is capped at warn and can never be escalated to error.',
|
|
149
216
|
],
|
|
150
217
|
examples: ['webjs doctor', 'webjs doctor --json', 'webjs doctor --strict', 'webjs doctor --json --strict'],
|
|
151
218
|
},
|
|
@@ -163,6 +230,12 @@ const HELP = {
|
|
|
163
230
|
usage: 'webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install]',
|
|
164
231
|
summary: 'Scaffold a new app. Defaults: full-stack template, Drizzle + SQLite, Node runtime.',
|
|
165
232
|
options: [
|
|
233
|
+
// Kept to one terminal line like every other row: printHelp does not
|
|
234
|
+
// wrap, so a long description renders as one 300-column line.
|
|
235
|
+
{
|
|
236
|
+
flag: '<name>',
|
|
237
|
+
description: 'Package name: letters, digits, - . _ , starts with a letter or digit.',
|
|
238
|
+
},
|
|
166
239
|
{ flag: '--template <t>', description: 'full-stack (default) or api (backend-only, no UI).' },
|
|
167
240
|
{ flag: '--db <d>', description: 'sqlite (default) or postgres.' },
|
|
168
241
|
{ flag: '--runtime <r>', description: 'node (default) or bun.' },
|
|
@@ -196,7 +269,7 @@ const HELP = {
|
|
|
196
269
|
},
|
|
197
270
|
mcp: {
|
|
198
271
|
usage: 'webjs mcp',
|
|
199
|
-
summary: 'Start the read-only MCP server (routes / actions / components / check + a docs/source knowledge layer).',
|
|
272
|
+
summary: 'Start the read-only MCP server (routes / actions / components / elision / check + a docs/source knowledge layer).',
|
|
200
273
|
examples: ['webjs mcp'],
|
|
201
274
|
},
|
|
202
275
|
version: {
|
|
@@ -235,6 +308,15 @@ function printCommandHelp(name) {
|
|
|
235
308
|
const width = Math.max(...options.map((o) => o.flag.length));
|
|
236
309
|
console.log('Options:');
|
|
237
310
|
for (const o of options) console.log(` ${o.flag.padEnd(width)} ${o.description}`);
|
|
311
|
+
// Optional per-command prose for surface a flag table cannot carry (doctor's
|
|
312
|
+
// package.json severity gate is the one that needs it). The heading defaults
|
|
313
|
+
// to `Config:` because that is what doctor's block is and what its help test
|
|
314
|
+
// pins; a command whose prose is not configuration names its own heading
|
|
315
|
+
// (`webjs elision`'s is a caveat about what --verify proves).
|
|
316
|
+
if (h.notes) {
|
|
317
|
+
console.log(`\n${h.notesTitle || 'Config'}:`);
|
|
318
|
+
for (const line of h.notes) console.log(` ${line}`);
|
|
319
|
+
}
|
|
238
320
|
console.log('\nExamples:');
|
|
239
321
|
for (const ex of h.examples) console.log(` ${ex}`);
|
|
240
322
|
return true;
|
|
@@ -630,7 +712,8 @@ async function main() {
|
|
|
630
712
|
if (rest.includes('--rules')) {
|
|
631
713
|
console.log('webjs check, correctness rules:');
|
|
632
714
|
console.log(' Every rule catches code that is wrong to ship: a crash, a');
|
|
633
|
-
console.log(' security leak,
|
|
715
|
+
console.log(' security leak, a reactive prop that silently stops');
|
|
716
|
+
console.log(' re-rendering, or a build/type-strip failure. They always');
|
|
634
717
|
console.log(' run. Project conventions (layout, style, process) are');
|
|
635
718
|
console.log(' guidance in CONVENTIONS.md, not rules here.\n');
|
|
636
719
|
for (const r of RULES) {
|
|
@@ -671,51 +754,113 @@ async function main() {
|
|
|
671
754
|
}
|
|
672
755
|
case 'doctor': {
|
|
673
756
|
// Project-health checklist (#266). The checks are PURE (in lib/doctor.js);
|
|
674
|
-
// this branch only renders them and owns the exit code
|
|
675
|
-
// HARD check FAILS
|
|
676
|
-
//
|
|
677
|
-
// app's concern, not a broken
|
|
678
|
-
|
|
679
|
-
|
|
757
|
+
// this branch only renders them and owns the exit code. The exit is
|
|
758
|
+
// non-zero when a HARD check FAILS or when a check the app gated `error`
|
|
759
|
+
// reports something; an UNGATED warn stays informational (env drift / pin
|
|
760
|
+
// staleness / version drift are the app's concern, not a broken
|
|
761
|
+
// toolchain). An app declares per-check severity
|
|
762
|
+
// in its package.json `webjs.doctor.gate` (#1257), which is what lets CI
|
|
763
|
+
// gate on a chosen subset without `--strict` making every warning fatal.
|
|
764
|
+
const { runDoctorChecks, readDoctorPolicy, applyDoctorPolicy, DOCTOR_CODES, DOCTOR_SEVERITIES } =
|
|
765
|
+
await import('../lib/doctor.js');
|
|
766
|
+
const appDir = process.cwd();
|
|
767
|
+
const strict = rest.includes('--strict');
|
|
768
|
+
const asJson = rest.includes('--json');
|
|
769
|
+
|
|
770
|
+
// Read the policy FIRST. A wrong shape, a key that is not a known code, or
|
|
771
|
+
// a value that is not a severity exits 1 without running the checks: a
|
|
772
|
+
// typo silently ignored would leave CI un-gated while looking gated,
|
|
773
|
+
// which is the worst failure a mechanism like this can have.
|
|
774
|
+
const policy = readDoctorPolicy(appDir);
|
|
775
|
+
const configErrors = [
|
|
776
|
+
...policy.malformed.map(({ path, value }) => ({ kind: 'malformed', path, value })),
|
|
777
|
+
...policy.unknownKeys.map((path) => ({ kind: 'unknown-key', path })),
|
|
778
|
+
...policy.unknownCodes.map((code) => ({ kind: 'unknown-code', code })),
|
|
779
|
+
...policy.badSeverities.map(({ code, value }) => ({ kind: 'bad-severity', code, value })),
|
|
780
|
+
];
|
|
781
|
+
if (configErrors.length > 0) {
|
|
782
|
+
if (asJson) {
|
|
783
|
+
console.log(JSON.stringify({
|
|
784
|
+
results: [],
|
|
785
|
+
summary: { pass: 0, warn: 0, fail: 0, off: 0, strict, ok: false },
|
|
786
|
+
configErrors,
|
|
787
|
+
}));
|
|
788
|
+
process.exit(1);
|
|
789
|
+
}
|
|
790
|
+
// Header names the BLOCK, not `gate`: two of the four error kinds are
|
|
791
|
+
// about `webjs.doctor` itself or a misspelled sibling, so naming `gate`
|
|
792
|
+
// would point at a key the package.json may not even contain.
|
|
793
|
+
console.error('webjs doctor: invalid "webjs.doctor" config in package.json\n');
|
|
794
|
+
for (const e of configErrors) {
|
|
795
|
+
if (e.kind === 'malformed') console.error(` Expected an object at ${e.path}, got ${JSON.stringify(e.value)}`);
|
|
796
|
+
else if (e.kind === 'unknown-key') console.error(` Unknown config key: ${e.path} (the only key is "gate")`);
|
|
797
|
+
else if (e.kind === 'unknown-code') console.error(` Unknown check code: ${e.code}`);
|
|
798
|
+
else console.error(` Invalid severity for ${e.code}: ${JSON.stringify(e.value)}`);
|
|
799
|
+
}
|
|
800
|
+
console.error('\n Shape: "webjs": { "doctor": { "gate": { "<CODE>": "<severity>" } } }');
|
|
801
|
+
console.error(` Valid severities: ${DOCTOR_SEVERITIES.join(' / ')}`);
|
|
802
|
+
console.error(` Valid codes: ${Object.values(DOCTOR_CODES).join(', ')}`);
|
|
803
|
+
process.exit(1);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const results = applyDoctorPolicy(await runDoctorChecks(appDir), policy.gate);
|
|
807
|
+
// Counts come off the EFFECTIVE severity, not the raw status, so a gated
|
|
808
|
+
// code lands in the bucket the app asked for. With no gate the two are
|
|
809
|
+
// identical (a `fail` defaults to `error`, a `warn` to `warn`), which is
|
|
810
|
+
// what keeps an un-configured app byte-identical to before.
|
|
680
811
|
const counts = results.reduce((acc, r) => {
|
|
681
|
-
acc[r.
|
|
812
|
+
acc[r.severity] = (acc[r.severity] || 0) + 1;
|
|
682
813
|
return acc;
|
|
683
814
|
}, /** @type {Record<string, number>} */ ({}));
|
|
684
815
|
const pass = counts.pass || 0;
|
|
685
816
|
const warn = counts.warn || 0;
|
|
686
|
-
const fail = counts.
|
|
687
|
-
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
|
|
817
|
+
const fail = counts.error || 0;
|
|
818
|
+
const off = counts.off || 0;
|
|
819
|
+
// `--strict` additionally fails the exit on every REMAINING warning, so an
|
|
820
|
+
// agent can gate on a fully-clean toolchain (drift / staleness / pin freshness) in a fix loop,
|
|
821
|
+
// not just on a hard toolchain break. Without it, a warn is fatal only
|
|
822
|
+
// where the app gated its code `error`, which folded into `fail` above.
|
|
691
823
|
const failing = fail > 0 || (strict && warn > 0);
|
|
692
824
|
|
|
693
|
-
// --json emits the raw DoctorResult[] (each carries a stable `code`
|
|
694
|
-
// a summary, so an agent consumes
|
|
695
|
-
// text. Shape mirrors `check
|
|
696
|
-
// with a `summary` count. The
|
|
697
|
-
// on the exit code AND parses
|
|
698
|
-
|
|
825
|
+
// --json emits the raw DoctorResult[] (each carries a stable `code` and
|
|
826
|
+
// its effective `severity`) plus a summary, so an agent consumes
|
|
827
|
+
// structured data instead of scraping the text. Shape mirrors `check
|
|
828
|
+
// --json`: a top-level array-bearing object with a `summary` count. The
|
|
829
|
+
// non-zero exit is preserved (an agent gates on the exit code AND parses
|
|
830
|
+
// the report).
|
|
831
|
+
if (asJson) {
|
|
699
832
|
console.log(JSON.stringify({
|
|
700
833
|
results,
|
|
701
|
-
summary: { pass, warn, fail, strict, ok: !failing },
|
|
834
|
+
summary: { pass, warn, fail, off, strict, ok: !failing },
|
|
702
835
|
}));
|
|
703
836
|
if (failing) process.exit(1);
|
|
704
837
|
break;
|
|
705
838
|
}
|
|
706
839
|
|
|
707
|
-
const marker = { pass: '[pass]', warn: '[warn]',
|
|
840
|
+
const marker = { pass: '[pass]', off: '[off]', warn: '[warn]', error: '[fail]' };
|
|
708
841
|
console.log('webjs doctor: project-health checklist\n');
|
|
709
842
|
for (const r of results) {
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
843
|
+
// Name the gate whenever it moved a result off its default, so the
|
|
844
|
+
// reason a warning is fatal (or silenced) is on the line itself.
|
|
845
|
+
const dflt = r.status === 'fail' ? 'error' : 'warn';
|
|
846
|
+
const gated = r.status !== 'pass' && r.severity !== dflt ? `, gated: ${r.severity}` : '';
|
|
847
|
+
console.log(` ${marker[r.severity]} ${r.name} (${r.code}${gated})`);
|
|
848
|
+
// A silenced check reports NOTHING beyond its name: an app that gated a
|
|
849
|
+
// code `off` asked not to hear about it, and printing the finding plus
|
|
850
|
+
// a Fix line every run is exactly the noise it turned off (ESLint's
|
|
851
|
+
// `off` drops the message too). The checklist still lists it as [off]
|
|
852
|
+
// and the summary still counts it, so a silenced check is never
|
|
853
|
+
// invisible, and `--json` keeps the whole result for tooling.
|
|
854
|
+
if (r.severity !== 'off') {
|
|
855
|
+
console.log(` ${r.message}`);
|
|
856
|
+
if (r.fix && r.status !== 'pass') console.log(` Fix: ${r.fix}`);
|
|
857
|
+
}
|
|
713
858
|
console.log();
|
|
714
859
|
}
|
|
715
|
-
console.log(` ${pass} passed, ${warn} warning(s), ${fail} failed.`);
|
|
860
|
+
console.log(` ${pass} passed, ${warn} warning(s), ${fail} failed${off > 0 ? `, ${off} silenced` : ''}.`);
|
|
716
861
|
if (failing) {
|
|
717
862
|
const reason = fail > 0
|
|
718
|
-
? `${fail}
|
|
863
|
+
? `${fail} check(s) failed. Fix the issue(s) above, or adjust "webjs.doctor.gate" in package.json.`
|
|
719
864
|
: `${warn} warning(s) found and --strict was set.`;
|
|
720
865
|
console.error(`\nwebjs doctor: ${reason}`);
|
|
721
866
|
process.exit(1);
|
|
@@ -799,6 +944,251 @@ async function main() {
|
|
|
799
944
|
}
|
|
800
945
|
break;
|
|
801
946
|
}
|
|
947
|
+
case 'elision': {
|
|
948
|
+
// Report the elision verdict for the app in cwd (#1308): which component
|
|
949
|
+
// modules the browser never downloads, why each one that ships does,
|
|
950
|
+
// which page/layout is inert / import-only / ships whole, and any orphan
|
|
951
|
+
// class that gets no verdict at all. Reuses the ONE analysis pass
|
|
952
|
+
// (`analyzeAppElision`, the same reporting layer `webjs doctor` and
|
|
953
|
+
// the MCP `list_elision` tool call), so `--json` is byte-identical to
|
|
954
|
+
// that tool. Read-only: no autofix, and nothing is written to disk.
|
|
955
|
+
const appDir = process.cwd();
|
|
956
|
+
|
|
957
|
+
// --verify: the app-level differential. Renders every static page route
|
|
958
|
+
// with elision ON and OFF in this one process and diffs the observable
|
|
959
|
+
// SSR bytes, which is literally the framework's own guard
|
|
960
|
+
// (`packages/server/test/elision/differential-elision.test.js`) pointed
|
|
961
|
+
// at an arbitrary app's route table.
|
|
962
|
+
if (rest.includes('--verify')) {
|
|
963
|
+
const { createRequestHandler, buildRouteTable, maskJsSet, staticPageRoutes } =
|
|
964
|
+
await import('@webjsdev/server');
|
|
965
|
+
|
|
966
|
+
const extra = parseRoutesFlag(rest);
|
|
967
|
+
let table;
|
|
968
|
+
try {
|
|
969
|
+
table = await buildRouteTable(appDir);
|
|
970
|
+
} catch (err) {
|
|
971
|
+
console.error(`webjs elision --verify: cannot read the route table here (${err && err.message}).`);
|
|
972
|
+
process.exit(1);
|
|
973
|
+
}
|
|
974
|
+
const staticRoutes = staticPageRoutes(table);
|
|
975
|
+
const dynamic = (table.pages || [])
|
|
976
|
+
.filter((r) => r.paramNames && r.paramNames.length)
|
|
977
|
+
.map((r) => (r.routeDir && r.routeDir !== '.' ? '/' + r.routeDir : '/'))
|
|
978
|
+
.sort();
|
|
979
|
+
// An extra route the author named explicitly is REQUIRED to render; a
|
|
980
|
+
// static one that does not is merely reported (a page may legitimately
|
|
981
|
+
// throw notFound() for every visitor).
|
|
982
|
+
const required = new Set(extra);
|
|
983
|
+
const routes = [...new Set([...staticRoutes, ...extra])];
|
|
984
|
+
|
|
985
|
+
// The app's access log would drown the verdict, and a verification
|
|
986
|
+
// run is not a server: keep errors (a real boot failure must surface)
|
|
987
|
+
// and drop the per-request info lines.
|
|
988
|
+
const quiet = { info: () => {}, warn: () => {}, debug: () => {}, error: (...a) => console.error(...a) };
|
|
989
|
+
|
|
990
|
+
const capture = async (h, r) => {
|
|
991
|
+
const resp = await h.handle(new Request('http://localhost' + r));
|
|
992
|
+
return { status: resp.status, html: await resp.text() };
|
|
993
|
+
};
|
|
994
|
+
// The module URLs a response preloads, with the content-hash query
|
|
995
|
+
// stripped. Comparing the two sides' sets is how the run reports what
|
|
996
|
+
// elision actually DROPPED, which is the only thing that distinguishes
|
|
997
|
+
// a real pass from two identical renders.
|
|
998
|
+
const preloadSet = (html) => new Set(
|
|
999
|
+
[...html.matchAll(/<link rel="modulepreload" href="([^"]+)"/g)].map((m) => m[1].split('?')[0]),
|
|
1000
|
+
);
|
|
1001
|
+
|
|
1002
|
+
const ORIG = process.env.WEBJS_ELIDE;
|
|
1003
|
+
/** @type {Record<string, {status:number, html:string}>} */
|
|
1004
|
+
const onA = {}, onB = {}, off = {};
|
|
1005
|
+
try {
|
|
1006
|
+
// Elision ON, FORCED. Deleting the override would only fall back to
|
|
1007
|
+
// `webjs.elide`, so on an app that opts out this side would run with
|
|
1008
|
+
// elision OFF too and the command would compare two identical renders
|
|
1009
|
+
// and report them "identical with elision on vs off", which is false
|
|
1010
|
+
// about a run where elision was never on. The env override wins over
|
|
1011
|
+
// the config key, which is exactly what makes it the right seam here.
|
|
1012
|
+
// Warm fully so the memoized verdict is locked before the env flips
|
|
1013
|
+
// for the second handler.
|
|
1014
|
+
process.env.WEBJS_ELIDE = '1';
|
|
1015
|
+
const hOn = await createRequestHandler({ appDir, dev: false, logger: quiet });
|
|
1016
|
+
if (hOn.warmup) await hOn.warmup();
|
|
1017
|
+
for (const r of routes) onA[r] = await capture(hOn, r);
|
|
1018
|
+
// A SECOND capture through the same warm handler. A route whose two
|
|
1019
|
+
// ON captures already differ is nondeterministic (live data, a random
|
|
1020
|
+
// id, a clock the masker does not normalize), so a differential over
|
|
1021
|
+
// it proves nothing and reporting it as a failure would be a red the
|
|
1022
|
+
// author cannot act on. The framework's own test needs no such pass
|
|
1023
|
+
// because its corpus is fixed and known; an arbitrary app's is not.
|
|
1024
|
+
for (const r of routes) onB[r] = await capture(hOn, r);
|
|
1025
|
+
// Elision OFF via the env override; a fresh handler reads it on its
|
|
1026
|
+
// own first warm.
|
|
1027
|
+
process.env.WEBJS_ELIDE = '0';
|
|
1028
|
+
const hOff = await createRequestHandler({ appDir, dev: false, logger: quiet });
|
|
1029
|
+
if (hOff.warmup) await hOff.warmup();
|
|
1030
|
+
for (const r of routes) off[r] = await capture(hOff, r);
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
console.error(`webjs elision --verify: could not boot the app (${err && err.message}).`);
|
|
1033
|
+
process.exit(1);
|
|
1034
|
+
} finally {
|
|
1035
|
+
if (ORIG === undefined) delete process.env.WEBJS_ELIDE;
|
|
1036
|
+
else process.env.WEBJS_ELIDE = ORIG;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const unrenderable = [], nondeterministic = [], diverged = [];
|
|
1040
|
+
/** @type {Set<string>} modules the OFF side preloads and the ON side does not */
|
|
1041
|
+
const dropped = new Set();
|
|
1042
|
+
let compared = 0;
|
|
1043
|
+
for (const r of routes) {
|
|
1044
|
+
if (onA[r].status >= 400) { unrenderable.push(`${r} (${onA[r].status})`); continue; }
|
|
1045
|
+
if (maskJsSet(onA[r].html) !== maskJsSet(onB[r].html)) { nondeterministic.push(r); continue; }
|
|
1046
|
+
compared++;
|
|
1047
|
+
// What elision removed on this route. A pass over a corpus where this
|
|
1048
|
+
// stays empty is TRUE but trivially so, and the author needs to see
|
|
1049
|
+
// that rather than read it as proof elision was exercised.
|
|
1050
|
+
const onSet = preloadSet(onA[r].html);
|
|
1051
|
+
for (const u of preloadSet(off[r].html)) if (!onSet.has(u)) dropped.add(u);
|
|
1052
|
+
const a = maskJsSet(onA[r].html);
|
|
1053
|
+
const b = maskJsSet(off[r].html);
|
|
1054
|
+
if (onA[r].status !== off[r].status) {
|
|
1055
|
+
diverged.push({ route: r, kind: 'status', a: String(onA[r].status), b: String(off[r].status), at: 0 });
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
if (a !== b) {
|
|
1059
|
+
let i = 0;
|
|
1060
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
|
1061
|
+
diverged.push({
|
|
1062
|
+
route: r, kind: 'body', at: i,
|
|
1063
|
+
a: a.slice(Math.max(0, i - 40), i + 40),
|
|
1064
|
+
b: b.slice(Math.max(0, i - 40), i + 40),
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
for (const d of diverged) {
|
|
1070
|
+
console.error(
|
|
1071
|
+
d.kind === 'status'
|
|
1072
|
+
? `FAIL ${d.route}: status differs with elision on vs off (on=${d.a}, off=${d.b})`
|
|
1073
|
+
: `FAIL ${d.route}: elision changed observable output near offset ${d.at}\n` +
|
|
1074
|
+
` ON : ...${JSON.stringify(d.a)}\n OFF: ...${JSON.stringify(d.b)}`,
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
const badRequired = unrenderable.filter((u) => required.has(u.split(' ')[0]));
|
|
1078
|
+
for (const u of badRequired) console.error(`FAIL ${u}: a --routes path must render`);
|
|
1079
|
+
|
|
1080
|
+
const skips = [
|
|
1081
|
+
dynamic.length ? `${dynamic.length} skipped (dynamic: ${dynamic.join(', ')})` : '0 skipped (dynamic)',
|
|
1082
|
+
`${nondeterministic.length} skipped (nondeterministic${nondeterministic.length ? ': ' + nondeterministic.join(', ') : ''})`,
|
|
1083
|
+
];
|
|
1084
|
+
if (unrenderable.length) skips.push(`${unrenderable.length} skipped (did not render: ${unrenderable.join(', ')})`);
|
|
1085
|
+
|
|
1086
|
+
if (diverged.length || badRequired.length) {
|
|
1087
|
+
console.error(`\nwebjs elision --verify: ${diverged.length} route(s) diverged out of ${compared} compared.`);
|
|
1088
|
+
process.exit(1);
|
|
1089
|
+
}
|
|
1090
|
+
if (compared === 0) {
|
|
1091
|
+
// A vacuous pass is a failure, the same posture scripts/run-bun-tests.js
|
|
1092
|
+
// takes for a run that executed zero tests.
|
|
1093
|
+
console.error(
|
|
1094
|
+
`webjs elision --verify: nothing was compared (${skips.join(', ')}).\n` +
|
|
1095
|
+
'Pass --routes /a,/b to name renderable paths, or add a static page route.',
|
|
1096
|
+
);
|
|
1097
|
+
process.exit(1);
|
|
1098
|
+
}
|
|
1099
|
+
console.log(
|
|
1100
|
+
`webjs elision --verify: ${compared} route(s) identical with elision on vs off, ${skips.join(', ')}.\n` +
|
|
1101
|
+
(dropped.size
|
|
1102
|
+
? `Elision dropped ${dropped.size} module(s) across that corpus, so the comparison was a real one.`
|
|
1103
|
+
: 'Elision dropped NO modules across that corpus: nothing on these routes was elidable, so '
|
|
1104
|
+
+ 'there was nothing for elision to change. The comparison holds, it just had no work to '
|
|
1105
|
+
+ 'do. Run `WEBJS_ELIDE=1 webjs elision` to see why every module here ships (the same '
|
|
1106
|
+
+ 'override this run used, so the verdict matches even in an app that opts out).'),
|
|
1107
|
+
);
|
|
1108
|
+
console.log(VERIFY_CAVEAT);
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const { analyzeAppElision } = await import('@webjsdev/server');
|
|
1113
|
+
const report = await analyzeAppElision(appDir);
|
|
1114
|
+
|
|
1115
|
+
// --json: the machine contract, identical to the MCP `list_elision` shape.
|
|
1116
|
+
if (rest.includes('--json')) {
|
|
1117
|
+
console.log(JSON.stringify(report));
|
|
1118
|
+
break;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
if (!report.analysed) {
|
|
1122
|
+
const why = {
|
|
1123
|
+
'no-app': 'no app/ directory here, so there is nothing to analyse',
|
|
1124
|
+
'elide-off': 'elision is disabled (webjs.elide false, or WEBJS_ELIDE), so every module ships',
|
|
1125
|
+
unanalysable: 'the app could not be analysed (`webjs check` and `webjs dev` name the real problem)',
|
|
1126
|
+
}[report.skipped] || 'nothing was analysed';
|
|
1127
|
+
console.log(`webjs elision: ${why}.`);
|
|
1128
|
+
break;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const s = report.summary;
|
|
1132
|
+
console.log(
|
|
1133
|
+
`webjs elision: ${s.components} component module(s), ${s.elided} elided, ${s.shipped} shipped. ` +
|
|
1134
|
+
`${s.routeModules} route module(s): ${s.inert} inert, ${s.importOnly} import-only, ${s.shippedWhole} ship whole.\n`,
|
|
1135
|
+
);
|
|
1136
|
+
|
|
1137
|
+
const elided = report.components.filter((c) => c.verdict === 'elided');
|
|
1138
|
+
const shipped = report.components.filter((c) => c.verdict === 'shipped');
|
|
1139
|
+
// Column width, capped so one outlier does not push every other row off
|
|
1140
|
+
// the terminal. An over-long cell overflows its own row rather than
|
|
1141
|
+
// widening the table. The FILE column gets the generous cap because a
|
|
1142
|
+
// module path is what a reader scans by; the tag column gets the tight
|
|
1143
|
+
// one because a file registering five tags is the rare case.
|
|
1144
|
+
const pad = (rows, col, cap) => Math.min(cap, Math.max(0, ...rows.map((r) => r[col].length)));
|
|
1145
|
+
const FILE_W = 58, TAG_W = 30;
|
|
1146
|
+
|
|
1147
|
+
if (elided.length) {
|
|
1148
|
+
console.log('Elided components (the browser never downloads these)');
|
|
1149
|
+
const rows = elided.map((c) => [c.file, c.tags.join(', ')]);
|
|
1150
|
+
const w = pad(rows, 0, FILE_W);
|
|
1151
|
+
for (const [file, tags] of rows) console.log(` ${file.padEnd(w)} ${tags}`.trimEnd());
|
|
1152
|
+
console.log();
|
|
1153
|
+
}
|
|
1154
|
+
if (shipped.length) {
|
|
1155
|
+
console.log('Shipped components (and the evidence that forced each one)');
|
|
1156
|
+
const rows = shipped.map((c) => [c.file, c.tags.join(', '), `${c.evidence || 'unknown'}: ${c.reason || 'no evidence recorded'}`]);
|
|
1157
|
+
const w0 = pad(rows, 0, FILE_W), w1 = pad(rows, 1, TAG_W);
|
|
1158
|
+
for (const [file, tags, why] of rows) console.log(` ${file.padEnd(w0)} ${tags.padEnd(w1)} ${why}`.trimEnd());
|
|
1159
|
+
console.log();
|
|
1160
|
+
}
|
|
1161
|
+
if (report.routeModules.length) {
|
|
1162
|
+
console.log('Route modules');
|
|
1163
|
+
const rows = report.routeModules.map((r) => [
|
|
1164
|
+
r.verdict, r.file,
|
|
1165
|
+
r.verdict === 'import-only' ? `emits ${r.emits.join(', ') || '(nothing)'}`
|
|
1166
|
+
: r.verdict === 'shipped' ? (r.blocker ? `blocked by ${r.blocker}, which ${r.reason}` : `it ${r.reason}`)
|
|
1167
|
+
: '',
|
|
1168
|
+
]);
|
|
1169
|
+
const w0 = pad(rows, 0, 12), w1 = pad(rows, 1, FILE_W);
|
|
1170
|
+
for (const [verdict, file, note] of rows) {
|
|
1171
|
+
console.log(` ${verdict.padEnd(w0)} ${file.padEnd(w1)}${note ? ' ' + note : ''}`.trimEnd());
|
|
1172
|
+
}
|
|
1173
|
+
console.log();
|
|
1174
|
+
}
|
|
1175
|
+
if (report.orphans.length) {
|
|
1176
|
+
console.log('Orphan components (no elision verdict; `static interactive = true` cannot rescue these)');
|
|
1177
|
+
for (const o of report.orphans) {
|
|
1178
|
+
console.log(` ${o.className} in ${o.file} is never registered with a literal tag`);
|
|
1179
|
+
}
|
|
1180
|
+
console.log(' Either there is no registration call at all, or the tag is computed. The scanner matches');
|
|
1181
|
+
console.log(' only a literal tag, so either way the module gets no verdict, no registry entry, and no');
|
|
1182
|
+
console.log(' preload hint. With no registration call the element never upgrades at all; with a computed');
|
|
1183
|
+
console.log(' tag it upgrades only while its module still reaches the browser through a shipping importer.');
|
|
1184
|
+
console.log(' Fix: register it with a literal tag, Class.register(\'my-tag\'), or delete the class.');
|
|
1185
|
+
console.log();
|
|
1186
|
+
}
|
|
1187
|
+
if (!report.components.length && !report.routeModules.length) {
|
|
1188
|
+
console.log(' Nothing to report. Add a component or a page under app/.');
|
|
1189
|
+
}
|
|
1190
|
+
break;
|
|
1191
|
+
}
|
|
802
1192
|
case 'types': {
|
|
803
1193
|
// Generate `.webjs/routes.d.ts` from the app's `app/` routes (#258),
|
|
804
1194
|
// narrowing the @webjsdev/core `Route` href union + per-route `params`.
|
|
@@ -850,6 +1240,11 @@ async function main() {
|
|
|
850
1240
|
const name = rest[0];
|
|
851
1241
|
if (!name || name.startsWith('-')) {
|
|
852
1242
|
console.error('Usage: webjs create <app-name> [--template full-stack|api]');
|
|
1243
|
+
// This branch fires for a MISSING name or one starting with `-`, so the
|
|
1244
|
+
// rule it prints has to lead with the first-character requirement. An
|
|
1245
|
+
// earlier wording listed the separators as allowed characters, which
|
|
1246
|
+
// reads as permission to the one user who just typed a leading hyphen.
|
|
1247
|
+
console.error('<app-name> must start with a letter or a digit, then letters, digits, "-", "." or "_".');
|
|
853
1248
|
process.exit(1);
|
|
854
1249
|
}
|
|
855
1250
|
const template = flag(rest, '--template', 'full-stack');
|
|
@@ -878,6 +1273,16 @@ files.
|
|
|
878
1273
|
Full docs: https://webjs.dev/docs`);
|
|
879
1274
|
process.exit(1);
|
|
880
1275
|
}
|
|
1276
|
+
// The name lands in the generated package.json `name` field AND is
|
|
1277
|
+
// interpolated into generated source (#1066), so a quote / backtick /
|
|
1278
|
+
// `${` in it emits a file that fails to parse on the first `webjs dev`.
|
|
1279
|
+
// Validate before anything is written, so a bad name leaves no directory
|
|
1280
|
+
// behind. `scaffoldApp` re-checks for programmatic callers.
|
|
1281
|
+
const nameCheck = checkAppName(name);
|
|
1282
|
+
if (!nameCheck.ok) {
|
|
1283
|
+
console.error(appNameErrorMessage(name, nameCheck.reason));
|
|
1284
|
+
process.exit(1);
|
|
1285
|
+
}
|
|
881
1286
|
const noInstall = rest.includes('--no-install');
|
|
882
1287
|
// --db picks the database dialect: sqlite (default) or postgres.
|
|
883
1288
|
const db = flag(rest, '--db', 'sqlite');
|