@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.
- package/README.md +3 -1
- package/bin/webjs.js +133 -30
- package/lib/api-gallery.js +6 -7
- package/lib/app-name.js +208 -0
- package/lib/create.js +37 -9
- package/lib/doctor.js +479 -7
- package/package.json +2 -2
- package/templates/.agents/rules/workflow.md +9 -1
- package/templates/.agents/skills/webjs/SKILL.md +25 -11
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +2 -2
- package/templates/.agents/skills/webjs/references/built-ins.md +25 -6
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +6 -2
- package/templates/.agents/skills/webjs/references/components.md +9 -1
- package/templates/.agents/skills/webjs/references/data-and-actions.md +40 -7
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +54 -18
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +35 -14
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +34 -15
- package/templates/.agents/skills/webjs/references/runtime.md +5 -1
- package/templates/.agents/skills/webjs/references/styling.md +1 -1
- package/templates/.agents/skills/webjs/references/testing.md +61 -3
- package/templates/.agents/skills/webjs/references/typescript.md +71 -2
- package/templates/.agents/skills/webjs/references/ui-kit.md +5 -2
- package/templates/.github/pull_request_template.md +1 -0
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/AGENTS.md +31 -5
- package/templates/CONVENTIONS.md +4 -1
- package/templates/gallery/app/examples/layout.ts +2 -1
- package/templates/gallery/app/examples/todo/page.ts +3 -16
- package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
- package/templates/gallery/app/features/auth/signup/page.ts +4 -23
- package/templates/gallery/app/features/caching/page.ts +6 -6
- package/templates/gallery/app/features/file-storage/page.ts +8 -19
- package/templates/gallery/app/features/forms/page.ts +12 -38
- package/templates/gallery/app/features/layout.ts +6 -2
- package/templates/gallery/app/features/route-handler/data/route.ts +2 -1
- package/templates/gallery/app/features/view-transitions/page.ts +1 -1
- package/templates/gallery/app/global-error.ts +7 -4
- package/templates/gallery/modules/auth/actions/signup.server.ts +27 -11
- package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +21 -10
- package/templates/gallery/modules/forms/actions/send-message.server.ts +34 -0
- package/templates/gallery/modules/gallery/nav.ts +1 -1
- package/templates/gallery/modules/server-actions/actions/greet.test.ts +6 -5
- package/templates/gallery/modules/todo/actions/submit-todo.server.ts +31 -0
- package/templates/gallery/modules/todo/components/todo-app.ts +8 -5
- package/templates/gallery/modules/todo/types.ts +15 -10
- package/templates/gallery/test/auth/auth.test.ts +31 -16
- package/templates/partials/agents-playbook-api.md +5 -0
- package/templates/partials/agents-playbook-fullstack.md +5 -0
- package/templates/scripts/clear-gallery.mjs +5 -4
- package/templates/test/hello/e2e/hello.test.ts +18 -1
package/README.md
CHANGED
|
@@ -39,12 +39,14 @@ Both `webjs create` and `create-webjs-app` auto-install dependencies in the new
|
|
|
39
39
|
|
|
40
40
|
```sh
|
|
41
41
|
webjs create <name> # scaffold a full-stack app (default; auth ships as a gallery card)
|
|
42
|
+
# <name> must be a valid package name: letters, digits,
|
|
43
|
+
# and - . _ , starting with a letter or a digit
|
|
42
44
|
webjs create <name> --template api # backend-only API app (routes + modules + Drizzle)
|
|
43
45
|
|
|
44
46
|
webjs dev # dev server with live reload (runs webjs.dev.before, e.g. webjs db migrate, then serves; npm run dev is a thin alias)
|
|
45
47
|
webjs start # production server (no build step, serves source directly)
|
|
46
48
|
webjs check # validate source-code conventions (CI gate)
|
|
47
|
-
webjs doctor # verify the project/toolchain setup (
|
|
49
|
+
webjs doctor # verify the project/toolchain setup (per-check severity via webjs.doctor.gate, so CI can gate a subset)
|
|
48
50
|
webjs test # run server + browser tests
|
|
49
51
|
webjs vendor pin [--download] # pin client deps to a committable importmap (offline/reproducible)
|
|
50
52
|
webjs db <generate|migrate|push|studio|seed> # drizzle-kit passthrough (+ seed)
|
package/bin/webjs.js
CHANGED
|
@@ -8,6 +8,7 @@ import { dbGenerateTtyHint } from '../lib/db-hints.js';
|
|
|
8
8
|
import { checkNodeInline, nodeInlineMessage } from '../lib/node-preflight.js';
|
|
9
9
|
import { loadAppEnv, resolvePort } from '../lib/port.js';
|
|
10
10
|
import { planDevSupervisor } from '../lib/dev-supervisor.js';
|
|
11
|
+
import { checkAppName, appNameErrorMessage } from '../lib/app-name.js';
|
|
11
12
|
|
|
12
13
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
14
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
@@ -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
|
|
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
|
|
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,
|
|
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
|
|
675
|
-
// HARD check FAILS
|
|
676
|
-
//
|
|
677
|
-
// app's concern, not a broken
|
|
678
|
-
|
|
679
|
-
|
|
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.
|
|
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.
|
|
687
|
-
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
|
|
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`
|
|
694
|
-
// a summary, so an agent consumes
|
|
695
|
-
// text. Shape mirrors `check
|
|
696
|
-
// with a `summary` count. The
|
|
697
|
-
// on the exit code AND parses
|
|
698
|
-
|
|
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]',
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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}
|
|
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');
|
package/lib/api-gallery.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
"//
|
|
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
|
-
"//
|
|
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
|
-
"//
|
|
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
|
-
"//
|
|
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
|
-
"//
|
|
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",
|
package/lib/app-name.js
ADDED
|
@@ -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:
|
|
170
|
-
//
|
|
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
|
|
260
|
-
// stays the raw slug
|
|
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
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
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
|
-
|
|
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();
|