@webjsdev/cli 0.10.41 → 0.10.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/webjs.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { resolve, join, dirname } from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
+ import { readFileSync } from 'node:fs';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import { resolveBin } from '../lib/resolve-bin.js';
6
7
  import { dbGenerateTtyHint } from '../lib/db-hints.js';
@@ -11,6 +12,11 @@ import { planDevSupervisor } from '../lib/dev-supervisor.js';
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
13
  const [cmd, ...rest] = process.argv.slice(2);
13
14
 
15
+ // A `--help`/`-h` or `--version`/`-v` request (top-level or after a subcommand)
16
+ // must not be gated by the Node-version preflight, so it works on an old Node.
17
+ const wantsHelp = cmd === '--help' || cmd === '-h' || rest.includes('--help') || rest.includes('-h');
18
+ const wantsVersion = cmd === '--version' || cmd === '-v' || cmd === 'version';
19
+
14
20
  // Node-version preflight (issue #238), INLINE and dependency-free.
15
21
  // This MUST run before any `import @webjsdev/server`: importing the server
16
22
  // package links `src/dev.js`, which references Node 24+ builtins, so on an old
@@ -19,8 +25,9 @@ const [cmd, ...rest] = process.argv.slice(2);
19
25
  // `../lib/node-preflight.js`, which imports nothing), depending only on
20
26
  // `process.versions.node`. The richer `assertNodeVersion` import inside main()
21
27
  // stays as belt-and-suspenders for the link-ok (>= 22.13) cases.
22
- // `help` / no-arg is exempt so a user on an old Node can still read usage.
23
- if (cmd !== 'help' && cmd !== undefined) {
28
+ // `help` / no-arg / any `--help`|`-h` / `version`|`--version`|`-v` request is
29
+ // exempt so a user on an old Node can still read usage and the version.
30
+ if (cmd !== 'help' && cmd !== undefined && !wantsHelp && !wantsVersion) {
24
31
  let engines = '>=24.0.0';
25
32
  try {
26
33
  const { readFileSync } = await import('node:fs');
@@ -47,8 +54,10 @@ const USAGE = `webjs commands:
47
54
  webjs start [--port 8080] Start production server (serves source directly, no build step)
48
55
  webjs test [--server|--browser] Run server + browser tests
49
56
  webjs check [--json] Run correctness checks (--json emits structured violations)
57
+ 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
50
58
  webjs mcp Start the read-only MCP server (routes / actions / components / check)
51
- webjs doctor Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision)
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
52
61
  webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
53
62
  webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors)
54
63
  webjs create <name> [--template full-stack|api|saas] [--db sqlite|postgres] [--runtime node|bun] [--no-install] Scaffold a new webjs app
@@ -70,7 +79,189 @@ const USAGE = `webjs commands:
70
79
  --download: also downloads bundles for offline production
71
80
  webjs vendor unpin <pkg> Remove a specific package from the pin file
72
81
  webjs vendor list Show pinned packages with versions and URLs
73
- webjs help Show this help`;
82
+ webjs version Print the installed @webjsdev/cli version (also: webjs --version / -v)
83
+ webjs help [command] Show this help, or per-command usage + examples (e.g. webjs help routes).
84
+ The flag forms work too: webjs --help / -h for this banner, webjs <command> --help / -h for one command`;
85
+
86
+ /**
87
+ * Per-command help: usage line, one-line summary, an Options table, and an
88
+ * Examples block (#975). `webjs help <cmd>` (and `webjs <cmd> --help`) renders
89
+ * this so an agent sees the exact flags + worked examples instead of guessing
90
+ * from the one-line USAGE row. Keyed by the top-level command; `db` / `ui` /
91
+ * `vendor` document their subcommand shape. Every entry that takes flags lists
92
+ * them in `options` so the flag surface is machine-readable, matching the Remix
93
+ * CLI's per-command Options section.
94
+ * @type {Record<string, { usage: string, summary: string, options?: Array<{ flag: string, description: string }>, examples: string[] }>}
95
+ */
96
+ const HELP = {
97
+ dev: {
98
+ usage: 'webjs dev [--port <n>] [--no-hot]',
99
+ summary: 'Start the dev server with live reload (source is the runtime, no build step).',
100
+ options: [
101
+ { flag: '--port <n>', description: 'Port to listen on (else PORT, else 8080).' },
102
+ { flag: '--no-hot', description: 'Run in-process, without the hot-reload supervisor.' },
103
+ ],
104
+ examples: ['webjs dev', 'webjs dev --port 3000', 'webjs dev --no-hot'],
105
+ },
106
+ start: {
107
+ usage: 'webjs start [--port <n>]',
108
+ summary: 'Start the production server (serves source directly, plain HTTP/1.1).',
109
+ options: [
110
+ { flag: '--port <n>', description: 'Port to listen on (else PORT, else 8080).' },
111
+ ],
112
+ examples: ['webjs start', 'webjs start --port 8080', 'PORT=8080 webjs start'],
113
+ },
114
+ test: {
115
+ usage: 'webjs test [--server] [--browser] [--watch]',
116
+ summary: 'Run the app test suites (server-side node:test and/or browser via web-test-runner).',
117
+ options: [
118
+ { flag: '--server', description: 'Run only the server-side tests.' },
119
+ { flag: '--browser', description: 'Run only the browser tests.' },
120
+ { flag: '--watch', description: 'Re-run on change.' },
121
+ ],
122
+ examples: ['webjs test', 'webjs test --server', 'webjs test --browser --watch'],
123
+ },
124
+ check: {
125
+ usage: 'webjs check [--rules] [--json]',
126
+ summary: 'Run the correctness checks (report-only, no autofix). Exits non-zero on any violation.',
127
+ options: [
128
+ { flag: '--json', description: 'Emit the structured violations + summary as JSON (agent-friendly).' },
129
+ { flag: '--rules', description: 'List the correctness rules instead of running them.' },
130
+ ],
131
+ examples: ['webjs check', 'webjs check --json', 'webjs check --rules'],
132
+ },
133
+ routes: {
134
+ usage: 'webjs routes [--json | --table] [--no-headers]',
135
+ summary: 'Print the route table: each page/route path, its owner file, and (for route handlers) its HTTP methods.',
136
+ options: [
137
+ { flag: '--json', description: 'Emit { pages, apis } as JSON (byte-identical to the MCP list_routes tool).' },
138
+ { flag: '--table', description: 'Flat aligned KIND / PATH / METHODS / FILE columns.' },
139
+ { flag: '--no-headers', description: 'Omit the header row (only with --table); easier to pipe.' },
140
+ ],
141
+ examples: ['webjs routes', 'webjs routes --table', 'webjs routes --table --no-headers', 'webjs routes --json'],
142
+ },
143
+ doctor: {
144
+ usage: 'webjs doctor [--json] [--strict]',
145
+ summary: 'Verify project health. Each result carries a stable code so an agent branches on the failure kind.',
146
+ 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.' },
149
+ ],
150
+ examples: ['webjs doctor', 'webjs doctor --json', 'webjs doctor --strict', 'webjs doctor --json --strict'],
151
+ },
152
+ types: {
153
+ usage: 'webjs types',
154
+ summary: 'Generate .webjs/routes.d.ts (the typed Route href union + per-route params).',
155
+ examples: ['webjs types'],
156
+ },
157
+ typecheck: {
158
+ usage: 'webjs typecheck [tsc args...]',
159
+ summary: "Type-check the app with the project's own tsc --noEmit. Extra args pass through to tsc.",
160
+ examples: ['webjs typecheck', 'webjs typecheck --watch'],
161
+ },
162
+ create: {
163
+ usage: 'webjs create <name> [--template full-stack|api|saas] [--db sqlite|postgres] [--runtime node|bun] [--no-install]',
164
+ summary: 'Scaffold a new app. Defaults: full-stack template, Drizzle + SQLite, Node runtime.',
165
+ options: [
166
+ { flag: '--template <t>', description: 'full-stack (default), api, or saas.' },
167
+ { flag: '--db <d>', description: 'sqlite (default) or postgres.' },
168
+ { flag: '--runtime <r>', description: 'node (default) or bun.' },
169
+ { flag: '--no-install', description: 'Skip the package-manager install step.' },
170
+ ],
171
+ examples: [
172
+ 'webjs create my-app',
173
+ 'webjs create my-api --template api',
174
+ 'webjs create my-saas --template saas --db postgres',
175
+ 'webjs create my-app --runtime bun',
176
+ ],
177
+ },
178
+ db: {
179
+ usage: 'webjs db <generate|migrate|push|studio|seed>',
180
+ summary: 'Database tasks (wraps drizzle-kit); seed runs db/seed.server.ts.',
181
+ examples: ['webjs db generate', 'webjs db migrate', 'webjs db studio', 'webjs db seed'],
182
+ },
183
+ ui: {
184
+ usage: 'webjs ui <init|add|list|view|diff|info> [names...]',
185
+ summary: 'AI-first component library CLI. Requires @webjsdev/ui installed in the project.',
186
+ examples: ['webjs ui init', 'webjs ui add button card', 'webjs ui list'],
187
+ },
188
+ vendor: {
189
+ usage: 'webjs vendor <pin|unpin|list|audit|outdated|update> [--from <provider>] [--download]',
190
+ summary: 'Pin client-side npm packages into .webjs/vendor/importmap.json.',
191
+ options: [
192
+ { flag: '--from <provider>', description: 'jspm (default), jsdelivr, unpkg, or skypack.' },
193
+ { flag: '--download', description: 'Also download the bundles for offline production (with pin).' },
194
+ ],
195
+ examples: ['webjs vendor pin', 'webjs vendor pin --download', 'webjs vendor list', 'webjs vendor outdated'],
196
+ },
197
+ mcp: {
198
+ usage: 'webjs mcp',
199
+ summary: 'Start the read-only MCP server (routes / actions / components / check + a docs/source knowledge layer).',
200
+ examples: ['webjs mcp'],
201
+ },
202
+ version: {
203
+ usage: 'webjs version',
204
+ summary: 'Print the installed @webjsdev/cli version. Also available as webjs --version / -v.',
205
+ examples: ['webjs version', 'webjs --version'],
206
+ },
207
+ };
208
+
209
+ /**
210
+ * Render `webjs help <cmd>` to stdout: usage, summary, an Options table (when
211
+ * the command takes flags), and Examples, in the Remix-CLI section shape. A
212
+ * universal `-h, --help` option row is appended so every command advertises it.
213
+ * Returns true if the command was found, false otherwise (so the caller can
214
+ * exit non-zero on an unknown help topic, matching the Remix CLI).
215
+ * @param {string} name
216
+ * @returns {boolean}
217
+ */
218
+ function printCommandHelp(name) {
219
+ const h = HELP[name];
220
+ if (!h) {
221
+ console.error(`Unknown help topic "${name}".\n`);
222
+ console.error(USAGE);
223
+ return false;
224
+ }
225
+ console.log(`Usage: ${h.usage}\n`);
226
+ console.log(` ${h.summary}\n`);
227
+ // A command that forwards to an external tool does NOT show webjs's own help
228
+ // for `--help`; word its `-h, --help` row to say so, rather than the generic
229
+ // "Show this help." which would be false for those three commands.
230
+ const tool = HELP_FLAG_PASSTHROUGH_TOOL[name];
231
+ const helpRow = tool
232
+ ? { flag: '-h, --help', description: `Forwarded to ${tool} (this command wraps it).` }
233
+ : { flag: '-h, --help', description: 'Show this help.' };
234
+ const options = [...(h.options || []), helpRow];
235
+ const width = Math.max(...options.map((o) => o.flag.length));
236
+ console.log('Options:');
237
+ for (const o of options) console.log(` ${o.flag.padEnd(width)} ${o.description}`);
238
+ console.log('\nExamples:');
239
+ for (const ex of h.examples) console.log(` ${ex}`);
240
+ return true;
241
+ }
242
+
243
+ /**
244
+ * Commands that forward their remaining args to an external CLI, so a trailing
245
+ * `--help` / `-h` should reach THAT tool's own help, not webjs's. Maps the
246
+ * command to the tool it wraps (used both to skip the help-flag intercept and
247
+ * to word the `-h, --help` row in that command's help accurately).
248
+ * @type {Record<string, string>}
249
+ */
250
+ const HELP_FLAG_PASSTHROUGH_TOOL = { typecheck: 'tsc', db: 'drizzle-kit', ui: '@webjsdev/ui' };
251
+ const HELP_FLAG_PASSTHROUGH = new Set(Object.keys(HELP_FLAG_PASSTHROUGH_TOOL));
252
+
253
+ /**
254
+ * The installed `@webjsdev/cli` version, read from this package's own
255
+ * package.json. Falls back to `0.0.0` if unreadable (never throws).
256
+ * @returns {string}
257
+ */
258
+ function readCliVersion() {
259
+ try {
260
+ return JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version || '0.0.0';
261
+ } catch {
262
+ return '0.0.0';
263
+ }
264
+ }
74
265
 
75
266
  /** @param {string[]} args */
76
267
  function flag(args, name, def) {
@@ -116,12 +307,34 @@ async function startDevParallelTasks(commands, cwd) {
116
307
  }
117
308
 
118
309
  async function main() {
119
- // Preflight: webjs needs Node 24+ (built-in TS strip + recursive fs.watch).
310
+ // `--version` / `-v` (top level): print the installed CLI version and exit.
311
+ if (cmd === '--version' || cmd === '-v') {
312
+ console.log(readCliVersion());
313
+ return;
314
+ }
315
+ // `--help` / `-h` (#975): a top-level flag (webjs --help / -h) prints the
316
+ // banner; the same flag AFTER a subcommand (webjs routes --help) prints that
317
+ // command's help and short-circuits it. Handled before the Node preflight so
318
+ // it works even on an old Node. A command that forwards its args to an
319
+ // external CLI (typecheck/db/ui, in HELP_FLAG_PASSTHROUGH) is skipped so the
320
+ // tool's own --help still reaches it; an UNRECOGNISED command is not
321
+ // intercepted either, so it falls through to the Unknown-command error
322
+ // (exit 1) rather than a silent 0.
323
+ if (cmd === '--help' || cmd === '-h') {
324
+ console.log(USAGE);
325
+ return;
326
+ }
327
+ if (cmd && HELP[cmd] && !HELP_FLAG_PASSTHROUGH.has(cmd) && (rest.includes('--help') || rest.includes('-h'))) {
328
+ printCommandHelp(cmd);
329
+ return;
330
+ }
331
+
332
+ // Node preflight: WebJs needs Node 24+ (built-in TS strip + recursive fs.watch).
120
333
  // Run before any subcommand so an older Node fails fast with a clear,
121
334
  // actionable message naming the found + required version, exiting non-zero
122
- // instead of crashing cryptically later. `help` is exempt so a user on an
123
- // old Node can still read usage.
124
- if (cmd !== 'help' && cmd !== undefined) {
335
+ // instead of crashing cryptically later. `help` / a help or version request
336
+ // is exempt so a user on an old Node can still read usage and the version.
337
+ if (cmd !== 'help' && cmd !== undefined && !wantsHelp && !wantsVersion) {
125
338
  const { assertNodeVersion } = await import('@webjsdev/server');
126
339
  assertNodeVersion({ onFail: 'exit' });
127
340
  }
@@ -461,14 +674,6 @@ async function main() {
461
674
  // app's concern, not a broken toolchain).
462
675
  const { runDoctorChecks } = await import('../lib/doctor.js');
463
676
  const results = await runDoctorChecks(process.cwd());
464
- const marker = { pass: '[pass]', warn: '[warn]', fail: '[fail]' };
465
- console.log('webjs doctor: project-health checklist\n');
466
- for (const r of results) {
467
- console.log(` ${marker[r.status]} ${r.name}`);
468
- console.log(` ${r.message}`);
469
- if (r.fix && r.status !== 'pass') console.log(` Fix: ${r.fix}`);
470
- console.log();
471
- }
472
677
  const counts = results.reduce((acc, r) => {
473
678
  acc[r.status] = (acc[r.status] || 0) + 1;
474
679
  return acc;
@@ -476,15 +681,121 @@ async function main() {
476
681
  const pass = counts.pass || 0;
477
682
  const warn = counts.warn || 0;
478
683
  const fail = counts.fail || 0;
684
+ // `--strict` also fails the exit on warnings, so an agent can gate on a
685
+ // fully-clean toolchain (drift / staleness / pin freshness) in a fix loop,
686
+ // not just on a hard toolchain break. Default keeps warnings non-fatal.
687
+ const strict = rest.includes('--strict');
688
+ const failing = fail > 0 || (strict && warn > 0);
689
+
690
+ // --json emits the raw DoctorResult[] (each carries a stable `code`) plus
691
+ // a summary, so an agent consumes structured data instead of scraping the
692
+ // text. Shape mirrors `check --json`: a top-level array-bearing object
693
+ // with a `summary` count. The non-zero exit is preserved (an agent gates
694
+ // on the exit code AND parses the report).
695
+ if (rest.includes('--json')) {
696
+ console.log(JSON.stringify({
697
+ results,
698
+ summary: { pass, warn, fail, strict, ok: !failing },
699
+ }));
700
+ if (failing) process.exit(1);
701
+ break;
702
+ }
703
+
704
+ const marker = { pass: '[pass]', warn: '[warn]', fail: '[fail]' };
705
+ console.log('webjs doctor: project-health checklist\n');
706
+ for (const r of results) {
707
+ console.log(` ${marker[r.status]} ${r.name} (${r.code})`);
708
+ console.log(` ${r.message}`);
709
+ if (r.fix && r.status !== 'pass') console.log(` Fix: ${r.fix}`);
710
+ console.log();
711
+ }
479
712
  console.log(` ${pass} passed, ${warn} warning(s), ${fail} failed.`);
480
- if (fail > 0) {
481
- console.error(
482
- `\nwebjs doctor: ${fail} hard check(s) failed. Fix the toolchain issue(s) above.`,
483
- );
713
+ if (failing) {
714
+ const reason = fail > 0
715
+ ? `${fail} hard check(s) failed. Fix the toolchain issue(s) above.`
716
+ : `${warn} warning(s) found and --strict was set.`;
717
+ console.error(`\nwebjs doctor: ${reason}`);
484
718
  process.exit(1);
485
719
  }
486
720
  break;
487
721
  }
722
+ case 'routes': {
723
+ // Print the app route table to stdout (#975): every page (path, owner
724
+ // file, dynamic params) and every route.{js,ts} API handler (path, owner
725
+ // file, HTTP methods). Reuses the ONE route walker (`buildRouteTable`, the
726
+ // same walk that backs `webjs types` and the dev server) and the shared
727
+ // `projectRoutes` projector, so `--json` is byte-identical to the MCP
728
+ // `list_routes` tool. Read-only: no module load, no autofix.
729
+ const { buildRouteTable } = await import('@webjsdev/server');
730
+ const { projectRoutes } = await import('@webjsdev/mcp/routes-report');
731
+ const { extractRouteMethods } = await import('@webjsdev/mcp');
732
+ const { readFile } = await import('node:fs/promises');
733
+ const appDir = process.cwd();
734
+ const table = await buildRouteTable(appDir);
735
+ const report = await projectRoutes(table, { appDir, readFile, extractRouteMethods });
736
+
737
+ // --json: the machine contract, identical to the MCP `list_routes` shape.
738
+ if (rest.includes('--json')) {
739
+ console.log(JSON.stringify(report));
740
+ break;
741
+ }
742
+
743
+ const { pages, apis } = report;
744
+ // A page is reached via GET (its server render); a route.{js,ts} exposes
745
+ // exactly its exported verbs. Present both as one path -> methods -> file
746
+ // view. Dynamic pages append their param names.
747
+ const pageMethods = 'GET';
748
+
749
+ // --table: flat, aligned columns (KIND / PATH / METHODS / FILE), the
750
+ // easiest shape for an agent to scan or a human to grep. `--no-headers`
751
+ // drops the header row so the output pipes cleanly into awk/cut.
752
+ if (rest.includes('--table')) {
753
+ /** @type {Array<[string,string,string,string]>} */
754
+ const rows = [];
755
+ if (!rest.includes('--no-headers')) rows.push(['KIND', 'PATH', 'METHODS', 'FILE']);
756
+ for (const p of pages) {
757
+ rows.push(['page', p.path + (p.params ? ` [${p.params.join(', ')}]` : ''), pageMethods, p.file]);
758
+ }
759
+ for (const a of apis) {
760
+ rows.push(['api', a.path, a.methods.join(', ') || '(none)', a.file]);
761
+ }
762
+ // With --no-headers and no routes there are no rows; nothing to print.
763
+ if (rows.length === 0) break;
764
+ const widths = [0, 1, 2].map((c) => Math.max(...rows.map((r) => r[c].length)));
765
+ for (const r of rows) {
766
+ console.log(
767
+ `${r[0].padEnd(widths[0])} ${r[1].padEnd(widths[1])} ${r[2].padEnd(widths[2])} ${r[3]}`,
768
+ );
769
+ }
770
+ break;
771
+ }
772
+
773
+ // Default: a grouped tree.
774
+ console.log(`webjs routes: ${pages.length} page(s), ${apis.length} API route(s)\n`);
775
+ if (pages.length) {
776
+ console.log('Pages');
777
+ const pathW = Math.max(...pages.map((p) => p.path.length));
778
+ for (const p of pages) {
779
+ const params = p.params ? ` ${p.params.map((n) => `[${n}]`).join(' ')}` : '';
780
+ console.log(` ${p.path.padEnd(pathW)} ${p.file}${params}`);
781
+ }
782
+ console.log();
783
+ }
784
+ if (apis.length) {
785
+ console.log('API routes');
786
+ const pathW = Math.max(...apis.map((a) => a.path.length));
787
+ const methW = Math.max(...apis.map((a) => (a.methods.join(', ') || '(none)').length));
788
+ for (const a of apis) {
789
+ const methods = a.methods.join(', ') || '(none)';
790
+ console.log(` ${a.path.padEnd(pathW)} ${methods.padEnd(methW)} ${a.file}`);
791
+ }
792
+ console.log();
793
+ }
794
+ if (!pages.length && !apis.length) {
795
+ console.log(' No routes found. Add an app/page.ts or an app/**/route.ts.');
796
+ }
797
+ break;
798
+ }
488
799
  case 'types': {
489
800
  // Generate `.webjs/routes.d.ts` from the app's `app/` routes (#258),
490
801
  // narrowing the @webjsdev/core `Route` href union + per-route `params`.
@@ -899,7 +1210,21 @@ Full docs: https://docs.webjs.dev`);
899
1210
  });
900
1211
  break;
901
1212
  }
1213
+ case 'version':
1214
+ // Print the installed CLI version (#975). Also reachable as the top-level
1215
+ // `webjs --version` / `-v` flag, handled at the top of main().
1216
+ console.log(readCliVersion());
1217
+ break;
902
1218
  case 'help':
1219
+ // `webjs help <cmd>` prints that command's usage + Options + Examples
1220
+ // (#975); a bare `webjs help` prints the full banner. An unknown topic
1221
+ // exits non-zero (printCommandHelp returns false), matching the Remix CLI.
1222
+ if (rest[0]) {
1223
+ if (!printCommandHelp(rest[0])) process.exit(1);
1224
+ } else {
1225
+ console.log(USAGE);
1226
+ }
1227
+ break;
903
1228
  case undefined:
904
1229
  console.log(USAGE);
905
1230
  break;
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 { leanComponentSource } from './lean-copy.js';
21
22
 
22
23
  /**
23
24
  * Detect which package manager invoked us. Reads `npm_config_user_agent`,
@@ -120,12 +121,16 @@ async function readUiComponent(name) {
120
121
  const raw = await readFile(src, 'utf8');
121
122
  // The registry component imports cn() via a relative `../lib/utils.ts`; rewrite
122
123
  // it to the scaffolded app's aliased path (cn lives at lib/utils/cn.ts).
123
- return raw
124
+ const rewritten = raw
124
125
  .replaceAll("'../lib/utils.ts'", "'#lib/utils/cn.ts'")
125
126
  .replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"')
126
127
  // onBeforeCache lives in its own client-only module so cn() stays pure (#819).
127
128
  .replaceAll("'../lib/dom.ts'", "'#lib/utils/dom.ts'")
128
129
  .replaceAll('"../lib/dom.ts"', '"#lib/utils/dom.ts"');
130
+ // Strip the worked @example from a Tier-1 helper (same as `webjs ui add`), so
131
+ // the scaffolded component is lean and the example is served on demand. The
132
+ // shared helper is used by the saas-template copier too, so they cannot drift.
133
+ return leanComponentSource(rewritten, name);
129
134
  }
130
135
 
131
136
  /**
package/lib/doctor.js CHANGED
@@ -44,9 +44,48 @@ import { checkNodeInline } from './node-preflight.js';
44
44
 
45
45
  /**
46
46
  * @typedef {'pass' | 'warn' | 'fail'} DoctorStatus
47
- * @typedef {{ name: string, status: DoctorStatus, message: string, fix?: string }} DoctorResult
47
+ * @typedef {{ name: string, code: string, status: DoctorStatus, message: string, fix?: string }} DoctorResult
48
48
  */
49
49
 
50
+ /**
51
+ * Stable machine-readable code per check (#975), so an agent consuming
52
+ * `webjs doctor --json` branches on the failure KIND, not the human message
53
+ * text (which is free to change). The `name` stays the display identity (some
54
+ * are kebab-case, two are prose); the `code` is the durable contract, a
55
+ * SCREAMING_SNAKE_CASE constant that never changes for a given check. Attached
56
+ * centrally in `runDoctorChecks` so every check function stays focused on its
57
+ * own logic. Mirrors Remix's `DoctorFindingCode` enum (its `doctor/types.ts`).
58
+ *
59
+ * Keyed by each check's `name`. A missing entry falls back to a name-derived
60
+ * code (see `codeForName`), but every shipped check is listed here explicitly
61
+ * and a drift test asserts each result carries one of these codes.
62
+ * @type {Record<string, string>}
63
+ */
64
+ export const DOCTOR_CODES = {
65
+ 'node-version': 'NODE_VERSION',
66
+ 'tsconfig-erasable': 'TSCONFIG_ERASABLE',
67
+ 'env-drift': 'ENV_DRIFT',
68
+ 'vendor-pin': 'VENDOR_PIN',
69
+ 'vendor-gitignore': 'VENDOR_GITIGNORE',
70
+ 'webjs-versions': 'WEBJS_VERSIONS',
71
+ 'framework-resolve': 'FRAMEWORK_RESOLVE',
72
+ 'importmap-coherence': 'IMPORTMAP_COHERENCE',
73
+ 'git-hook': 'GIT_HOOK',
74
+ 'Page/layout elision (carrier hygiene)': 'ELISION_CARRIERS',
75
+ 'Static build outputs (dev.regenerate freshness)': 'STATIC_ASSET_FRESHNESS',
76
+ };
77
+
78
+ /**
79
+ * The stable code for a check name: the explicit `DOCTOR_CODES` entry, else a
80
+ * best-effort derivation (uppercased, non-alphanumerics collapsed to `_`) so a
81
+ * newly-added check that forgets its map entry still gets a non-empty code.
82
+ * @param {string} name
83
+ * @returns {string}
84
+ */
85
+ export function codeForName(name) {
86
+ return DOCTOR_CODES[name] || name.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
87
+ }
88
+
50
89
  /**
51
90
  * Read the CLI package's own `engines.node` so the required Node major lives in
52
91
  * one place (mirrors how `bin/webjs.js` sources it). Falls back to `>=24.0.0`.
@@ -1020,5 +1059,8 @@ export async function runDoctorChecks(appDir, opts = {}) {
1020
1059
  checkElisionCarriers(appDir),
1021
1060
  checkStaticAssetFreshness(appDir),
1022
1061
  ]);
1062
+ // Attach the stable machine code to every result (#975). Centralized here so
1063
+ // each check function stays free of the code-contract concern.
1064
+ for (const r of results) r.code = codeForName(r.name);
1023
1065
  return results;
1024
1066
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The scaffold's lean-copy of a ui component (#983).
3
+ *
4
+ * `webjs create` copies a few `@webjsdev/ui` registry components into a
5
+ * generated app. To match what `webjs ui add` writes, a Tier-1 helper's worked
6
+ * `@example` is stripped (the example is served on demand by `webjs ui view` /
7
+ * the MCP `ui` tool), while a Tier-2 element file is kept whole. Both scaffold
8
+ * copiers (`create.js` and `saas-template.js`) go through THIS one helper so
9
+ * they cannot drift.
10
+ *
11
+ * The strip primitives live in `@webjsdev/ui/registry/extract`; if that subpath
12
+ * cannot be resolved, this degrades to a no-op (keep the example) so the strip
13
+ * is never a reason `webjs create` fails.
14
+ *
15
+ * @module lean-copy
16
+ */
17
+
18
+ let _mod = null;
19
+
20
+ async function loadPrimitives() {
21
+ if (_mod) return _mod;
22
+ try {
23
+ const m = await import('@webjsdev/ui/registry/extract');
24
+ _mod = { stripExample: m.stripExample, isCustomElementSource: m.isCustomElementSource };
25
+ } catch {
26
+ _mod = { stripExample: (s) => s, isCustomElementSource: () => true };
27
+ }
28
+ return _mod;
29
+ }
30
+
31
+ /**
32
+ * Return the component source as `webjs ui add` would write it: a Tier-1 helper
33
+ * has its worked `@example` stripped and a pointer left; a Tier-2 element is
34
+ * returned unchanged.
35
+ *
36
+ * @param {string} source the component source (imports already rewritten)
37
+ * @param {string} name the component name (for the pointer)
38
+ * @returns {Promise<string>}
39
+ */
40
+ export async function leanComponentSource(source, name) {
41
+ const { stripExample, isCustomElementSource } = await loadPrimitives();
42
+ return isCustomElementSource(source) ? source : stripExample(source, name);
43
+ }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { mkdir, writeFile, readFile } from 'node:fs/promises';
7
7
  import { bunifyProse } from './runtime-rewrite.js';
8
+ import { leanComponentSource } from './lean-copy.js';
8
9
  import { existsSync } from 'node:fs';
9
10
  import { join, resolve, dirname } from 'node:path';
10
11
  import { fileURLToPath } from 'node:url';
@@ -25,7 +26,7 @@ async function readUiComponent(name) {
25
26
  const raw = await readFile(src, 'utf8');
26
27
  // The registry component imports cn() via a relative `../lib/utils.ts`; rewrite
27
28
  // it to the scaffolded app's aliased path (cn lives at lib/utils/cn.ts).
28
- return raw
29
+ const rewritten = raw
29
30
  .replaceAll("'../lib/utils.ts'", "'#lib/utils/cn.ts'")
30
31
  .replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"')
31
32
  // onBeforeCache lives in its own client-only module so cn() stays pure (#819).
@@ -33,6 +34,9 @@ async function readUiComponent(name) {
33
34
  // (which resolves to a nonexistent components/lib/dom.ts) and fails typecheck.
34
35
  .replaceAll("'../lib/dom.ts'", "'#lib/utils/dom.ts'")
35
36
  .replaceAll('"../lib/dom.ts"', '"#lib/utils/dom.ts"');
37
+ // Strip a Tier-1 helper's worked @example (same as create.js + `webjs ui add`)
38
+ // so switch / checkbox are lean, not just the full-stack base set (#983).
39
+ return leanComponentSource(rewritten, name);
36
40
  }
37
41
 
38
42
  /** Copy named registry components into `<appDir>/components/ui/`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.41",
3
+ "version": "0.10.43",
4
4
  "type": "module",
5
5
  "description": "webjs CLI - dev, start, create, db",
6
6
  "bin": {
@@ -44,6 +44,7 @@ Classify the task first, then load the smallest useful reference set. Each refer
44
44
  | Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout | `references/styling.md` |
45
45
  | Client router, prefetch, frames, view transitions, Suspense streaming | `references/client-router-and-streaming.md` |
46
46
  | Optimistic UI for a user-facing mutation | `references/optimistic-ui.md` |
47
+ | The `@webjsdev/ui` component kit (a `components.json` is present): class helpers, tokens, `add` / `view`, the MCP `ui` tool | `references/ui-kit.md` |
47
48
  | TypeScript at runtime, erasable syntax, full-stack types | `references/typescript.md` |
48
49
  | Unit, browser, e2e tests, the `handle()` harness, Bun parity | `references/testing.md` |
49
50
  | Auth, caching, env vars, rate limit, file storage, the `webjs` config block | `references/built-ins.md` |
@@ -0,0 +1,68 @@
1
+ # The `@webjsdev/ui` component kit
2
+
3
+ Load this when the app has a `components.json` (it uses `@webjsdev/ui`, the
4
+ shadcn-style kit for WebJs). The source is copied into your repo (`components/ui/`),
5
+ so you own and edit it. Two tiers:
6
+
7
+ - **Tier 1, class helpers (23 components).** Pure functions returning Tailwind
8
+ class strings (`buttonClass({ variant })`, `cardClass()`), composed with
9
+ whatever native element you write. Reach for these instead of expanding
10
+ Tailwind by hand: the call site is a fraction of the tokens and the class list
11
+ cannot drift.
12
+ - **Tier 2, stateful custom elements (9 components).** `<ui-dialog>`, `<ui-tabs>`,
13
+ `<ui-dropdown-menu>`, and friends own their ARIA (focus trap, roving tabindex,
14
+ `aria-controls` / `inert`, live regions). Write the tag and the accessible
15
+ behaviour comes with it. Do NOT hand-roll these; the wiring is easy to get
16
+ subtly wrong.
17
+
18
+ ## The workflow: query for the structure, do not guess it
19
+
20
+ `add` copies a Tier-1 component's class helpers plus a lean header (what each
21
+ helper is, the accessibility obligations) and a one-line pointer. It does NOT
22
+ copy the worked structural example, because that example is guidance you consume
23
+ once while composing, not code that should sit in your repo. Get the full
24
+ paste-ready structure on demand:
25
+
26
+ - **MCP `ui` tool** (preferred when available): call `ui` with no args for the
27
+ kit inventory (each component's tier, helper signatures, npm deps); pass
28
+ `{ name: "accordion" }` for one component's helper signatures, the paste-ready
29
+ structural example, the accessibility header, and deps.
30
+ - **CLI**: `webjs ui list` (inventory), `webjs ui view <name>` (the projected
31
+ view plus the full source). Same data as the MCP tool (one shared projector).
32
+
33
+ So the loop is: `add` the component, then query `ui <name>` (MCP) or
34
+ `webjs ui view <name>` for the accessible structure, paste it, and fill it in.
35
+
36
+ ## Setup and resolution
37
+
38
+ - `webjs ui init` writes `components.json`, `lib/utils.ts`, and the CSS design
39
+ tokens the helpers render against (`--background`, `--foreground`,
40
+ `--destructive`, ...). It HARD-FAILS if the tokens cannot be written, so a
41
+ clean exit means the kit is styled. `add` self-heals the tokens if they go
42
+ missing.
43
+ - Resolution is LOCAL-FIRST: `init` / `add` / `list` / `view` read the registry
44
+ that ships inside the installed `@webjsdev/ui`, with no network. This pins you
45
+ to the installed version; run `webjs ui diff` to see where your local copies
46
+ drift from the upstream (that command alone compares against the live registry).
47
+
48
+ ## Inventory (run `webjs ui list` or the MCP `ui` tool for the authoritative, current set)
49
+
50
+ **Tier 1 (class helpers):** accordion, alert, aspect-ratio, avatar, badge,
51
+ breadcrumb, button, card, checkbox, collapsible, input, kbd, label,
52
+ native-select, pagination, popover, progress, radio-group, separator, skeleton,
53
+ switch, table, textarea.
54
+
55
+ **Tier 2 (custom elements, own their ARIA):** alert-dialog, dialog,
56
+ dropdown-menu, hover-card, sonner, tabs, tooltip, plus toggle and toggle-group
57
+ (these two register an element AND export a `*Class` helper).
58
+
59
+ ## Idioms
60
+
61
+ - A helper is a function, so compose it: `class=${buttonClass({ variant: 'outline' })}`.
62
+ The unquoted `${...}` is a normal `html` attribute hole.
63
+ - Tier-1 helpers assume the design tokens exist; if a component paints unstyled,
64
+ the tokens are missing (re-run `webjs ui init` or let `add` self-heal them).
65
+ - Custom elements are display-only-safe at SSR and hydrate in the browser, the
66
+ standard WebJs component model (`references/components.md`).
67
+
68
+ Full per-package reference lives in the installed `@webjsdev/ui/AGENTS.md`.