@webjsdev/cli 0.10.50 → 0.10.51

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 (50) hide show
  1. package/README.md +3 -1
  2. package/bin/webjs.js +133 -30
  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 +479 -7
  7. package/package.json +2 -2
  8. package/templates/.agents/rules/workflow.md +9 -1
  9. package/templates/.agents/skills/webjs/SKILL.md +25 -11
  10. package/templates/.agents/skills/webjs/references/auth-and-sessions.md +2 -2
  11. package/templates/.agents/skills/webjs/references/built-ins.md +25 -6
  12. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +6 -2
  13. package/templates/.agents/skills/webjs/references/components.md +9 -1
  14. package/templates/.agents/skills/webjs/references/data-and-actions.md +40 -7
  15. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +54 -18
  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 +61 -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/auth/actions/signup.server.ts +27 -11
  39. package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +21 -10
  40. package/templates/gallery/modules/forms/actions/send-message.server.ts +34 -0
  41. package/templates/gallery/modules/gallery/nav.ts +1 -1
  42. package/templates/gallery/modules/server-actions/actions/greet.test.ts +6 -5
  43. package/templates/gallery/modules/todo/actions/submit-todo.server.ts +31 -0
  44. package/templates/gallery/modules/todo/components/todo-app.ts +8 -5
  45. package/templates/gallery/modules/todo/types.ts +15 -10
  46. package/templates/gallery/test/auth/auth.test.ts +31 -16
  47. package/templates/partials/agents-playbook-api.md +5 -0
  48. package/templates/partials/agents-playbook-fullstack.md +5 -0
  49. package/templates/scripts/clear-gallery.mjs +5 -4
  50. 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);
@@ -56,11 +57,14 @@ const USAGE = `webjs commands:
56
57
  webjs check [--json] Run correctness checks (--json emits structured violations)
57
58
  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
59
  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
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).
61
+ --json emits the structured results (with stable codes). --strict additionally fails on every remaining warning.
62
+ Per-check severity is CONFIG: map a code to off/warn/error under "webjs": { "doctor": { "gate": {...} } }
63
+ in package.json, so CI gates on a chosen subset without every warning becoming fatal
61
64
  webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
62
65
  webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors)
63
66
  webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install] Scaffold a new webjs app
67
+ <name> must be a valid package name (letters, digits, - . _, starts with a letter or digit)
64
68
  (only 2 templates exist. default: full-stack, Drizzle, --db sqlite, --runtime node)
65
69
  --runtime bun emits a Bun-flavored app (bun.lock, bun Dockerfile/CI, bun docs);
66
70
  also auto-detected when run via "bun create webjs".
@@ -144,8 +148,17 @@ const HELP = {
144
148
  usage: 'webjs doctor [--json] [--strict]',
145
149
  summary: 'Verify project health. Each result carries a stable code so an agent branches on the failure kind.',
146
150
  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.' },
151
+ { flag: '--json', description: 'Emit the DoctorResult[] (with stable codes + severities) + a summary as JSON.' },
152
+ { flag: '--strict', description: 'Also fail the exit on EVERY remaining warning, not just hard failures and gated errors.' },
153
+ ],
154
+ notes: [
155
+ 'Per-check severity is CONFIG, not a flag. Declare it in package.json under',
156
+ '"webjs": { "doctor": { "gate": { "<CODE>": "off" | "warn" | "error" } } }, so CI',
157
+ 'gates on a chosen subset without --strict making every warning fatal. A malformed',
158
+ 'gate exits 1 naming it (an unknown code, a bad severity, a non-object doctor/gate,',
159
+ 'or a misspelled sibling like "gates"); under --json those come back as a',
160
+ 'configErrors array with results empty. A "could not check" result (a network or',
161
+ 'toolchain outage) is capped at warn and can never be escalated to error.',
149
162
  ],
150
163
  examples: ['webjs doctor', 'webjs doctor --json', 'webjs doctor --strict', 'webjs doctor --json --strict'],
151
164
  },
@@ -163,6 +176,12 @@ const HELP = {
163
176
  usage: 'webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install]',
164
177
  summary: 'Scaffold a new app. Defaults: full-stack template, Drizzle + SQLite, Node runtime.',
165
178
  options: [
179
+ // Kept to one terminal line like every other row: printHelp does not
180
+ // wrap, so a long description renders as one 300-column line.
181
+ {
182
+ flag: '<name>',
183
+ description: 'Package name: letters, digits, - . _ , starts with a letter or digit.',
184
+ },
166
185
  { flag: '--template <t>', description: 'full-stack (default) or api (backend-only, no UI).' },
167
186
  { flag: '--db <d>', description: 'sqlite (default) or postgres.' },
168
187
  { flag: '--runtime <r>', description: 'node (default) or bun.' },
@@ -235,6 +254,12 @@ function printCommandHelp(name) {
235
254
  const width = Math.max(...options.map((o) => o.flag.length));
236
255
  console.log('Options:');
237
256
  for (const o of options) console.log(` ${o.flag.padEnd(width)} ${o.description}`);
257
+ // Optional per-command prose for surface a flag table cannot carry (doctor's
258
+ // package.json severity gate is the one that needs it).
259
+ if (h.notes) {
260
+ console.log('\nConfig:');
261
+ for (const line of h.notes) console.log(` ${line}`);
262
+ }
238
263
  console.log('\nExamples:');
239
264
  for (const ex of h.examples) console.log(` ${ex}`);
240
265
  return true;
@@ -630,7 +655,8 @@ async function main() {
630
655
  if (rest.includes('--rules')) {
631
656
  console.log('webjs check, correctness rules:');
632
657
  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');
658
+ console.log(' security leak, a reactive prop that silently stops');
659
+ console.log(' re-rendering, or a build/type-strip failure. They always');
634
660
  console.log(' run. Project conventions (layout, style, process) are');
635
661
  console.log(' guidance in CONVENTIONS.md, not rules here.\n');
636
662
  for (const r of RULES) {
@@ -671,51 +697,113 @@ async function main() {
671
697
  }
672
698
  case 'doctor': {
673
699
  // 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());
700
+ // this branch only renders them and owns the exit code. The exit is
701
+ // non-zero when a HARD check FAILS or when a check the app gated `error`
702
+ // reports something; an UNGATED warn stays informational (env drift / pin
703
+ // staleness / version drift are the app's concern, not a broken
704
+ // toolchain). An app declares per-check severity
705
+ // in its package.json `webjs.doctor.gate` (#1257), which is what lets CI
706
+ // gate on a chosen subset without `--strict` making every warning fatal.
707
+ const { runDoctorChecks, readDoctorPolicy, applyDoctorPolicy, DOCTOR_CODES, DOCTOR_SEVERITIES } =
708
+ await import('../lib/doctor.js');
709
+ const appDir = process.cwd();
710
+ const strict = rest.includes('--strict');
711
+ const asJson = rest.includes('--json');
712
+
713
+ // Read the policy FIRST. A wrong shape, a key that is not a known code, or
714
+ // a value that is not a severity exits 1 without running the checks: a
715
+ // typo silently ignored would leave CI un-gated while looking gated,
716
+ // which is the worst failure a mechanism like this can have.
717
+ const policy = readDoctorPolicy(appDir);
718
+ const configErrors = [
719
+ ...policy.malformed.map(({ path, value }) => ({ kind: 'malformed', path, value })),
720
+ ...policy.unknownKeys.map((path) => ({ kind: 'unknown-key', path })),
721
+ ...policy.unknownCodes.map((code) => ({ kind: 'unknown-code', code })),
722
+ ...policy.badSeverities.map(({ code, value }) => ({ kind: 'bad-severity', code, value })),
723
+ ];
724
+ if (configErrors.length > 0) {
725
+ if (asJson) {
726
+ console.log(JSON.stringify({
727
+ results: [],
728
+ summary: { pass: 0, warn: 0, fail: 0, off: 0, strict, ok: false },
729
+ configErrors,
730
+ }));
731
+ process.exit(1);
732
+ }
733
+ // Header names the BLOCK, not `gate`: two of the four error kinds are
734
+ // about `webjs.doctor` itself or a misspelled sibling, so naming `gate`
735
+ // would point at a key the package.json may not even contain.
736
+ console.error('webjs doctor: invalid "webjs.doctor" config in package.json\n');
737
+ for (const e of configErrors) {
738
+ if (e.kind === 'malformed') console.error(` Expected an object at ${e.path}, got ${JSON.stringify(e.value)}`);
739
+ else if (e.kind === 'unknown-key') console.error(` Unknown config key: ${e.path} (the only key is "gate")`);
740
+ else if (e.kind === 'unknown-code') console.error(` Unknown check code: ${e.code}`);
741
+ else console.error(` Invalid severity for ${e.code}: ${JSON.stringify(e.value)}`);
742
+ }
743
+ console.error('\n Shape: "webjs": { "doctor": { "gate": { "<CODE>": "<severity>" } } }');
744
+ console.error(` Valid severities: ${DOCTOR_SEVERITIES.join(' / ')}`);
745
+ console.error(` Valid codes: ${Object.values(DOCTOR_CODES).join(', ')}`);
746
+ process.exit(1);
747
+ }
748
+
749
+ const results = applyDoctorPolicy(await runDoctorChecks(appDir), policy.gate);
750
+ // Counts come off the EFFECTIVE severity, not the raw status, so a gated
751
+ // code lands in the bucket the app asked for. With no gate the two are
752
+ // identical (a `fail` defaults to `error`, a `warn` to `warn`), which is
753
+ // what keeps an un-configured app byte-identical to before.
680
754
  const counts = results.reduce((acc, r) => {
681
- acc[r.status] = (acc[r.status] || 0) + 1;
755
+ acc[r.severity] = (acc[r.severity] || 0) + 1;
682
756
  return acc;
683
757
  }, /** @type {Record<string, number>} */ ({}));
684
758
  const pass = counts.pass || 0;
685
759
  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');
760
+ const fail = counts.error || 0;
761
+ const off = counts.off || 0;
762
+ // `--strict` additionally fails the exit on every REMAINING warning, so an
763
+ // agent can gate on a fully-clean toolchain (drift / staleness / pin freshness) in a fix loop,
764
+ // not just on a hard toolchain break. Without it, a warn is fatal only
765
+ // where the app gated its code `error`, which folded into `fail` above.
691
766
  const failing = fail > 0 || (strict && warn > 0);
692
767
 
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')) {
768
+ // --json emits the raw DoctorResult[] (each carries a stable `code` and
769
+ // its effective `severity`) plus a summary, so an agent consumes
770
+ // structured data instead of scraping the text. Shape mirrors `check
771
+ // --json`: a top-level array-bearing object with a `summary` count. The
772
+ // non-zero exit is preserved (an agent gates on the exit code AND parses
773
+ // the report).
774
+ if (asJson) {
699
775
  console.log(JSON.stringify({
700
776
  results,
701
- summary: { pass, warn, fail, strict, ok: !failing },
777
+ summary: { pass, warn, fail, off, strict, ok: !failing },
702
778
  }));
703
779
  if (failing) process.exit(1);
704
780
  break;
705
781
  }
706
782
 
707
- const marker = { pass: '[pass]', warn: '[warn]', fail: '[fail]' };
783
+ const marker = { pass: '[pass]', off: '[off]', warn: '[warn]', error: '[fail]' };
708
784
  console.log('webjs doctor: project-health checklist\n');
709
785
  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}`);
786
+ // Name the gate whenever it moved a result off its default, so the
787
+ // reason a warning is fatal (or silenced) is on the line itself.
788
+ const dflt = r.status === 'fail' ? 'error' : 'warn';
789
+ const gated = r.status !== 'pass' && r.severity !== dflt ? `, gated: ${r.severity}` : '';
790
+ console.log(` ${marker[r.severity]} ${r.name} (${r.code}${gated})`);
791
+ // A silenced check reports NOTHING beyond its name: an app that gated a
792
+ // code `off` asked not to hear about it, and printing the finding plus
793
+ // a Fix line every run is exactly the noise it turned off (ESLint's
794
+ // `off` drops the message too). The checklist still lists it as [off]
795
+ // and the summary still counts it, so a silenced check is never
796
+ // invisible, and `--json` keeps the whole result for tooling.
797
+ if (r.severity !== 'off') {
798
+ console.log(` ${r.message}`);
799
+ if (r.fix && r.status !== 'pass') console.log(` Fix: ${r.fix}`);
800
+ }
713
801
  console.log();
714
802
  }
715
- console.log(` ${pass} passed, ${warn} warning(s), ${fail} failed.`);
803
+ console.log(` ${pass} passed, ${warn} warning(s), ${fail} failed${off > 0 ? `, ${off} silenced` : ''}.`);
716
804
  if (failing) {
717
805
  const reason = fail > 0
718
- ? `${fail} hard check(s) failed. Fix the toolchain issue(s) above.`
806
+ ? `${fail} check(s) failed. Fix the issue(s) above, or adjust "webjs.doctor.gate" in package.json.`
719
807
  : `${warn} warning(s) found and --strict was set.`;
720
808
  console.error(`\nwebjs doctor: ${reason}`);
721
809
  process.exit(1);
@@ -850,6 +938,11 @@ async function main() {
850
938
  const name = rest[0];
851
939
  if (!name || name.startsWith('-')) {
852
940
  console.error('Usage: webjs create <app-name> [--template full-stack|api]');
941
+ // This branch fires for a MISSING name or one starting with `-`, so the
942
+ // rule it prints has to lead with the first-character requirement. An
943
+ // earlier wording listed the separators as allowed characters, which
944
+ // reads as permission to the one user who just typed a leading hyphen.
945
+ console.error('<app-name> must start with a letter or a digit, then letters, digits, "-", "." or "_".');
853
946
  process.exit(1);
854
947
  }
855
948
  const template = flag(rest, '--template', 'full-stack');
@@ -878,6 +971,16 @@ files.
878
971
  Full docs: https://webjs.dev/docs`);
879
972
  process.exit(1);
880
973
  }
974
+ // The name lands in the generated package.json `name` field AND is
975
+ // interpolated into generated source (#1066), so a quote / backtick /
976
+ // `${` in it emits a file that fails to parse on the first `webjs dev`.
977
+ // Validate before anything is written, so a bad name leaves no directory
978
+ // behind. `scaffoldApp` re-checks for programmatic callers.
979
+ const nameCheck = checkAppName(name);
980
+ if (!nameCheck.ok) {
981
+ console.error(appNameErrorMessage(name, nameCheck.reason));
982
+ process.exit(1);
983
+ }
881
984
  const noInstall = rest.includes('--no-install');
882
985
  // --db picks the database dialect: sqlite (default) or postgres.
883
986
  const db = flag(rest, '--db', 'sqlite');
@@ -13,8 +13,7 @@ import { join } from 'node:path';
13
13
  /**
14
14
  * Write the api backend-features gallery into `<appDir>/app/api/features/**`
15
15
  * plus a boot-time env-validation example at `app/env.ts`. Each demo carries a
16
- * `webjs-scaffold-placeholder` marker so `webjs check` fails until it is
17
- * pruned or adapted.
16
+ * comment naming exactly what to delete when it is pruned.
18
17
  * @param {string} appDir
19
18
  */
20
19
  export async function writeApiGallery(appDir) {
@@ -35,7 +34,7 @@ export async function writeApiGallery(appDir) {
35
34
  ].join('\n'));
36
35
  await mkdir(feat('validate'), { recursive: true });
37
36
  await writeFile(feat('validate', 'route.ts'), [
38
- "// webjs-scaffold-placeholder. API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/validate route AND modules/widgets), then delete this marker line. webjs check fails while the marker remains.",
37
+ "// API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/validate route AND modules/widgets).",
39
38
  "// route() turns a 'use server' action into a REST endpoint: it merges the URL",
40
39
  "// query, route params, and JSON body into one input, runs `validate` at the",
41
40
  "// boundary (a { success:false, fieldErrors } return is a 422, no action call),",
@@ -76,7 +75,7 @@ export async function writeApiGallery(appDir) {
76
75
  "",
77
76
  ].join('\n'));
78
77
  await writeFile(feat('rate-limit', 'route.ts'), [
79
- "// webjs-scaffold-placeholder. API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/rate-limit route), then delete this marker line. webjs check fails while the marker remains.",
78
+ "// API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/rate-limit route).",
80
79
  "// The middleware.ts beside this file stamps X-RateLimit-* headers and returns",
81
80
  "// a 429 with Retry-After once the window is exhausted, so this handler never",
82
81
  "// runs on a limited request. Call it six times in ten seconds to see the 429.",
@@ -89,7 +88,7 @@ export async function writeApiGallery(appDir) {
89
88
  // 3) Streaming response (chunks flushed as produced, no buffering).
90
89
  await mkdir(feat('stream'), { recursive: true });
91
90
  await writeFile(feat('stream', 'route.ts'), [
92
- "// webjs-scaffold-placeholder. API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/stream route), then delete this marker line. webjs check fails while the marker remains.",
91
+ "// API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/stream route).",
93
92
  "// Streams JSON-per-line chunks as they are produced, so a slow or large",
94
93
  "// result is delivered incrementally instead of buffered whole. A hand-written",
95
94
  "// route.ts returns a ReadableStream for full control. Served as text/plain so",
@@ -115,7 +114,7 @@ export async function writeApiGallery(appDir) {
115
114
  // 4) File storage (upload + serve), both hand-written route.ts.
116
115
  await mkdir(feat('files', '[key]'), { recursive: true });
117
116
  await writeFile(feat('files', 'route.ts'), [
118
- "// webjs-scaffold-placeholder. API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/files route), then delete this marker line. webjs check fails while the marker remains.",
117
+ "// API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/files route).",
119
118
  "// POST a multipart file: the bytes stream into the FileStore (a local",
120
119
  "// .webjs/uploads dir by default, gitignored; swap for S3/R2 with one",
121
120
  "// setFileStore() call). Returns the key + a URL served by [key]/route.ts.",
@@ -166,7 +165,7 @@ export async function writeApiGallery(appDir) {
166
165
  // 5) WebSocket endpoint with broadcast fan-out.
167
166
  await mkdir(feat('ws'), { recursive: true });
168
167
  await writeFile(feat('ws', 'route.ts'), [
169
- "// webjs-scaffold-placeholder. API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/ws route), then delete this marker line. webjs check fails while the marker remains.",
168
+ "// API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/ws route).",
170
169
  "// A WebSocket endpoint: exporting WS(ws, req) upgrades this route to a socket.",
171
170
  "// The framework auto-registers each connection to its path, so broadcast()",
172
171
  "// fans a message out to every connected client. Connect two clients to",
@@ -0,0 +1,208 @@
1
+ /**
2
+ * App-name validation for `webjs create <name>` (issue #1066).
3
+ *
4
+ * The name a user passes to `webjs create` is not just a directory name. It is
5
+ * ALSO interpolated into generated source as a template-literal value (the
6
+ * `metadata.title` in `app/page.ts`, the `name` field of the api template's
7
+ * root route handler, the `{{APP_NAME}}` substitution in every copied template
8
+ * file) and written verbatim into the generated `package.json` `name` field. A
9
+ * name carrying a quote, a backtick, a `${`, or a backslash therefore breaks
10
+ * the emitted file at the JS/TS syntax level, and the failure surfaces as a
11
+ * parse error on the very first `npm run dev` of the fresh app, far from its
12
+ * actual cause.
13
+ *
14
+ * Rather than escape at each interpolation site (an open-ended list that grows
15
+ * every time the scaffold emits a new file), the name is validated ONCE at the
16
+ * boundary, before any file is written. The rule is npm's package-name rules
17
+ * MINUS the lowercase-only clause (see `ALLOWED_CHAR`), which is still strictly
18
+ * narrower than every interpolation site needs, so a name that passes is safe
19
+ * everywhere the scaffold puts it. `npm init` and `create-next-app` do refuse
20
+ * uppercase; this deliberately does not, because the scaffold's manifest is
21
+ * private and a capital letter breaks nothing.
22
+ *
23
+ * This module is pure and imports nothing, so both the CLI entry
24
+ * (`bin/webjs.js`) and the programmatic entry (`scaffoldApp` in `lib/create.js`)
25
+ * can share one rule.
26
+ */
27
+
28
+ /** npm's hard cap on a package name. */
29
+ export const APP_NAME_MAX_LENGTH = 214;
30
+
31
+ /** One-line description of the allowed shape, reused by both error surfaces. */
32
+ export const APP_NAME_SHAPE =
33
+ 'letters, digits, and the separators "-", "." and "_", starting with a letter or a digit, at most 214 characters, and not a name npm reserves';
34
+
35
+ /**
36
+ * npm rejects these outright, whatever else the name looks like. Matched
37
+ * case-insensitively, since the name is also a directory and `Node_Modules`
38
+ * collides with `node_modules` on a case-insensitive filesystem.
39
+ * @type {string[]}
40
+ */
41
+ const RESERVED_NAMES = ['node_modules', 'favicon.ico'];
42
+
43
+ /**
44
+ * Every character allowed in an app name.
45
+ *
46
+ * npm's own rule for a PUBLISHED package name is lowercase-only, and this guard
47
+ * followed it at first. Uppercase is allowed back deliberately: it never broke
48
+ * anything. The scaffold's `package.json` is `private: true`, so the name is
49
+ * never published, and npm installs a capitalized private package without
50
+ * complaint (checked, not assumed). Every OTHER character this rule refuses
51
+ * corrupts generated source or the manifest, which is what the guard is for, so
52
+ * refusing a capital letter alongside them would have been a naming convention
53
+ * wearing a correctness costume, and `webjs create MyApp` worked before.
54
+ */
55
+ const ALLOWED_CHAR = /[A-Za-z0-9._-]/;
56
+
57
+ /** What a name may start with, per the shape both error surfaces state. */
58
+ const ALLOWED_FIRST_CHAR = /[A-Za-z0-9]/;
59
+
60
+ /**
61
+ * How much of the rejected name to echo back. The name is attacker-shaped by
62
+ * definition here (it is the thing that just failed validation), and it is
63
+ * echoed into a terminal, so it is capped rather than printed whole.
64
+ */
65
+ const DISPLAY_MAX_LENGTH = 48;
66
+
67
+ /**
68
+ * Render the rejected name for an error message. Two things it must not do:
69
+ * break the message onto a second line (a thrown Error is read programmatically
70
+ * and documented as single-line, and a newline is a name `checkAppName`
71
+ * rejects), and replay a control sequence into the terminal it is printed to (a
72
+ * name of `\x1b[31m...` would otherwise repaint the user's shell). So every
73
+ * character with no safe visible form is named by code point, and the result is
74
+ * capped so a 214-character name cannot produce a 240-column line.
75
+ * @param {unknown} name
76
+ * @returns {string}
77
+ */
78
+ function describeName(name) {
79
+ const raw = typeof name === 'string' ? name : String(name);
80
+ let out = '';
81
+ for (const ch of raw) {
82
+ const code = ch.codePointAt(0) ?? 0;
83
+ out += isUnprintable(code)
84
+ ? `<U+${code.toString(16).toUpperCase().padStart(4, '0')}>`
85
+ : ch;
86
+ }
87
+ return out.length > DISPLAY_MAX_LENGTH ? `${out.slice(0, DISPLAY_MAX_LENGTH)}...` : out;
88
+ }
89
+
90
+ /**
91
+ * Whether a code point has no safe visible form in an error message. C0 and
92
+ * DEL are the obvious set. Two additions matter here: the C1 range, which is
93
+ * where an 8-bit terminal reads control functions, and U+2028 / U+2029, which
94
+ * are JS LineTerminators, so they break the single-line contract exactly the
95
+ * way `\n` does while sailing past any `split('\n')` that claims to check it.
96
+ * @param {number} code
97
+ * @returns {boolean}
98
+ */
99
+ function isUnprintable(code) {
100
+ return code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0x2028 || code === 0x2029;
101
+ }
102
+
103
+ /**
104
+ * Render a character for an error message. A control character has no visible
105
+ * form, so name it by code point instead of printing it. The quote delimiter is
106
+ * picked to avoid the character itself, so a rejected apostrophe does not print
107
+ * as the unreadable `'''`.
108
+ * @param {string} ch
109
+ * @returns {string}
110
+ */
111
+ function describeChar(ch) {
112
+ const code = ch.codePointAt(0) ?? 0;
113
+ if (isUnprintable(code)) {
114
+ return `the control character U+${code.toString(16).toUpperCase().padStart(4, '0')}`;
115
+ }
116
+ if (ch === ' ') return 'a space';
117
+ const q = ch === "'" ? '"' : "'";
118
+ return `${q}${ch}${q}`;
119
+ }
120
+
121
+ /**
122
+ * Pure validator. Returns the first problem it finds, phrased as a sentence
123
+ * fragment that reads after "app name ... because".
124
+ *
125
+ * @param {unknown} name the raw name as typed
126
+ * @returns {{ ok: true } | { ok: false, reason: string }}
127
+ */
128
+ export function checkAppName(name) {
129
+ // A non-string reaches here only from a programmatic caller, and calling it
130
+ // "empty" states something false about the input (a `123` is not empty), which
131
+ // is worse than no message at all when the reader is debugging their own call.
132
+ if (typeof name !== 'string') {
133
+ return { ok: false, reason: `an app name must be a string (this one is a ${typeof name})` };
134
+ }
135
+ if (name.length === 0) {
136
+ return { ok: false, reason: 'an app name cannot be empty' };
137
+ }
138
+ if (name.trim() !== name) {
139
+ return { ok: false, reason: 'an app name cannot start or end with whitespace' };
140
+ }
141
+ if (name.length > APP_NAME_MAX_LENGTH) {
142
+ return {
143
+ ok: false,
144
+ reason: `an app name cannot be longer than ${APP_NAME_MAX_LENGTH} characters (this one is ${name.length})`,
145
+ };
146
+ }
147
+ if (RESERVED_NAMES.includes(name.toLowerCase())) {
148
+ return { ok: false, reason: `'${name}' is reserved by npm and cannot be a package name` };
149
+ }
150
+ // Report the first offending character, since that is what the user has to
151
+ // change.
152
+ for (const ch of name) {
153
+ if (ALLOWED_CHAR.test(ch)) continue;
154
+ return { ok: false, reason: `${describeChar(ch)} is not allowed in an app name` };
155
+ }
156
+ // Checked after the character scan so a leading quote is reported as the bad
157
+ // character rather than as a leading-separator problem. Every separator is
158
+ // refused here, not just the leading dot and underscore npm itself refuses:
159
+ // the shape both error surfaces state is "starting with a letter or a digit",
160
+ // and a rule the message claims but does not enforce is worse than either
161
+ // rule on its own. A leading hyphen also reads as a flag everywhere the name
162
+ // is later used as a directory.
163
+ if (!ALLOWED_FIRST_CHAR.test(name[0])) {
164
+ return { ok: false, reason: `an app name cannot start with ${describeChar(name[0])}` };
165
+ }
166
+ return { ok: true };
167
+ }
168
+
169
+ /**
170
+ * The multi-line message the CLI prints. Kept here so the programmatic throw
171
+ * and the CLI output agree on the rule they state.
172
+ * @param {unknown} name
173
+ * @param {string} reason
174
+ * @returns {string}
175
+ */
176
+ export function appNameErrorMessage(name, reason) {
177
+ return `Error: invalid app name "${describeName(name)}".
178
+
179
+ ${reason[0].toUpperCase()}${reason.slice(1)}.
180
+
181
+ The name becomes the app's directory, its package.json name, AND a value the
182
+ scaffold writes into generated source, so it is restricted to:
183
+
184
+ letters and digits
185
+ the separators "-", "." and "_"
186
+ starting with a letter or a digit
187
+ at most ${APP_NAME_MAX_LENGTH} characters
188
+ not ${RESERVED_NAMES.map((n) => `"${n}"`).join(' or ')} (npm reserves both)
189
+
190
+ Example: webjs create my-app`;
191
+ }
192
+
193
+ /**
194
+ * Convenience wrapper for callers that want an exception. Throws with a
195
+ * single-line message (a thrown Error is read programmatically, so it stays
196
+ * compact) and returns the validated name otherwise.
197
+ * @param {unknown} name
198
+ * @returns {string}
199
+ */
200
+ export function assertValidAppName(name) {
201
+ const result = checkAppName(name);
202
+ if (!result.ok) {
203
+ throw new Error(
204
+ `Invalid app name '${describeName(name)}'. ${result.reason[0].toUpperCase()}${result.reason.slice(1)}. Allowed: ${APP_NAME_SHAPE}.`,
205
+ );
206
+ }
207
+ return /** @type {string} */ (name);
208
+ }
package/lib/create.js CHANGED
@@ -18,6 +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
22
 
22
23
  /**
23
24
  * Detect which package manager invoked us. Reads `npm_config_user_agent`,
@@ -166,8 +167,11 @@ async function writeUiBootstrap(appDir) {
166
167
  );
167
168
  await writeFile(join(appDir, 'lib', 'utils', 'dom.ts'), domContent);
168
169
 
169
- // 2) components.json: the same shape `webjsui init` writes for webjs
170
- // projects (see packages/ui/src/utils/detect-project.js). The utils alias
170
+ // 2) components.json: byte for byte what `webjsui init` writes (see the
171
+ // DEFAULT_ALIASES / DEFAULT_TAILWIND_CSS constants in
172
+ // packages/ui/src/commands/init.js, which #1129 made the single source of
173
+ // these values). Keep the two in step: an app that scaffolds and one that
174
+ // runs `webjs ui init` must end up with the same config. The utils alias
171
175
  // is lib/utils/cn so get-config.js's `+ '.ts'` resolves to lib/utils/cn.ts.
172
176
  // The theme CSS lives at styles/globals.css, NOT app/globals.css: app/ is
173
177
  // routing-only, so a non-routing stylesheet does not belong there.
@@ -255,9 +259,15 @@ function assertUiRegistryAvailable() {
255
259
  * @param {string} cwd Current working directory
256
260
  */
257
261
  export async function scaffoldApp(name, cwd, opts = {}) {
262
+ // Defence in depth, same as the template check below. `webjs create` already
263
+ // validates the name, but a programmatic caller can pass anything, and the
264
+ // name is interpolated into generated source as a template-literal value
265
+ // (#1066), so an unvalidated quote / backtick / `${` would emit a file that
266
+ // fails to parse. Throwing here happens before any directory is created.
267
+ assertValidAppName(name);
258
268
  const template = opts.template || 'full-stack';
259
- // A human-friendly display title for the example home page. The npm `name`
260
- // stays the raw slug (lowercase, hyphenated), but showing a hyphenated slug as
269
+ // A human-friendly display title for the example home page. The package
270
+ // `name` stays the raw slug as typed, but showing a hyphenated slug as
261
271
  // a hero title looks unpolished, so title-case it for display ("my-app" ->
262
272
  // "My App"). Replace this with your real brand anyway.
263
273
  const displayName = name.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
@@ -388,10 +398,12 @@ export async function scaffoldApp(name, cwd, opts = {}) {
388
398
  'test:browser': 'webjs test --browser',
389
399
  check: 'webjs check',
390
400
  typecheck: 'webjs typecheck',
391
- // Onboarding/setup-verify: a contributor runs `npm run doctor` after
392
- // cloning to assert the toolchain (Node floor, tsconfig flag, env drift,
393
- // vendor pins, @webjsdev versions, git hook). Local tool, NOT a CI gate
394
- // (its env-drift + network pin-freshness checks would make CI flaky).
401
+ // Project health: a contributor runs `npm run doctor` after cloning to
402
+ // assert the toolchain (Node floor, tsconfig flag, env drift, vendor
403
+ // pins, @webjsdev versions, git hook), and CI runs the same script. Which
404
+ // findings are FATAL comes from the `webjs.doctor.gate` block below, so
405
+ // the environment-shaped checks (env drift, pin freshness over the
406
+ // network, the git hook) stay warns and cannot make CI flaky.
395
407
  doctor: 'webjs doctor',
396
408
  'db:generate': 'webjs db generate',
397
409
  'db:migrate': 'webjs db migrate',
@@ -481,6 +493,17 @@ export async function scaffoldApp(name, cwd, opts = {}) {
481
493
  }),
482
494
  },
483
495
  start: { before: isApi ? ['webjs db migrate'] : ['webjs db migrate', cssBuildCmd] },
496
+ // Which doctor findings are FATAL is the app's own call (#1257), declared
497
+ // here rather than in the CI workflow so `npm run doctor` locally and the
498
+ // workflow step agree about what fails. UNMARKED_ASSET_LINKS starts at
499
+ // error because an un-versioned /public url is a real deploy-staleness
500
+ // bug (it shipped a visible regression on webjs.dev) and the generated
501
+ // layout already writes asset(), so a fresh app is green on day one.
502
+ // Two checks are fatal with no entry here at all, NODE_VERSION and
503
+ // TSCONFIG_ERASABLE, because either would 500 the app at runtime;
504
+ // everything else keeps its default warn. Add a code with "off" to
505
+ // silence it, or "error" to make it fatal too.
506
+ doctor: { gate: { UNMARKED_ASSET_LINKS: 'error' } },
484
507
  },
485
508
  }, null, 2) + '\n');
486
509
 
@@ -1149,6 +1172,7 @@ ${uiThemeRaw}
1149
1172
  await copyGallery(appDir);
1150
1173
 
1151
1174
  await writeFile(join(appDir, 'app', 'layout.ts'), `import { html, cspNonce, asset } from '@webjsdev/core';
1175
+ import type { LayoutProps } from '@webjsdev/core';
1152
1176
  import '#components/theme-toggle.ts';
1153
1177
 
1154
1178
  /**
@@ -1167,7 +1191,11 @@ import '#components/theme-toggle.ts';
1167
1191
  // lives at public/favicon.svg and serves at /public/favicon.svg.
1168
1192
  export const metadata = { icons: '/public/favicon.svg' };
1169
1193
 
1170
- export default function RootLayout({ children }: { children: unknown }) {
1194
+ // LayoutProps types every layout argument (children, params, searchParams,
1195
+ // url) from the framework, so children is a TemplateResult rather than an
1196
+ // untyped value. Derive types like this everywhere instead of widening to
1197
+ // unknown; see .agents/skills/webjs/references/typescript.md.
1198
+ export default function RootLayout({ children }: LayoutProps) {
1171
1199
  // Read the in-flight request's CSP nonce so the theme-detection inline script
1172
1200
  // passes strict CSP. Returns '' when no CSP nonce is set.
1173
1201
  const nonce = cspNonce();