@webjsdev/cli 0.10.51 → 0.10.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/webjs.js CHANGED
@@ -49,6 +49,40 @@ if (cmd !== 'help' && cmd !== undefined && !wantsHelp && !wantsVersion) {
49
49
  // .agents/rules/workflow.md / .github/copilot-instructions.md mirror it.
50
50
  const TEMPLATES = ['full-stack', 'api'];
51
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
+
52
86
  const USAGE = `webjs commands:
53
87
  webjs dev [--port 8080] [--no-hot] Start dev server with live reload
54
88
  (--no-hot: run in-process, no hot-reload supervisor)
@@ -56,8 +90,10 @@ const USAGE = `webjs commands:
56
90
  webjs test [--server|--browser] Run server + browser tests
57
91
  webjs check [--json] Run correctness checks (--json emits structured violations)
58
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
59
- webjs mcp Start the read-only MCP server (routes / actions / components / check)
60
- webjs doctor [--json] [--strict] Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision, un-versioned stylesheet links).
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).
61
97
  --json emits the structured results (with stable codes). --strict additionally fails on every remaining warning.
62
98
  Per-check severity is CONFIG: map a code to off/warn/error under "webjs": { "doctor": { "gate": {...} } }
63
99
  in package.json, so CI gates on a chosen subset without every warning becoming fatal
@@ -144,6 +180,24 @@ const HELP = {
144
180
  ],
145
181
  examples: ['webjs routes', 'webjs routes --table', 'webjs routes --table --no-headers', 'webjs routes --json'],
146
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
+ },
147
201
  doctor: {
148
202
  usage: 'webjs doctor [--json] [--strict]',
149
203
  summary: 'Verify project health. Each result carries a stable code so an agent branches on the failure kind.',
@@ -215,7 +269,7 @@ const HELP = {
215
269
  },
216
270
  mcp: {
217
271
  usage: 'webjs mcp',
218
- 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).',
219
273
  examples: ['webjs mcp'],
220
274
  },
221
275
  version: {
@@ -255,9 +309,12 @@ function printCommandHelp(name) {
255
309
  console.log('Options:');
256
310
  for (const o of options) console.log(` ${o.flag.padEnd(width)} ${o.description}`);
257
311
  // Optional per-command prose for surface a flag table cannot carry (doctor's
258
- // package.json severity gate is the one that needs it).
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).
259
316
  if (h.notes) {
260
- console.log('\nConfig:');
317
+ console.log(`\n${h.notesTitle || 'Config'}:`);
261
318
  for (const line of h.notes) console.log(` ${line}`);
262
319
  }
263
320
  console.log('\nExamples:');
@@ -887,6 +944,251 @@ async function main() {
887
944
  }
888
945
  break;
889
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
+ }
890
1192
  case 'types': {
891
1193
  // Generate `.webjs/routes.d.ts` from the app's `app/` routes (#258),
892
1194
  // narrowing the @webjsdev/core `Route` href union + per-route `params`.
package/lib/app-name.js CHANGED
@@ -206,3 +206,76 @@ export function assertValidAppName(name) {
206
206
  }
207
207
  return /** @type {string} */ (name);
208
208
  }
209
+
210
+ /**
211
+ * PostgreSQL's hard cap on an identifier, `NAMEDATALEN - 1` bytes. An
212
+ * over-length `CREATE DATABASE` name is silently truncated to this with only a
213
+ * NOTICE, which would reproduce the same name mismatch in a new guise, so the
214
+ * derivation caps it here instead.
215
+ */
216
+ export const DB_NAME_MAX_LENGTH = 63;
217
+
218
+ /**
219
+ * Derive the PostgreSQL database name the scaffold writes into the generated
220
+ * `.env.example` `DATABASE_URL`. The APP NAME itself is never touched by this:
221
+ * the directory, the `package.json` `name`, the `{{APP_NAME}}` substitution and
222
+ * `metadata.title` all keep the name exactly as typed. Only the database
223
+ * segment of that one URL is normalized.
224
+ *
225
+ * The point is a QUOTING-INVARIANT name. A result in `[a-z_][a-z0-9_]*` under
226
+ * 63 bytes folds to itself under `CREATE DATABASE <name>;` AND is passed
227
+ * through unquoted by `createdb` (which builds its statement through `fmtId`),
228
+ * so the emitted URL names the same database whichever route the user takes.
229
+ * A case-preserving name does not have that property: `CREATE DATABASE MyApp;`
230
+ * creates `myapp` while `createdb MyApp` creates `MyApp`.
231
+ *
232
+ * One qualification on that property. A name that folds to a PostgreSQL
233
+ * KEYWORD (`order`, `user`, `table`, `group`, `check`, `window`, `limit`) is
234
+ * still not quoting-invariant: `CREATE DATABASE order;` is a syntax error
235
+ * rather than a fold, while `createdb order` succeeds because `fmtId` quotes a
236
+ * keyword. That is deliberately not detected here. The keyword list is
237
+ * version-dependent and roughly 470 entries, which is disproportionate in a
238
+ * helper whose whole point is being pure and dependency-free, and the failure
239
+ * is a loud syntax error on a placeholder line the user is editing anyway,
240
+ * not the silent wrong-database mismatch this function exists to remove.
241
+ *
242
+ * Three sub-rules, in this order, and the order is load-bearing:
243
+ *
244
+ * 1. Fold with `toLowerCase()`, NOT `toLocaleLowerCase()`. The latter is
245
+ * locale-dependent, so a Turkish-locale machine would fold `I` to the
246
+ * dotless `ı`, which the class below then turns into `_`, making the
247
+ * generated file machine-dependent.
248
+ * 2. Map every remaining character outside `[a-z0-9_]` to `_`, one for one.
249
+ * No run-collapsing, no trimming: both are legal identifier characters,
250
+ * and a 1:1 fold is one a reader can apply by eye. `checkAppName` already
251
+ * restricts the input to `[A-Za-z0-9._-]`, so in practice this only ever
252
+ * rewrites `.` and `-`.
253
+ * 3. Prefix a single `_` when the first character is a digit, BEFORE the
254
+ * slice so the cap governs the final string. `ALLOWED_FIRST_CHAR` admits
255
+ * a leading digit, and an unquoted PostgreSQL identifier may not start
256
+ * with one.
257
+ *
258
+ * Slicing LAST is what makes the byte cap correct: after the fold every
259
+ * surviving character is one ASCII byte, so a code-unit slice is a byte slice,
260
+ * and there is no surrogate pair or percent-escape for it to bisect (every
261
+ * character is unreserved under RFC 3986, so the segment needs no encoding).
262
+ *
263
+ * Precondition: `name` has passed `checkAppName`. `scaffoldApp` asserts that
264
+ * before any file is written. That is what guarantees a non-empty result (a
265
+ * validated name's first character is `[A-Za-z0-9]`, which folds into the
266
+ * class and survives), so there is no empty-result fallback branch here.
267
+ *
268
+ * Collisions are accepted and NOT detected. `My-App` and `my_app` both fold to
269
+ * `my_app`. This is a placeholder in `.env.example`, not a provisioned
270
+ * resource: the scaffold contacts no server and cannot know what exists, and a
271
+ * uniquifying suffix would make the name untraceable to the app name. Rails
272
+ * takes the same position in `railties/lib/rails/generators/app_name.rb`.
273
+ *
274
+ * @param {string} name an app name that has passed `checkAppName`
275
+ * @returns {string} a fold-stable, unquoted-safe PostgreSQL database name
276
+ */
277
+ export function toDatabaseName(name) {
278
+ const folded = name.toLowerCase().replace(/[^a-z0-9_]/g, '_');
279
+ const prefixed = /^[0-9]/.test(folded) ? `_${folded}` : folded;
280
+ return prefixed.slice(0, DB_NAME_MAX_LENGTH);
281
+ }
package/lib/create.js CHANGED
@@ -18,7 +18,7 @@ import { existsSync } from 'node:fs';
18
18
  import { createRequire } from 'node:module';
19
19
  import { spawnSync } from 'node:child_process';
20
20
  import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi } from './runtime-rewrite.js';
21
- import { assertValidAppName } from './app-name.js';
21
+ import { assertValidAppName, toDatabaseName } from './app-name.js';
22
22
 
23
23
  /**
24
24
  * Detect which package manager invoked us. Reads `npm_config_user_agent`,
@@ -541,6 +541,11 @@ export async function scaffoldApp(name, cwd, opts = {}) {
541
541
  { name: '@webjsdev/intellisense' },
542
542
  ],
543
543
  },
544
+ // `test/**/*` is in so `webjs typecheck` reads the tests you write, the
545
+ // same way Next / Remix / Astro's generated configs do (#1299). A type
546
+ // error in a test is then a gate failure rather than something a reviewer
547
+ // has to catch by eye, and it needs no second config to remember to run.
548
+ //
544
549
  // `.webjs/routes.d.ts` is the OPT-IN generated route-types overlay (#258):
545
550
  // run `webjs types` (or `webjs dev`, which emits it) to narrow the
546
551
  // @webjsdev/core `Route` href union + per-route `params`. Listed in
@@ -552,6 +557,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
552
557
  'components/**/*',
553
558
  'modules/**/*',
554
559
  'lib/**/*',
560
+ 'test/**/*',
555
561
  'middleware.js',
556
562
  'middleware.ts',
557
563
  '.webjs/routes.d.ts',
@@ -877,9 +883,14 @@ export default defineConfig({
877
883
  `);
878
884
 
879
885
  // Env vars: append DATABASE_URL to the .env.example the template already
880
- // copied (if present), idempotently.
886
+ // copied (if present), idempotently. The database segment is the app name
887
+ // normalized to a fold-stable PostgreSQL identifier, so the emitted URL
888
+ // names the same database whether the user runs `createdb` or types
889
+ // `CREATE DATABASE`. Hoisted because the post-scaffold guidance below names
890
+ // the same value, and the two must not be able to drift.
891
+ const dbName = toDatabaseName(name);
881
892
  const dbUrlLine = dialect === 'postgres'
882
- ? 'DATABASE_URL=postgres://user:password@localhost:5432/' + name.replace(/[^a-z0-9_]/gi, '_')
893
+ ? 'DATABASE_URL=postgres://user:password@localhost:5432/' + dbName
883
894
  : 'DATABASE_URL=file:./db/dev.db';
884
895
  const envExample = join(appDir, '.env.example');
885
896
  if (existsSync(envExample)) {
@@ -1596,7 +1607,7 @@ ThemeToggle.register('theme-toggle');
1596
1607
  // local file with no .env). Point it at a running database; `dev` / `start`
1597
1608
  // then apply pending migrations via webjs.*.before.
1598
1609
  const pgNote = dialect === 'postgres'
1599
- ? `\nPostgres: copy .env.example to .env and set DATABASE_URL to a running database before \`${pm} run dev\`.\n`
1610
+ ? `\nPostgres: copy .env.example to .env and set DATABASE_URL to a running database before \`${pm} run dev\`.\nThe example URL names the database \`${dbName}\`. Create that database or edit the URL.\n`
1600
1611
  : '';
1601
1612
  // Use `npx webjsdev ui ...` here, not `npx webjs ui ...`. The bare
1602
1613
  // `webjs` npm name is owned by an unrelated package; `npx webjs