@webjsdev/cli 0.10.52 → 0.10.54

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 (33) hide show
  1. package/README.md +2 -2
  2. package/bin/webjs.js +17 -0
  3. package/lib/app-name.js +73 -0
  4. package/lib/check-target.js +145 -0
  5. package/lib/create.js +42 -26
  6. package/lib/doctor.js +91 -16
  7. package/lib/gallery-shell-files.js +36 -0
  8. package/package.json +3 -3
  9. package/templates/.agents/skills/webjs/SKILL.md +6 -2
  10. package/templates/.agents/skills/webjs/references/built-ins.md +1 -1
  11. package/templates/.agents/skills/webjs/references/components.md +95 -3
  12. package/templates/.agents/skills/webjs/references/data-and-actions.md +29 -2
  13. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +25 -1
  14. package/templates/.agents/skills/webjs/references/routing-and-pages.md +24 -1
  15. package/templates/.agents/skills/webjs/references/styling.md +14 -1
  16. package/templates/.agents/skills/webjs/references/testing.md +8 -0
  17. package/templates/.agents/skills/webjs/references/typescript.md +17 -3
  18. package/templates/.agents/skills/webjs/references/ui-kit.md +15 -0
  19. package/templates/.claude/hooks/block-prose-punctuation.sh +66 -24
  20. package/templates/gallery/app/icon.ts +10 -5
  21. package/templates/gallery/modules/directives/components/directive-demo.ts +4 -1
  22. package/templates/gallery/modules/gallery/components/gallery-nav.ts +5 -0
  23. package/templates/gallery/modules/gallery/nav.ts +12 -3
  24. package/templates/gallery/modules/todo/actions/submit-todo.server.ts +7 -0
  25. package/templates/test/hello/e2e/hello.test.ts +26 -1
  26. package/templates/.cursor/hooks/nudge-uncommitted.sh +0 -38
  27. package/templates/.cursor/hooks.json +0 -8
  28. package/templates/.cursorrules +0 -21
  29. package/templates/.gemini/hooks/nudge-uncommitted.sh +0 -42
  30. package/templates/.gemini/settings.json +0 -15
  31. package/templates/.github/copilot-instructions.md +0 -9
  32. package/templates/.opencode/plugins/nudge-uncommitted.ts +0 -62
  33. package/templates/GEMINI.md +0 -11
package/README.md CHANGED
@@ -68,8 +68,8 @@ the CLI gives you `webjs ui` automatically. See
68
68
 
69
69
  The scaffold seeds opinionated defaults so AI agents produce consistent code:
70
70
 
71
- - `AGENTS.md` + `CONVENTIONS.md` (the machine-readable contract)
72
- - `.claude/`, `.cursorrules`, `.agents/rules/workflow.md` (Antigravity), `.github/copilot-instructions.md`
71
+ - `AGENTS.md` + `CONVENTIONS.md` + `.agents/skills/webjs/` (single cross-agent source of truth)
72
+ - `.agents/rules/workflow.md` & `.claude/` protective hooks
73
73
  - `test/<feature>/` (with optional `browser/` / `e2e/` subfolders per kind) with example tests
74
74
  - Tailwind CSS via CLI (no browser runtime at build time)
75
75
  - TypeScript, `.editorconfig`, `.gitignore`
package/bin/webjs.js CHANGED
@@ -9,6 +9,7 @@ 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
11
  import { checkAppName, appNameErrorMessage } from '../lib/app-name.js';
12
+ import { findCheckTarget, notAnAppMessage, notAnAppJson } from '../lib/check-target.js';
12
13
 
13
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
15
  const [cmd, ...rest] = process.argv.slice(2);
@@ -722,6 +723,22 @@ async function main() {
722
723
  break;
723
724
  }
724
725
 
726
+ // #1301: `webjs check` is an APP-level tool. At a workspace root it
727
+ // walks every package's tests and every app at once and reports
728
+ // cross-app collisions that no single runtime ever sees (67 false
729
+ // findings at this repo's root). Refuse instead, naming the member
730
+ // apps to run it in. Exit 1: an agent gates on the exit status, and
731
+ // 0 would read as "clean", the exact false signal this fixes.
732
+ const target = await findCheckTarget(process.cwd());
733
+ if (!target.isApp) {
734
+ if (rest.includes('--json')) {
735
+ console.log(JSON.stringify(notAnAppJson(process.cwd(), target.workspaceApps)));
736
+ } else {
737
+ console.error(notAnAppMessage(process.cwd(), target.workspaceApps));
738
+ }
739
+ process.exit(1);
740
+ }
741
+
725
742
  const violations = await checkConventions(process.cwd());
726
743
 
727
744
  // --json emits the raw structured violations + a summary count as JSON,
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
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `webjs check` invocation-target guard (#1301).
3
+ *
4
+ * `webjs check` is an APP-level tool: every rule assumes one application,
5
+ * meaning one module graph, one custom-element registry, one runtime. Run at a
6
+ * workspace root it walks whatever JS/TS happens to live under that path (two
7
+ * apps, every package's test suite, editor fixtures, the scaffold templates)
8
+ * and reports collisions no single runtime ever sees. At this repo's root that
9
+ * was 67 findings, all false.
10
+ *
11
+ * The predicate is the presence of an `app/` directory, nothing else. That is
12
+ * the same test `check.js` already applies per-rule, `app/` cannot be renamed
13
+ * (AGENTS.md "App layout"), and BOTH scaffold templates create it. Next.js
14
+ * refuses on the identical predicate in
15
+ * `packages/next/src/lib/find-pages-dir.ts`. A `workspaces` key is NOT part of
16
+ * the predicate (a directory with no `app/` is not an app either way); it only
17
+ * enriches the MESSAGE with the member apps to run instead.
18
+ *
19
+ * PURE apart from directory reads: it never prints and never exits. The bin
20
+ * owns rendering and the exit code.
21
+ *
22
+ * @module check-target
23
+ */
24
+
25
+ import { statSync } from 'node:fs';
26
+ import { readFile, glob } from 'node:fs/promises';
27
+ import { join } from 'node:path';
28
+
29
+ /**
30
+ * @typedef {{ isApp: boolean, workspaceApps: string[] }} CheckTarget
31
+ */
32
+
33
+ /**
34
+ * Whether `dir` holds an `app/` DIRECTORY. A plain file named `app` is not one,
35
+ * and `existsSync` alone would call it one, so the type is checked. A broken
36
+ * symlink or an unreadable parent throws out of `statSync` rather than
37
+ * returning false, so it is caught: an unreadable path is not an app either.
38
+ *
39
+ * @param {string} dir
40
+ * @returns {boolean}
41
+ */
42
+ function hasAppDir(dir) {
43
+ try {
44
+ return statSync(join(dir, 'app')).isDirectory();
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Classify `cwd` as a checkable app or not, and (when it declares npm
52
+ * workspaces) list the member directories that ARE apps, sorted, as
53
+ * cwd-relative POSIX paths.
54
+ *
55
+ * @param {string} cwd
56
+ * @returns {Promise<CheckTarget>}
57
+ */
58
+ export async function findCheckTarget(cwd) {
59
+ if (hasAppDir(cwd)) return { isApp: true, workspaceApps: [] };
60
+ return { isApp: false, workspaceApps: await workspaceApps(cwd) };
61
+ }
62
+
63
+ /**
64
+ * Expand `package.json` `workspaces` (the array form and yarn's
65
+ * `{ packages: [...] }` form) and keep the members that have an `app/`
66
+ * directory. Any read / parse failure yields an empty list: the message
67
+ * degrades to the generic form and the refusal still stands.
68
+ *
69
+ * @param {string} cwd
70
+ * @returns {Promise<string[]>}
71
+ */
72
+ async function workspaceApps(cwd) {
73
+ let patterns;
74
+ try {
75
+ const pkg = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8'));
76
+ const ws = pkg.workspaces;
77
+ patterns = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : null;
78
+ } catch {
79
+ return [];
80
+ }
81
+ if (!patterns) return [];
82
+ /** @type {Set<string>} */
83
+ const apps = new Set();
84
+ for (const pattern of patterns) {
85
+ if (typeof pattern !== 'string') continue;
86
+ try {
87
+ for await (const match of glob(pattern, { cwd })) {
88
+ // `glob` yields whatever matched, files included, so the app test is
89
+ // what filters a stray same-named file out too.
90
+ if (hasAppDir(join(cwd, match))) apps.add(match.split('\\').join('/'));
91
+ }
92
+ } catch {
93
+ // A malformed pattern drops out of the listing, never out of the refusal.
94
+ }
95
+ }
96
+ return [...apps].sort();
97
+ }
98
+
99
+ /**
100
+ * The human refusal, for stderr.
101
+ *
102
+ * @param {string} cwd
103
+ * @param {string[]} apps
104
+ * @returns {string}
105
+ */
106
+ export function notAnAppMessage(cwd, apps) {
107
+ const lines = [
108
+ 'webjs check: this directory is not a WebJs app, so nothing was checked.',
109
+ '',
110
+ ` ${cwd}`,
111
+ '',
112
+ 'There is no `app/` directory here. Every check assumes ONE application',
113
+ '(one module graph, one custom-element registry, one runtime), so running',
114
+ 'them over a workspace root reports collisions no single runtime ever sees.',
115
+ '',
116
+ ];
117
+ if (apps.length) {
118
+ lines.push('This is a workspace root. Run the check inside each app:', '');
119
+ for (const app of apps) lines.push(` ( cd ${app} && npx webjs check )`);
120
+ } else {
121
+ lines.push('Change into your app directory (the one holding `app/`) and re-run.');
122
+ }
123
+ lines.push('', '`webjs check --rules` lists the rules and works from anywhere.');
124
+ return lines.join('\n');
125
+ }
126
+
127
+ /**
128
+ * The `--json` refusal. It carries NO `violations` key on purpose: a consumer
129
+ * that ignores the exit code and reads `report.violations.length` must throw
130
+ * rather than be told the workspace is clean.
131
+ *
132
+ * @param {string} cwd
133
+ * @param {string[]} apps
134
+ * @returns {{ error: { code: string, message: string, cwd: string, apps: string[] } }}
135
+ */
136
+ export function notAnAppJson(cwd, apps) {
137
+ return {
138
+ error: {
139
+ code: 'NOT_AN_APP',
140
+ message: 'No `app/` directory here, so webjs check has no application to check.',
141
+ cwd,
142
+ apps,
143
+ },
144
+ };
145
+ }
package/lib/create.js CHANGED
@@ -12,13 +12,14 @@
12
12
  */
13
13
 
14
14
  import { mkdir, writeFile, readFile, cp } from 'node:fs/promises';
15
- import { join, resolve, dirname } from 'node:path';
15
+ import { join, resolve, dirname, relative } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  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
+ import { isGalleryAppShellFile } from './gallery-shell-files.js';
22
23
 
23
24
  /**
24
25
  * Detect which package manager invoked us. Reads `npm_config_user_agent`,
@@ -116,13 +117,20 @@ const UI_REGISTRY_ROOT = resolveUiRegistryRoot();
116
117
  * Ships verbatim (no `{{APP_NAME}}` substitution): the examples are self-
117
118
  * contained and reference only `@webjsdev/*`, drizzle, `#db/*`, and each other.
118
119
  * The scaffold's own `app/page.ts` / `app/layout.ts` are written AFTER this and
119
- * the gallery ships neither, so there is no clobber. `cp` merges into existing
120
- * `app/` and `modules/` dirs rather than replacing them.
120
+ * are filtered out of the copy here (see GALLERY_APP_SHELL_FILES), so there is
121
+ * no clobber. `cp` merges into existing `app/` and `modules/` dirs rather than
122
+ * replacing them.
123
+ *
124
+ * The source is the canonical repo-root `gallery/` app in monorepo dev, and the
125
+ * `templates/gallery/` copy `prepack` bundles into the tarball when installed
126
+ * from npm. Both are filtered identically, so the two modes emit the same app.
121
127
  *
122
128
  * @param {string} appDir
123
129
  */
124
130
  async function copyGallery(appDir) {
125
- const galleryDir = join(TEMPLATES, 'gallery');
131
+ const bundledGallery = join(TEMPLATES, 'gallery');
132
+ const repoRootGallery = resolve(__dirname, '..', '..', '..', 'gallery');
133
+ const galleryDir = existsSync(bundledGallery) ? bundledGallery : repoRootGallery;
126
134
  // `test` carries the auth card's real request-pipeline test (test/auth); it
127
135
  // ships with the gallery and is pruned by gallery:clear alongside the card.
128
136
  // `components` carries the gallery's EXAMPLE design system (components/ui/ class
@@ -135,7 +143,15 @@ async function copyGallery(appDir) {
135
143
  // the ui bootstrap's cn.ts/dom.ts (written earlier); gallery:clear removes just
136
144
  // ui.ts. cp is recursive-merge, so the pre-written lib/utils/ files are kept.
137
145
  for (const sub of ['app', 'modules', 'test', 'components', 'lib']) {
138
- await cp(join(galleryDir, sub), join(appDir, sub), { recursive: true });
146
+ const srcSub = join(galleryDir, sub);
147
+ if (!existsSync(srcSub)) continue;
148
+ await cp(srcSub, join(appDir, sub), {
149
+ recursive: true,
150
+ // Skip the gallery's own app shell (root layout, home page, theme toggle,
151
+ // cn.ts). Those exist because gallery/ is a live app; the scaffold writes
152
+ // its own, with the app's displayName and the ui-registry cn.ts.
153
+ filter: (src) => !isGalleryAppShellFile(relative(galleryDir, src)),
154
+ });
139
155
  }
140
156
  }
141
157
 
@@ -541,6 +557,11 @@ export async function scaffoldApp(name, cwd, opts = {}) {
541
557
  { name: '@webjsdev/intellisense' },
542
558
  ],
543
559
  },
560
+ // `test/**/*` is in so `webjs typecheck` reads the tests you write, the
561
+ // same way Next / Remix / Astro's generated configs do (#1299). A type
562
+ // error in a test is then a gate failure rather than something a reviewer
563
+ // has to catch by eye, and it needs no second config to remember to run.
564
+ //
544
565
  // `.webjs/routes.d.ts` is the OPT-IN generated route-types overlay (#258):
545
566
  // run `webjs types` (or `webjs dev`, which emits it) to narrow the
546
567
  // @webjsdev/core `Route` href union + per-route `params`. Listed in
@@ -552,6 +573,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
552
573
  'components/**/*',
553
574
  'modules/**/*',
554
575
  'lib/**/*',
576
+ 'test/**/*',
555
577
  'middleware.js',
556
578
  'middleware.ts',
557
579
  '.webjs/routes.d.ts',
@@ -562,26 +584,12 @@ export async function scaffoldApp(name, cwd, opts = {}) {
562
584
  // --- Templates (AGENTS.md, CONVENTIONS.md, CLAUDE.md, test files, Claude hooks) ---
563
585
 
564
586
  const templateFiles = [
565
- // Single cross-agent source: a thin AGENTS.md points at the skill; the
587
+ // Single cross-agent source: AGENTS.md points at .agents/skills/webjs/; the
566
588
  // .agents/rules workflow rules and the Claude enforcement hooks back it up.
567
589
  'AGENTS.md',
568
590
  'CONVENTIONS.md',
569
591
  '.agents/rules/workflow.md',
570
- // Per-agent files. Content is single-source (AGENTS.md + the skill); these
571
- // are thin bridges plus each tool's own config and commit-nudge hook. Claude
572
- // Code (CLAUDE.md @-imports AGENTS.md), Gemini CLI (GEMINI.md), Copilot in VS
573
- // Code (copilot-instructions.md). Cursor / opencode / Antigravity read
574
- // AGENTS.md natively; Cursor also gets a .cursorrules bridge, and each of
575
- // Cursor / Gemini / opencode ships a "commit often" nudge hook.
576
592
  'CLAUDE.md',
577
- 'GEMINI.md',
578
- '.github/copilot-instructions.md',
579
- '.cursorrules',
580
- '.cursor/hooks.json',
581
- '.cursor/hooks/nudge-uncommitted.sh',
582
- '.gemini/settings.json',
583
- '.gemini/hooks/nudge-uncommitted.sh',
584
- '.opencode/plugins/nudge-uncommitted.ts',
585
593
  // Claude Code config + the protective enforcement hooks (no design ceremony).
586
594
  '.claude.json',
587
595
  '.claude/settings.json',
@@ -623,7 +631,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
623
631
  // rewrites; the three infra files get their file-specific transform. On Node,
624
632
  // every file is copied byte-identical (the map is empty).
625
633
  const PROSE_REWRITE = new Set([
626
- 'AGENTS.md', 'CLAUDE.md', 'CONVENTIONS.md', '.cursorrules',
634
+ 'AGENTS.md', 'CLAUDE.md', 'CONVENTIONS.md',
627
635
  '.agents/rules/workflow.md',
628
636
  'test/hello/browser/hello.test.js', 'test/hello/e2e/hello.test.ts',
629
637
  ]);
@@ -877,9 +885,14 @@ export default defineConfig({
877
885
  `);
878
886
 
879
887
  // Env vars: append DATABASE_URL to the .env.example the template already
880
- // copied (if present), idempotently.
888
+ // copied (if present), idempotently. The database segment is the app name
889
+ // normalized to a fold-stable PostgreSQL identifier, so the emitted URL
890
+ // names the same database whether the user runs `createdb` or types
891
+ // `CREATE DATABASE`. Hoisted because the post-scaffold guidance below names
892
+ // the same value, and the two must not be able to drift.
893
+ const dbName = toDatabaseName(name);
881
894
  const dbUrlLine = dialect === 'postgres'
882
- ? 'DATABASE_URL=postgres://user:password@localhost:5432/' + name.replace(/[^a-z0-9_]/gi, '_')
895
+ ? 'DATABASE_URL=postgres://user:password@localhost:5432/' + dbName
883
896
  : 'DATABASE_URL=file:./db/dev.db';
884
897
  const envExample = join(appDir, '.env.example');
885
898
  if (existsSync(envExample)) {
@@ -1365,13 +1378,16 @@ import { cardClass } from '#components/ui/card.ts';
1365
1378
  import { badgeClass } from '#components/ui/badge.ts';
1366
1379
  // The demo index is defined once in modules/gallery/nav.ts (the same source the
1367
1380
  // left sidebar reads), so the home cards and the sidebar can never drift.
1368
- import { FEATURES, EXAMPLES } from '#modules/gallery/nav.ts';
1381
+ import { featureList, EXAMPLES } from '#modules/gallery/nav.ts';
1369
1382
 
1370
1383
  export const metadata = {
1371
1384
  title: '${displayName}',
1372
1385
  };
1373
1386
 
1374
1387
  export default function Home() {
1388
+ // Flattened HERE rather than at module scope. A top-level call is a module
1389
+ // side effect, which would ship this page to the browser for nothing.
1390
+ const FEATURES = featureList();
1375
1391
  return html\`
1376
1392
  <div class="py-8 flex flex-col items-center gap-16">
1377
1393
  <!-- Hero -->
@@ -1596,7 +1612,7 @@ ThemeToggle.register('theme-toggle');
1596
1612
  // local file with no .env). Point it at a running database; `dev` / `start`
1597
1613
  // then apply pending migrations via webjs.*.before.
1598
1614
  const pgNote = dialect === 'postgres'
1599
- ? `\nPostgres: copy .env.example to .env and set DATABASE_URL to a running database before \`${pm} run dev\`.\n`
1615
+ ? `\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
1616
  : '';
1601
1617
  // Use `npx webjsdev ui ...` here, not `npx webjs ui ...`. The bare
1602
1618
  // `webjs` npm name is owned by an unrelated package; `npx webjs
package/lib/doctor.js CHANGED
@@ -52,7 +52,7 @@
52
52
 
53
53
  import { existsSync, statSync, readdirSync, readFileSync } from 'node:fs';
54
54
  import { readFile } from 'node:fs/promises';
55
- import { join, relative } from 'node:path';
55
+ import { dirname, join, relative } from 'node:path';
56
56
  import { createRequire } from 'node:module';
57
57
  import { checkNodeInline } from './node-preflight.js';
58
58
 
@@ -837,13 +837,87 @@ async function checkImportmapCoherence(appDir, opts) {
837
837
  };
838
838
  }
839
839
 
840
+ /**
841
+ * Read a dependency's INSTALLED version as resolved FROM `appDir`, or null when
842
+ * it does not resolve there at all.
843
+ *
844
+ * Node's own resolver is the ground truth here, not a directory read. The check
845
+ * this serves asks "would this app resolve this dependency at runtime, and at
846
+ * what version", and Node's resolution algorithm IS that question's definition,
847
+ * so anything re-implementing it can only be a worse approximation. Asking Node
848
+ * handles workspace hoisting (the bug this fixes: under npm workspaces the
849
+ * `@webjsdev/*` deps hoist to the ROOT node_modules, so an app subdirectory has
850
+ * no local copy and a per-app `node_modules/<dep>/package.json` read reported
851
+ * every declared dep missing on a healthy install), symlinked workspace links,
852
+ * nested non-hoisted trees, and `package.json` `imports`, for free and for ever.
853
+ *
854
+ * The direct `<dep>/package.json` resolve is attempted FIRST because a package
855
+ * may declare no main entry at all: `@webjsdev/cli` is bin-only (no `main`, no
856
+ * `exports`), so `require.resolve('@webjsdev/cli')` throws MODULE_NOT_FOUND.
857
+ * The ERR_PACKAGE_PATH_NOT_EXPORTED fallback exists because a package may lock
858
+ * its manifest out of its `exports` map: `@webjsdev/server` exports only `.`,
859
+ * `./check`, `./testing`, and `./webjs-config.schema.json`, so the direct
860
+ * manifest resolve is refused and the main entry plus a bounded walk up to the
861
+ * package root is the way in. Neither strategy alone resolves all four
862
+ * `@webjsdev/*` packages; both halves are required.
863
+ *
864
+ * Local rather than `getPackageVersion` from `@webjsdev/server` for two reasons.
865
+ * Doctor must stay usable when the framework does not resolve from the app dir
866
+ * at all, which is the #954 fresh-worktree case doctor exists to diagnose, so
867
+ * this check cannot import the server (the same argument `frameworkResolves`
868
+ * below already follows). And `getPackageVersion` resolves the main entry only,
869
+ * so it returns null for a bin-only package, which would leave `@webjsdev/cli`
870
+ * reported missing: the same false positive with more machinery.
871
+ *
872
+ * Pinned by the workspace, bin-only, and exports-locked fixtures in
873
+ * `test/cli/doctor.test.mjs`.
874
+ * @param {string} dep package name, e.g. `@webjsdev/server`
875
+ * @param {string} appDir directory to anchor resolution at
876
+ * @returns {Promise<string|null>} the installed version, or null when unresolvable
877
+ */
878
+ async function readInstalledVersion(dep, appDir) {
879
+ // The base file need not exist; createRequire only uses it to anchor the
880
+ // node_modules lookup at appDir.
881
+ const require = createRequire(join(appDir, '__webjs_resolve_probe__.js'));
882
+ let manifestPath = null;
883
+ try {
884
+ manifestPath = require.resolve(dep + '/package.json');
885
+ } catch (err) {
886
+ if (err?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') return null;
887
+ let entry;
888
+ try {
889
+ entry = require.resolve(dep);
890
+ } catch {
891
+ return null;
892
+ }
893
+ let dir = dirname(entry);
894
+ for (let i = 0; i < 12; i++) {
895
+ const candidate = join(dir, 'package.json');
896
+ if (existsSync(candidate)) {
897
+ manifestPath = candidate;
898
+ break;
899
+ }
900
+ const parent = dirname(dir);
901
+ if (parent === dir) break;
902
+ dir = parent;
903
+ }
904
+ if (!manifestPath) return null;
905
+ }
906
+ try {
907
+ return JSON.parse(await readFile(manifestPath, 'utf8')).version || null;
908
+ } catch {
909
+ return null;
910
+ }
911
+ }
912
+
840
913
  /**
841
914
  * CHECK 5, @webjsdev/* version coherence. WARN-level only (a version drift is
842
915
  * not a crash). Reads the app package.json `@webjsdev/*` ranges across
843
- * dependencies + devDependencies, then for each reads the INSTALLED version from
844
- * `node_modules/@webjsdev/<pkg>/package.json` and checks it satisfies the
845
- * declared range. PASS when every @webjsdev dep is present + satisfied; WARN on
846
- * a missing install or a range drift.
916
+ * dependencies + devDependencies, then for each resolves the INSTALLED version
917
+ * through Node's own resolver anchored at the app dir (see
918
+ * `readInstalledVersion`, which is why a workspace-hoisted install resolves)
919
+ * and checks it satisfies the declared range. PASS when every @webjsdev dep is
920
+ * present + satisfied; WARN on a missing install or a range drift.
847
921
  * @param {string} appDir
848
922
  * @returns {Promise<DoctorResult>}
849
923
  */
@@ -881,15 +955,8 @@ async function checkWebjsVersions(appDir) {
881
955
  const missing = [];
882
956
  const drift = [];
883
957
  for (const dep of webjsDeps) {
884
- const installedPkg = join(appDir, 'node_modules', dep, 'package.json');
885
- if (!existsSync(installedPkg)) {
886
- missing.push(dep);
887
- continue;
888
- }
889
- let installedVersion = '';
890
- try {
891
- installedVersion = JSON.parse(await readFile(installedPkg, 'utf8')).version || '';
892
- } catch {
958
+ const installedVersion = await readInstalledVersion(dep, appDir);
959
+ if (!installedVersion) {
893
960
  missing.push(dep);
894
961
  continue;
895
962
  }
@@ -1329,11 +1396,19 @@ function unmarkedStylesheetHref(tag, basePath = '') {
1329
1396
  * Ported rather than imported because that helper is not on `@webjsdev/server`'s
1330
1397
  * public surface, and because doctor must stay usable when the framework does
1331
1398
  * not resolve from the app dir at all (the #954 fresh-worktree case this same
1332
- * command exists to diagnose). `test/cli/doctor.test.mjs` pins the forms.
1399
+ * command exists to diagnose). The port is intentional and stays. What makes it
1400
+ * safe is that the drift is tested rather than trusted.
1401
+ *
1402
+ * `test/cli/base-path-parity.test.mjs` feeds one input table through BOTH this
1403
+ * function and the server's `readBasePath`, asserting they agree with each other
1404
+ * and with the expected value. Change either side without the other and it reds.
1405
+ * So edit this body only alongside `packages/server/src/base-path.js`, and run
1406
+ * that test. (`test/cli/doctor.test.mjs` covers the check that consumes this,
1407
+ * not the normalization forms themselves.)
1333
1408
  * @param {string} appDir
1334
1409
  * @returns {Promise<string>}
1335
1410
  */
1336
- async function readAppBasePath(appDir) {
1411
+ export async function readAppBasePath(appDir) {
1337
1412
  let raw;
1338
1413
  try {
1339
1414
  const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The files the canonical `gallery/` app owns only because it is a RUNNABLE
3
+ * WebJs app, and which the scaffold (`webjs create`) generates itself.
4
+ *
5
+ * `gallery/` is both the single source of the scaffold's feature gallery AND a
6
+ * live app deployed on its own, so it needs a root layout, a home page, a theme
7
+ * toggle, and the `cn()` helper. The scaffold writes all four itself, with
8
+ * things the gallery's copies cannot carry: the app's `displayName`, the
9
+ * `cspNonce()` wiring, `LayoutProps` typing, the `metadata.icons` favicon, and
10
+ * a `cn.ts` read verbatim from the `@webjsdev/ui` registry so `webjs ui add`
11
+ * stays in lockstep with the kit.
12
+ *
13
+ * So they are the ONE part of `gallery/` that is not scaffold payload. They are
14
+ * dropped from the published bundle (`scripts/sync-scaffold-gallery.mjs`) and
15
+ * skipped by `copyGallery()` so monorepo-dev and installed-npm scaffolding emit
16
+ * byte-identical apps. Paths are POSIX-relative to the gallery root.
17
+ *
18
+ * @type {readonly string[]}
19
+ */
20
+ export const GALLERY_APP_SHELL_FILES = Object.freeze([
21
+ 'app/layout.ts',
22
+ 'app/page.ts',
23
+ 'components/theme-toggle.ts',
24
+ 'lib/utils/cn.ts',
25
+ ]);
26
+
27
+ /**
28
+ * True when `relPath` (POSIX-relative to the gallery root) is a gallery-only
29
+ * app-shell file rather than scaffold payload.
30
+ *
31
+ * @param {string} relPath
32
+ * @returns {boolean}
33
+ */
34
+ export function isGalleryAppShellFile(relPath) {
35
+ return GALLERY_APP_SHELL_FILES.includes(relPath.split('\\').join('/'));
36
+ }
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.52",
3
+ "version": "0.10.54",
4
4
  "type": "module",
5
5
  "description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
6
6
  "bin": {
7
7
  "webjs": "bin/webjs.js"
8
8
  },
9
9
  "scripts": {
10
- "prepack": "node ../../scripts/sync-scaffold-skill.mjs",
11
- "postpack": "node ../../scripts/sync-scaffold-skill.mjs --clean"
10
+ "prepack": "node ../../scripts/sync-scaffold-skill.mjs && node ../../scripts/sync-scaffold-gallery.mjs",
11
+ "postpack": "node ../../scripts/sync-scaffold-skill.mjs --clean && node ../../scripts/sync-scaffold-gallery.mjs --clean"
12
12
  },
13
13
  "files": [
14
14
  "bin",
@@ -38,7 +38,7 @@ Classify the task first, then load the smallest useful reference set. Each refer
38
38
  | Task involves... | Start with |
39
39
  | --------------------------------------------------------------------------- | --------------------------------------------- |
40
40
  | Pages, layouts, dynamic routes, route handlers, metadata, redirects, 404s | `references/routing-and-pages.md` |
41
- | Writing components: reactive props, signals, lifecycle, light vs shadow DOM | `references/components.md` |
41
+ | Writing components: what a component owns, reactive props, signals, lifecycle, light vs shadow DOM | `references/components.md` |
42
42
  | Why a component's JS was or was not downloaded, `webjs elision`, `static interactive = true` | `references/components.md` |
43
43
  | Server actions, mutations, queries, validation, the `ActionResult` envelope | `references/data-and-actions.md` |
44
44
  | Sessions, login flows, route protection, `forbidden()` / `unauthorized()` | `references/auth-and-sessions.md` |
@@ -238,8 +238,12 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
238
238
  - Writing a bare `<form method="post">` and expecting a page `action` export to catch it. There is no such export; bind the action with `action=${fn}` or the submission is a 405.
239
239
  - Putting a submitter's `formaction=${fn}` on anything that is not a submit control, or on a button carrying its own `name` / `value`. The identity IS the button's name/value pair, so both halves are spoken for.
240
240
  - Writing `formmethod="get"` or `formenctype="text/plain"` on a button that BINDS an action. Neither can carry that action's body, so the pair contradicts itself and throws. On a button that binds nothing it is a legal native override and is honoured.
241
- - Binding an action whose file declares `export const method = 'GET'`. That is a 405 at runtime and a `webjs check` error.
241
+ - Binding an action whose file declares `export const method = 'GET'`. Form-bound actions strictly require POST (default). Binding a GET action to a form is a 405 at runtime and a `webjs check` error (`form-action-not-a-get-action`).
242
+ - Leaving read-only RPC server query actions as default `POST`. Always export `export const method = 'GET'` for RPC data queries so arguments ride URL params, ETags/304 caching work, and CSRF is safely bypassed.
243
+ - Writing `method="get"` on a bound `<form action=${fn}>`. WebJs supplies `method="post"` and `formenctype` automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read.
242
244
  - Throwing `redirect()` / `notFound()` inside a `route.ts` handler (uncaught 500). Return a `Response` there.
243
245
  - A placeholder first paint that fetches in `connectedCallback`. SSR does not call `connectedCallback`; put first-paint data in the constructor (server-known inputs) or use `async render()`.
244
246
  - A browser global (`window`, `document`, `localStorage`) in the constructor or `render()`. It throws at SSR; do browser-only work in `connectedCallback`.
245
247
  - Interpolating into a component's `<style>` / `<script>` body. Use `static styles` or Tailwind.
248
+ - Driving a component's markup from a delegated `document` listener in a page or layout, coupled by a class selector. The markup, the state, and the listener belong in one component.
249
+ - Parking one component's UI state on `<body>` or `<html>`. The router's swap range never covers the document shell, so the flag outlives the markup it described. A document-wide SETTING such as the theme is the exception, and a transient effect such as a scroll lock is released in `disconnectedCallback`.
@@ -145,7 +145,7 @@ setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' }));
145
145
 
146
146
  ## The `"webjs"` config block (package.json)
147
147
 
148
- All keys are optional, and a malformed entry in a key the SERVER reads is dropped at boot with a warning, never crashing the pipeline. The one exception is `doctor.gate`, which is read by the `webjs doctor` CLI rather than the server and rejects a bad entry outright (see the doctor severity gate below): a gate whose typo was quietly ignored would leave CI un-gated while looking gated, which is the one thing that mechanism cannot afford.
148
+ All keys are optional, and a malformed entry in a key the SERVER reads is dropped at boot rather than crashing the pipeline, some readers warning as they drop it. `headers` and `redirects` warn on every drop. Both warn for the key itself when it is present but not an array, and for each rule or entry inside it; `headers` has a third level and warns per header directive too. An absent key is the default and says nothing. An UNKNOWN top-level key no longer passes in silence either (#1300): the block is validated against the published JSON Schema once per boot, in dev and in prod alike, and one aggregated warning names what was ignored. So a `"redirect"` typed for `"redirects"` now says so in the server output instead of silently leaving the feature at its default. It never fails the boot. Know its exact reach, since what it passes over looks like what it catches. It reports any unknown TOP-LEVEL key, whatever it is called, and it checks the VALUE of 9 of the 17 known keys, being the one `enum` and the `boolean` / `integer` leaves. It does not descend into a nested object, so a misspelling inside `dev` or `start` is not reported, and it does not type-check the other 8 (`headers`, `redirects`, `basePath`, `allowedOrigins`, `csp`, `dev`, `start`, `doctor`), whose schemas are the free-form ones a blunt check would start refusing working configs over. Whether anything else notices a value this check passes over is up to that value's own reader, and it varies by reader. `doctor.gate` varies furthest, since the boot check never descends into any key and the `webjs doctor` CLI, not the server, is what reads this one, rejecting a bad entry outright (see the doctor severity gate below): a gate whose typo was quietly ignored would leave CI un-gated while looking gated, which is the one thing that mechanism cannot afford.
149
149
 
150
150
  ### Security headers
151
151