@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.
Files changed (51) hide show
  1. package/README.md +3 -1
  2. package/bin/webjs.js +437 -32
  3. package/lib/api-gallery.js +6 -7
  4. package/lib/app-name.js +208 -0
  5. package/lib/create.js +37 -9
  6. package/lib/doctor.js +566 -21
  7. package/package.json +2 -2
  8. package/templates/.agents/rules/workflow.md +9 -1
  9. package/templates/.agents/skills/webjs/SKILL.md +26 -11
  10. package/templates/.agents/skills/webjs/references/auth-and-sessions.md +2 -2
  11. package/templates/.agents/skills/webjs/references/built-ins.md +26 -7
  12. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +16 -2
  13. package/templates/.agents/skills/webjs/references/components.md +59 -2
  14. package/templates/.agents/skills/webjs/references/data-and-actions.md +92 -7
  15. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +75 -19
  16. package/templates/.agents/skills/webjs/references/optimistic-ui.md +35 -14
  17. package/templates/.agents/skills/webjs/references/routing-and-pages.md +34 -15
  18. package/templates/.agents/skills/webjs/references/runtime.md +5 -1
  19. package/templates/.agents/skills/webjs/references/styling.md +1 -1
  20. package/templates/.agents/skills/webjs/references/testing.md +80 -3
  21. package/templates/.agents/skills/webjs/references/typescript.md +71 -2
  22. package/templates/.agents/skills/webjs/references/ui-kit.md +5 -2
  23. package/templates/.github/pull_request_template.md +1 -0
  24. package/templates/.github/workflows/ci.yml +13 -0
  25. package/templates/AGENTS.md +31 -5
  26. package/templates/CONVENTIONS.md +4 -1
  27. package/templates/gallery/app/examples/layout.ts +2 -1
  28. package/templates/gallery/app/examples/todo/page.ts +3 -16
  29. package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
  30. package/templates/gallery/app/features/auth/signup/page.ts +4 -23
  31. package/templates/gallery/app/features/caching/page.ts +6 -6
  32. package/templates/gallery/app/features/file-storage/page.ts +8 -19
  33. package/templates/gallery/app/features/forms/page.ts +12 -38
  34. package/templates/gallery/app/features/layout.ts +6 -2
  35. package/templates/gallery/app/features/route-handler/data/route.ts +2 -1
  36. package/templates/gallery/app/features/view-transitions/page.ts +1 -1
  37. package/templates/gallery/app/global-error.ts +7 -4
  38. package/templates/gallery/modules/async-render/components/server-clock.ts +3 -2
  39. package/templates/gallery/modules/auth/actions/signup.server.ts +27 -11
  40. package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +21 -10
  41. package/templates/gallery/modules/forms/actions/send-message.server.ts +34 -0
  42. package/templates/gallery/modules/gallery/nav.ts +1 -1
  43. package/templates/gallery/modules/server-actions/actions/greet.test.ts +6 -5
  44. package/templates/gallery/modules/todo/actions/submit-todo.server.ts +33 -0
  45. package/templates/gallery/modules/todo/components/todo-app.ts +8 -5
  46. package/templates/gallery/modules/todo/types.ts +15 -10
  47. package/templates/gallery/test/auth/auth.test.ts +31 -16
  48. package/templates/partials/agents-playbook-api.md +5 -0
  49. package/templates/partials/agents-playbook-fullstack.md +5 -0
  50. package/templates/scripts/clear-gallery.mjs +5 -4
  51. 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 (local onboarding, not CI)
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 mcp Start the read-only MCP server (routes / actions / components / check)
59
- webjs doctor [--json] [--strict] Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision).
60
- --json emits the structured results (with stable codes). --strict also fails the exit on warnings
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 warnings, not just hard failures.' },
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, or a build/type-strip failure. They always');
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: non-zero iff any
675
- // HARD check FAILS, so CI can gate on it. Warns are informational and do
676
- // NOT fail the exit (env drift / pin staleness / version drift are the
677
- // app's concern, not a broken toolchain).
678
- const { runDoctorChecks } = await import('../lib/doctor.js');
679
- const results = await runDoctorChecks(process.cwd());
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.status] = (acc[r.status] || 0) + 1;
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.fail || 0;
687
- // `--strict` also fails the exit on warnings, so an agent can gate on a
688
- // fully-clean toolchain (drift / staleness / pin freshness) in a fix loop,
689
- // not just on a hard toolchain break. Default keeps warnings non-fatal.
690
- const strict = rest.includes('--strict');
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`) plus
694
- // a summary, so an agent consumes structured data instead of scraping the
695
- // text. Shape mirrors `check --json`: a top-level array-bearing object
696
- // with a `summary` count. The non-zero exit is preserved (an agent gates
697
- // on the exit code AND parses the report).
698
- if (rest.includes('--json')) {
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]', fail: '[fail]' };
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
- console.log(` ${marker[r.status]} ${r.name} (${r.code})`);
711
- console.log(` ${r.message}`);
712
- if (r.fix && r.status !== 'pass') console.log(` Fix: ${r.fix}`);
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} hard check(s) failed. Fix the toolchain issue(s) above.`
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');