@webjsdev/cli 0.10.12 → 0.10.14

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 CHANGED
@@ -10,7 +10,7 @@ Installing this package gives you the `webjs` command.
10
10
  Install once, globally:
11
11
 
12
12
  ```sh
13
- npm i -g @webjsdev/cli
13
+ npm i -g webjsdev
14
14
  ```
15
15
 
16
16
  Then scaffold a new app anywhere:
@@ -42,10 +42,12 @@ webjs create <name> # scaffold a full-stack app (default)
42
42
  webjs create <name> --template api # backend-only API app
43
43
  webjs create <name> --template saas # auth + dashboard + Prisma User model
44
44
 
45
- webjs dev # dev server with live reload
45
+ webjs dev # dev server with live reload (prefer `npm run dev`, which runs the predev prisma generate hook)
46
46
  webjs start # production server (no build step, serves source directly)
47
- webjs check # validate project conventions
47
+ webjs check # validate source-code conventions (CI gate)
48
+ webjs doctor # verify the project/toolchain setup (local onboarding, not CI)
48
49
  webjs test # run server + browser tests
50
+ webjs vendor pin [--download] # pin client deps to a committable importmap (offline/reproducible)
49
51
  webjs db <prisma-subcommand> # prisma passthrough (saas template)
50
52
 
51
53
  webjs ui init # initialise @webjsdev/ui in this project
package/bin/webjs.js CHANGED
@@ -3,6 +3,7 @@ import { resolve, join, dirname } from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { checkNodeInline, nodeInlineMessage } from '../lib/node-preflight.js';
6
+ import { loadAppEnv, resolvePort } from '../lib/port.js';
6
7
 
7
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
9
  const [cmd, ...rest] = process.argv.slice(2);
@@ -43,7 +44,7 @@ const USAGE = `webjs commands:
43
44
  webjs test [--server|--browser] Run server + browser tests
44
45
  webjs check [--json] Run correctness checks on the app (--json emits structured violations)
45
46
  webjs mcp Start the read-only MCP server (routes / actions / components / check)
46
- webjs doctor Verify project health (Node, tsconfig, env, vendor pins, @webjsdev versions, git hook)
47
+ webjs doctor Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook)
47
48
  webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
48
49
  webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors)
49
50
  webjs create <name> [--template full-stack|api|saas] [--no-install] Scaffold a new webjs app
@@ -85,11 +86,24 @@ async function main() {
85
86
  // If we're already inside the --watch child, start the server directly.
86
87
  if (process.env.__WEBJS_DEV_CHILD === '1') {
87
88
  const { startServer } = await import('@webjsdev/server');
88
- const port = Number(flag(rest, '--port', process.env.PORT || 8080));
89
+ // Load `.env` BEFORE resolving the port so a `PORT` set there is in
90
+ // process.env at resolution time (#447). The server loads `.env`
91
+ // too, but that runs too late to affect the port the CLI computes.
92
+ loadAppEnv(process.cwd());
93
+ const port = resolvePort(flag(rest, '--port'));
89
94
  await startServer({ appDir: process.cwd(), port, dev: true });
90
95
  break;
91
96
  }
92
97
 
98
+ // A bare `webjs dev` (not `npm run dev`) skips the `predev` hook, so a
99
+ // Prisma app boots against an ungenerated client and crashes with no
100
+ // hint (#452). Detect that here, in the PARENT only, so the message
101
+ // prints once rather than on every watch restart. Scoped to Prisma apps;
102
+ // a non-Prisma app sees nothing. A hint, not an auto-run.
103
+ const { prismaDevHint } = await import('../lib/prisma-preflight.js');
104
+ const hint = prismaDevHint(process.cwd());
105
+ if (hint) console.error(hint);
106
+
93
107
  // Otherwise, spawn ourselves as a child with node --watch.
94
108
  // This restarts the process on file changes, guaranteeing a fresh
95
109
  // Node ESM module cache. Without this, edits to transitively-imported
@@ -125,7 +139,10 @@ async function main() {
125
139
  }
126
140
  case 'start': {
127
141
  const { startServer } = await import('@webjsdev/server');
128
- const port = Number(flag(rest, '--port', process.env.PORT || 8080));
142
+ // Load `.env` BEFORE resolving the port so a `PORT` set there wins over
143
+ // the 8080 default (#447), same as for `dev`.
144
+ loadAppEnv(process.cwd());
145
+ const port = resolvePort(flag(rest, '--port'));
129
146
  await startServer({ appDir: process.cwd(), port, dev: false });
130
147
  break;
131
148
  }
@@ -141,7 +158,7 @@ async function main() {
141
158
  }
142
159
  case 'ui': {
143
160
  // Delegate to @webjsdev/ui. Bundled as a hard dependency of
144
- // @webjsdev/cli, so `npm install -g @webjsdev/cli` pulls it in
161
+ // @webjsdev/cli, so `npm install -g webjsdev` pulls it in
145
162
  // automatically, and `webjs ui add button` works out of the box
146
163
  // without an extra install in user projects.
147
164
  const { createRequire } = await import('node:module');
@@ -157,7 +174,7 @@ async function main() {
157
174
  entry = userReq.resolve('@webjsdev/ui/bin/webjsui.js');
158
175
  } catch {
159
176
  console.error('@webjsdev/ui could not be resolved.');
160
- console.error('Reinstall the CLI: npm install -g @webjsdev/cli');
177
+ console.error('Reinstall the CLI: npm install -g webjsdev');
161
178
  process.exit(1);
162
179
  }
163
180
  }
@@ -275,7 +292,9 @@ async function main() {
275
292
  // identical to the MCP `check` tool. The non-zero exit on violations is
276
293
  // preserved (an agent gates on the exit code AND parses the report).
277
294
  if (rest.includes('--json')) {
278
- const { projectCheck } = await import('../lib/check-json.js');
295
+ // The projector lives in @webjsdev/mcp (the MCP `check` tool's home),
296
+ // so `check --json` and the MCP tool stay byte-identical (#415).
297
+ const { projectCheck } = await import('@webjsdev/mcp/check-report');
279
298
  console.log(JSON.stringify(projectCheck(violations)));
280
299
  if (violations.length > 0) process.exit(1);
281
300
  break;
@@ -415,7 +434,7 @@ Full docs: https://docs.webjs.com`);
415
434
  const sub = rest[0];
416
435
  const args = rest.slice(1);
417
436
  const appDir = process.cwd();
418
- const { pinAll, unpinPackage, listPinned, auditPinned, findOutdated, updatePinned, readPinFile, SUPPORTED_PROVIDERS } = await import('@webjsdev/server');
437
+ const { pinAll, unpinPackage, listPinned, auditPinned, findOutdated, updatePinned, readPinFile, ensureVendorCommittable, SUPPORTED_PROVIDERS } = await import('@webjsdev/server');
419
438
 
420
439
  // Parse `--from <provider>` once at the top so subcommands share it.
421
440
  // Mirrors importmap-rails's `bin/importmap pin foo --from jsdelivr`.
@@ -496,6 +515,30 @@ Full docs: https://docs.webjs.com`);
496
515
  (downloaded ? ` + ${downloaded} bundle${downloaded === 1 ? '' : 's'}` : '') + '.';
497
516
  const pruneMsg = pruned.length ? ` Pruned ${pruned.length} orphan${pruned.length === 1 ? '' : 's'}.` : '';
498
517
  console.log(pinMsg + pruneMsg);
518
+
519
+ // Make the pins committable. Vendoring is opt-in, so the pins the
520
+ // user just wrote are meant for source control; a `.gitignore`
521
+ // that excludes `.webjs/` would silently swallow them. Fresh
522
+ // scaffolds already carry the `!.webjs/vendor/` exception, so for
523
+ // them this is a no-op. If the output IS ignored, self-heal the
524
+ // app's own `.gitignore`; if there is no `.gitignore` to patch (the
525
+ // ignore comes from a parent repo or `.git/info/exclude`), print a
526
+ // notice so the pins do not vanish from `git status` unexplained.
527
+ const committable = await ensureVendorCommittable(appDir);
528
+ if (committable.patched) {
529
+ console.log(
530
+ `Added the \`.webjs/vendor/\` exception to .gitignore so these pins commit. ` +
531
+ `Run \`git add .gitignore .webjs/vendor\`.`,
532
+ );
533
+ } else if (committable.ignored) {
534
+ console.warn(
535
+ `[webjs] .webjs/vendor/importmap.json is gitignored, so these pins will NOT ` +
536
+ `commit. The ignore is not in this app's .gitignore (a parent repo's .gitignore ` +
537
+ `or .git/info/exclude). Un-ignore it by adding \`!**/.webjs/vendor/\` and ` +
538
+ `\`!**/.webjs/vendor/**\` where the \`.webjs\` exclusion lives, then ` +
539
+ `\`git add .webjs/vendor\`. Verify with \`git check-ignore -q .webjs/vendor/importmap.json\`.`,
540
+ );
541
+ }
499
542
  break;
500
543
  }
501
544
 
@@ -647,19 +690,23 @@ Full docs: https://docs.webjs.com`);
647
690
  process.exit(1);
648
691
  }
649
692
  case 'mcp': {
650
- // Read-only MCP server (#262) over stdio. STDOUT is the JSON-RPC channel,
651
- // so nothing here may write to stdout: the data functions are read-only
652
- // and `runMcpServer` routes all diagnostics to stderr. The CLI version is
653
- // advertised in the initialize handshake's serverInfo.
654
- const { readFileSync } = await import('node:fs');
693
+ // Read-only MCP server (#262, #415) over stdio. STDOUT is the JSON-RPC
694
+ // channel, so nothing here may write to stdout: the data functions are
695
+ // read-only and `runMcpServer` routes all diagnostics to stderr. The
696
+ // implementation lives in the standalone `@webjsdev/mcp` package (also
697
+ // runnable directly as `npx @webjsdev/mcp`); `webjs mcp` delegates to it
698
+ // for back-compat. The version advertised in the initialize handshake is
699
+ // @webjsdev/mcp's own, resolved by its bin, so this passes none.
700
+ const { runMcpServer } = await import('@webjsdev/mcp');
701
+ const { createRequire } = await import('node:module');
702
+ const require = createRequire(import.meta.url);
655
703
  let version = '0.0.0';
656
704
  try {
657
- const pkg = JSON.parse(
658
- readFileSync(join(__dirname, '..', 'package.json'), 'utf8'),
659
- );
660
- version = pkg.version || version;
705
+ const { readFileSync } = await import('node:fs');
706
+ version = JSON.parse(
707
+ readFileSync(require.resolve('@webjsdev/mcp/package.json'), 'utf8'),
708
+ ).version || version;
661
709
  } catch {}
662
- const { runMcpServer } = await import('../lib/mcp.js');
663
710
  await runMcpServer({
664
711
  stdin: process.stdin,
665
712
  stdout: process.stdout,
package/lib/create.js CHANGED
@@ -308,6 +308,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
308
308
  // tsc --noEmit). Not needed at runtime (Node strips types in place), only
309
309
  // to type-check the app.
310
310
  typescript: '^5.6.0',
311
+ '@types/node': '^24.0.0',
311
312
  '@web/test-runner': '^0.20.0',
312
313
  '@web/test-runner-playwright': '^0.11.0',
313
314
  'playwright': '^1.59.0',
@@ -315,14 +316,19 @@ export async function scaffoldApp(name, cwd, opts = {}) {
315
316
  // assertNoA11yViolations() test helper from @webjsdev/core/testing.
316
317
  // Test-only: dynamically imported, never shipped to the app runtime.
317
318
  'axe-core': '^4.10.0',
318
- // tsserver plugin for editor intelligence inside html`` templates.
319
- // @webjsdev/ts-plugin bundles ts-lit-plugin internally, so just one
320
- // plugin entry is needed in tsconfig (see below).
321
- '@webjsdev/ts-plugin': 'latest',
322
- // AI-first component library CLI, preinstalled so `webjs ui add button`
323
- // works immediately after scaffold. Users can remove if they prefer
324
- // to add it later.
325
- '@webjsdev/ui': 'latest',
319
+ // tsserver plugin, wired into tsconfig below. Gives the language
320
+ // INTELLIGENCE (go-to-def, completions, diagnostics, hover inside html``
321
+ // templates) in any tsserver editor with NO editor plugin installed,
322
+ // because editors load tsconfig plugins from node_modules. The `webjs`
323
+ // VS Code extension and webjs.nvim ALSO bundle this plugin (so it works
324
+ // before `npm install` too, and adds template HIGHLIGHTING, which a
325
+ // tsserver plugin can't provide); tsserver dedupes by name, so loading
326
+ // it both ways is a no-op. Standalone, no Lit dependency. Editor-only.
327
+ '@webjsdev/intellisense': 'latest',
328
+ // NOTE: @webjsdev/ui is intentionally NOT pinned. The UI kit is
329
+ // shadcn-style copy-in: `webjs ui add <name>` copies component source
330
+ // into components/ui/ (they import @webjsdev/core, not the kit), and the
331
+ // CLI resolves @webjsdev/ui from its own install.
326
332
  },
327
333
  }, null, 2) + '\n');
328
334
 
@@ -332,6 +338,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
332
338
  module: 'NodeNext',
333
339
  moduleResolution: 'NodeNext',
334
340
  lib: ['ES2022', 'DOM', 'DOM.Iterable'],
341
+ types: ['node'],
335
342
  strict: true,
336
343
  noEmit: true,
337
344
  allowImportingTsExtensions: true,
@@ -347,17 +354,16 @@ export async function scaffoldApp(name, cwd, opts = {}) {
347
354
  // SYNTAX errors. Use a `const` object + union for enum-shaped
348
355
  // values; write fields + constructor assignments explicitly.
349
356
  erasableSyntaxOnly: true,
350
- // @webjsdev/ts-plugin gives the editor:
351
- // • type-check + diagnostics inside html`` templates (via the
352
- // ts-lit-plugin it bundles internally)
353
- // • webjs-aware go-to-definition on custom-element tags
354
- // • "Unknown tag/attribute" suppression for elements registered
355
- // via Class.register('tag-name')
356
- // • attribute auto-complete sourced from `static properties`
357
- // • attribute-value type-check against `declare` annotations
358
- // Editor-only. The framework runs without it.
357
+ // @webjsdev/intellisense (standalone, no Lit dependency) gives the editor,
358
+ // inside html`` templates:
359
+ // • go-to-definition on custom-element tags, attributes, and CSS classes
360
+ // • binding-aware completions (tag names, .prop / ?bool / plain attrs)
361
+ // • diagnostics (value type-checks, unquoted-binding, expressionless .prop)
362
+ // • hover showing the component class / declared member type
363
+ // Editor-only. The framework runs without it. For VS Code / Cursor /
364
+ // Windsurf, the `webjs` extension bundles this automatically.
359
365
  plugins: [
360
- { name: '@webjsdev/ts-plugin' },
366
+ { name: '@webjsdev/intellisense' },
361
367
  ],
362
368
  },
363
369
  // `.webjs/routes.d.ts` is the OPT-IN generated route-types overlay (#258):
package/lib/doctor.js CHANGED
@@ -326,6 +326,84 @@ async function checkVendorPin(appDir, opts) {
326
326
  };
327
327
  }
328
328
 
329
+ /**
330
+ * CHECK: the `.gitignore` does not swallow the committed vendor pin. The pattern
331
+ * for `.webjs/vendor/` is subtle: a bare `.webjs/` line excludes the directory
332
+ * entirely and git cannot re-include children of an excluded parent, so a
333
+ * `!.webjs/vendor/` exception silently does nothing and `webjs vendor pin`
334
+ * output never gets committed. The correct pattern is the depth-robust
335
+ * contents-glob form (see the fix text below / VENDOR_GITIGNORE_LINES in
336
+ * vendor.js): a globstar-prefixed `.webjs/*` plus the matching vendor
337
+ * negations, which ignores transient `.webjs` output at any depth while
338
+ * keeping the committed vendor pin tracked.
339
+ *
340
+ * This was a `webjs check` rule, but inspecting `.gitignore` is a project-config
341
+ * concern (like `tsconfig-erasable`), not source-code correctness, and vendoring
342
+ * is optional, so a doctor WARN fits the domain and severity better than a CI
343
+ * hard-fail (#461). It lives next to `vendor-pin` (same family).
344
+ *
345
+ * PASS/skip when the dir is not a git repo or has no `.gitignore` (the user has
346
+ * not opted into version control yet). Probes two representative paths via
347
+ * `git check-ignore` with the inherited GIT_* env stripped so `cwd` is the sole
348
+ * authority on which repo + .gitignore stack is consulted (a pre-commit hook
349
+ * from a linked worktree exports GIT_WORK_TREE, which would otherwise override
350
+ * cwd-based discovery).
351
+ *
352
+ * @param {string} appDir
353
+ * @returns {Promise<DoctorResult>}
354
+ */
355
+ async function checkVendorGitignore(appDir) {
356
+ const hasGit = existsSync(join(appDir, '.git'));
357
+ const hasGitignore = existsSync(join(appDir, '.gitignore'));
358
+ if (!hasGit || !hasGitignore) {
359
+ return {
360
+ name: 'vendor-gitignore',
361
+ status: 'pass',
362
+ message: 'Not a git checkout with a .gitignore; nothing to verify.',
363
+ };
364
+ }
365
+ const { spawnSync } = await import('node:child_process');
366
+ const {
367
+ GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp,
368
+ ...gitEnv
369
+ } = process.env;
370
+ // Check two representative paths: the pin manifest AND a sample downloaded
371
+ // bundle. A `.gitignore` that allows the manifest but blocks bundles (e.g.
372
+ // `*.js` higher up) would still break `webjs vendor pin --download`.
373
+ // `git check-ignore -q` exits 0 when the path is ignored, 1 when not.
374
+ const probes = [
375
+ '.webjs/vendor/importmap.json',
376
+ '.webjs/vendor/sample-pkg@1.0.0.js',
377
+ ];
378
+ for (const probe of probes) {
379
+ const result = spawnSync('git', ['check-ignore', '-q', probe], {
380
+ cwd: appDir,
381
+ stdio: 'pipe',
382
+ env: gitEnv,
383
+ });
384
+ if (result.status === 0) {
385
+ return {
386
+ name: 'vendor-gitignore',
387
+ status: 'warn',
388
+ message:
389
+ `${probe} is gitignored, but \`webjs vendor pin\` writes files under .webjs/vendor/ that MUST be committed for a production deploy to use the pin (instead of calling api.jspm.io on every cold start). The most common cause: a \`.webjs/\` line that excludes the parent directory before the \`!.webjs/vendor/\` exception can take effect (git semantics: a parent exclusion blocks child negations). A second cause is a broader rule (e.g. \`*.js\` at root) hiding bundle files added by \`webjs vendor pin --download\`.`,
390
+ fix:
391
+ 'Replace `.webjs/` in your .gitignore with this three-line pattern:\n' +
392
+ ' **/.webjs/*\n' +
393
+ ' !**/.webjs/vendor/\n' +
394
+ ' !**/.webjs/vendor/**\n' +
395
+ 'The `**/` prefix ignores `.webjs/` at any depth (so a nested / monorepo app does not leak its generated `.webjs/routes.d.ts`) while still re-including the committed vendor pin. ' +
396
+ 'Verify with `git check-ignore -q .webjs/vendor/importmap.json` (exit 1 means correctly un-ignored).',
397
+ };
398
+ }
399
+ }
400
+ return {
401
+ name: 'vendor-gitignore',
402
+ status: 'pass',
403
+ message: 'The .gitignore keeps .webjs/vendor/ committable.',
404
+ };
405
+ }
406
+
329
407
  /**
330
408
  * Compare an installed version against a semver range PRAGMATICALLY (no semver
331
409
  * dependency). Supports the common scaffold shapes: `latest` / `*` / `workspace:*`
@@ -367,6 +445,200 @@ function satisfiesRange(installed, range) {
367
445
  return null;
368
446
  }
369
447
 
448
+ /**
449
+ * Read the declared dependency ranges of an INSTALLED package from
450
+ * `node_modules/<pkg>/package.json`, for the importmap-coherence check. This
451
+ * is the "already-resolved metadata, no network" path the issue calls for: the
452
+ * package is on disk (it was installed for the importmap to pin it), so its
453
+ * manifest is a local read. Returns null on any failure (not installed,
454
+ * unreadable, unparseable), which the coherence check treats as "could not
455
+ * verify" rather than a conflict.
456
+ *
457
+ * @param {string} appDir
458
+ * @returns {(pkg: string) => Promise<{ dependencies?: Record<string,string>, peerDependencies?: Record<string,string> } | null>}
459
+ */
460
+ function makeInstalledManifestReader(appDir) {
461
+ return async (pkg) => {
462
+ const manifestPath = join(appDir, 'node_modules', pkg, 'package.json');
463
+ if (!existsSync(manifestPath)) return null;
464
+ try {
465
+ const parsed = JSON.parse(await readFile(manifestPath, 'utf8'));
466
+ return {
467
+ dependencies: parsed.dependencies || {},
468
+ peerDependencies: parsed.peerDependencies || {},
469
+ };
470
+ } catch {
471
+ return null;
472
+ }
473
+ };
474
+ }
475
+
476
+ /**
477
+ * Format a coherence conflict list into a single human-readable warning line
478
+ * naming each conflicting pair, the required range, and the pinned version.
479
+ * @param {Array<{ pkg: string, version: string, dependsOn: string, kind: string, requiredRange: string, pinnedVersion: string }>} conflicts
480
+ * @returns {string}
481
+ */
482
+ function formatConflicts(conflicts) {
483
+ return conflicts
484
+ .map(
485
+ (c) =>
486
+ `${c.pkg}@${c.version} needs ${c.dependsOn} ${c.kind === 'peerDependency' ? '(peer) ' : ''}${c.requiredRange} but the importmap pins ${c.dependsOn}@${c.pinnedVersion}`,
487
+ )
488
+ .join('; ');
489
+ }
490
+
491
+ /**
492
+ * CHECK 7, importmap coherence (issue #450). Defense-in-depth that catches an
493
+ * INCOHERENT client dependency graph in the produced importmap, regardless of
494
+ * how the incoherence arose (a hand-edited pin file, a partial vendor pin, or
495
+ * the #446 resolution skew). For each resolved package, it checks that the
496
+ * version actually pinned for every OTHER resolved package it depends on
497
+ * satisfies the declared range; a miss warns naming both packages, the range,
498
+ * and the pinned version.
499
+ *
500
+ * Runs the SAME check over BOTH inputs and produces the same verdict for the
501
+ * same dep set (the parity invariant): the live importmap (resolved the way the
502
+ * server resolves it at runtime) AND the vendored `.webjs/vendor/importmap.json`.
503
+ * A vendored importmap is a freeze of the runtime-resolved graph, so a coherent
504
+ * runtime graph that gets vendored stays coherent.
505
+ *
506
+ * WARN-only and BEST-EFFORT: it never hard-fails (a runtime incoherence is the
507
+ * app's concern, not a broken toolchain), and it degrades to a soft
508
+ * "could not verify" whenever metadata or a live resolve is unavailable rather
509
+ * than failing closed. Dependency metadata is read from the already-installed
510
+ * `node_modules` manifests, no network call of its own; the only network touch
511
+ * is the live importmap resolve, which is wrapped so any failure degrades.
512
+ *
513
+ * The vendor functions + manifest reader are injectable via `opts.coherence`
514
+ * so a test can drive every branch without a network call.
515
+ *
516
+ * @param {string} appDir
517
+ * @param {{ coherence?: {
518
+ * liveImports?: () => Promise<Record<string,string> | null>,
519
+ * vendoredImports?: () => Promise<Record<string,string> | null>,
520
+ * getManifest?: (pkg: string, version: string) => Promise<any>,
521
+ * check?: (imports: Record<string,string>, o: { getManifest: any }) => Promise<{ conflicts: any[], unverified: any[], checked: number }>,
522
+ * } }} opts
523
+ * @returns {Promise<DoctorResult>}
524
+ */
525
+ async function checkImportmapCoherence(appDir, opts) {
526
+ let inj = opts.coherence;
527
+ // Resolve the real vendor toolchain unless a test injected stubs. Both the
528
+ // importmap sources and the coherence-check function come from
529
+ // @webjsdev/server, so a missing install degrades to a WARN, never a throw.
530
+ if (!inj || !inj.check || !inj.liveImports || !inj.vendoredImports || !inj.getManifest) {
531
+ let mod;
532
+ try {
533
+ mod = await import('@webjsdev/server');
534
+ } catch {
535
+ return {
536
+ name: 'importmap-coherence',
537
+ status: 'warn',
538
+ message: 'Could not load the vendor toolchain to check importmap coherence.',
539
+ fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.',
540
+ };
541
+ }
542
+ const real = {
543
+ check: mod.checkImportmapCoherence,
544
+ // Hoist-aware manifest read from the already-installed node_modules (no
545
+ // network of its own), so a monorepo-hoisted dep still resolves. Falls
546
+ // back to the local app/node_modules read if the server build predates
547
+ // getPackageManifest.
548
+ getManifest: typeof mod.getPackageManifest === 'function'
549
+ ? (pkg) => mod.getPackageManifest(pkg, appDir)
550
+ : makeInstalledManifestReader(appDir),
551
+ // Live importmap: resolve vendor imports the way the server does on the
552
+ // first request (prefers the pin file, else a live jspm.io resolve).
553
+ liveImports: async () => {
554
+ try {
555
+ const resolved = await mod.resolveVendorImports(appDir, () => mod.scanBareImports(appDir));
556
+ return resolved && resolved.imports ? resolved.imports : {};
557
+ } catch {
558
+ return null;
559
+ }
560
+ },
561
+ // Vendored importmap: the committed pin file, no network.
562
+ vendoredImports: async () => {
563
+ try {
564
+ const pin = await mod.readPinFile(appDir);
565
+ return pin && pin.imports ? pin.imports : null;
566
+ } catch {
567
+ return null;
568
+ }
569
+ },
570
+ };
571
+ inj = { ...real, ...(inj || {}) };
572
+ }
573
+
574
+ // Gather both importmaps. Either may be absent (no pin file, or a live
575
+ // resolve that failed / found no vendor imports); the check runs over
576
+ // whichever exist, identically.
577
+ let live = null;
578
+ let vendored = null;
579
+ try { live = await inj.liveImports(); } catch { live = null; }
580
+ try { vendored = await inj.vendoredImports(); } catch { vendored = null; }
581
+
582
+ const liveHas = live && Object.keys(live).length > 0;
583
+ const vendoredHas = vendored && Object.keys(vendored).length > 0;
584
+ if (!liveHas && !vendoredHas) {
585
+ return {
586
+ name: 'importmap-coherence',
587
+ status: 'pass',
588
+ message: 'No vendor importmap to check (the app imports no npm packages on the client).',
589
+ };
590
+ }
591
+
592
+ // Run the IDENTICAL check over each available importmap. The function is
593
+ // pure in (imports, getManifest), so the same pinned dep set produces the
594
+ // same verdict whichever input it came from (the runtime-vs-vendored parity
595
+ // invariant). Aggregate the conflicts; dedupe identical ones so a package
596
+ // pinned the same way in both maps is reported once.
597
+ /** @type {Map<string, any>} */
598
+ const conflictsByKey = new Map();
599
+ let anyChecked = 0;
600
+ let anyUnverified = 0;
601
+ for (const imports of [liveHas ? live : null, vendoredHas ? vendored : null]) {
602
+ if (!imports) continue;
603
+ let report;
604
+ try {
605
+ report = await inj.check(imports, { getManifest: inj.getManifest });
606
+ } catch {
607
+ // A check that threw is a "could not verify", never a doctor crash.
608
+ anyUnverified++;
609
+ continue;
610
+ }
611
+ anyChecked += report.checked || 0;
612
+ anyUnverified += (report.unverified || []).length;
613
+ for (const c of report.conflicts || []) {
614
+ conflictsByKey.set(`${c.pkg}@${c.version}->${c.dependsOn}@${c.pinnedVersion}`, c);
615
+ }
616
+ }
617
+
618
+ const conflicts = [...conflictsByKey.values()];
619
+ if (conflicts.length > 0) {
620
+ return {
621
+ name: 'importmap-coherence',
622
+ status: 'warn',
623
+ message: `Incoherent client dependency graph in the importmap: ${formatConflicts(conflicts)}.`,
624
+ fix: 'Align the pinned versions: re-run `webjs vendor pin` to re-resolve a coherent set, or bump the lagging package in package.json and reinstall so the importmap pins a version satisfying every dependent.',
625
+ };
626
+ }
627
+ if (anyChecked === 0 && anyUnverified > 0) {
628
+ return {
629
+ name: 'importmap-coherence',
630
+ status: 'warn',
631
+ message: 'Could not verify importmap coherence (dependency metadata for the pinned packages was unavailable).',
632
+ fix: 'Run `npm install` so the pinned packages are present in node_modules, then re-run `webjs doctor`.',
633
+ };
634
+ }
635
+ return {
636
+ name: 'importmap-coherence',
637
+ status: 'pass',
638
+ message: 'The importmap dependency graph is coherent (every pinned package satisfies its dependents\' declared ranges).',
639
+ };
640
+ }
641
+
370
642
  /**
371
643
  * CHECK 5, @webjsdev/* version coherence. WARN-level only (a version drift is
372
644
  * not a crash). Reads the app package.json `@webjsdev/*` ranges across
@@ -518,6 +790,9 @@ function checkGitHook(appDir) {
518
790
  * required major (defaults to THIS module's package);
519
791
  * - `vendor`: inject the `{ hasVendorPin, findOutdated }` pair so the pin check
520
792
  * runs against a stub instead of a real network call.
793
+ * - `coherence`: inject `{ liveImports, vendoredImports, getManifest, check }`
794
+ * so the importmap-coherence check runs against stub importmaps + metadata
795
+ * instead of a real live resolve / node_modules read.
521
796
  * @returns {Promise<DoctorResult[]>}
522
797
  */
523
798
  export async function runDoctorChecks(appDir, opts = {}) {
@@ -527,7 +802,9 @@ export async function runDoctorChecks(appDir, opts = {}) {
527
802
  checkTsconfig(appDir),
528
803
  checkEnv(appDir),
529
804
  checkVendorPin(appDir, opts),
805
+ checkVendorGitignore(appDir),
530
806
  checkWebjsVersions(appDir),
807
+ checkImportmapCoherence(appDir, opts),
531
808
  Promise.resolve(checkGitHook(appDir)),
532
809
  ]);
533
810
  return results;
package/lib/port.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Port resolution for `webjs dev` / `webjs start` (issue #447).
3
+ *
4
+ * The bug this fixes: the CLI read `process.env.PORT || 8080` BEFORE the
5
+ * server's bootstrap ran `process.loadEnvFile('.env')`, so a `PORT` set in
6
+ * the project's `.env` never reached the port comparison and the server
7
+ * always came up on 8080. Every OTHER `.env` var worked, because the server
8
+ * loads `.env` early enough for everything IT reads; only the port, computed
9
+ * one layer up in the CLI, missed the load.
10
+ *
11
+ * The fix loads `.env` into `process.env` here, in the CLI, before the port
12
+ * is computed. Both functions live in this module so `dev` and `start` share
13
+ * one implementation and the logic is unit-testable without spawning a
14
+ * server.
15
+ */
16
+ import { join } from 'node:path';
17
+
18
+ /**
19
+ * Load `<appDir>/.env` into `process.env`, guarded exactly like the server's
20
+ * own `loadAppEnv` (`packages/server/src/dev.js`): only on a Node with the
21
+ * built-in `process.loadEnvFile`, and swallowing a missing or malformed file.
22
+ *
23
+ * Node's `loadEnvFile` does NOT override a var already present in
24
+ * `process.env`, so a real shell-exported `PORT=NNNN npm run dev` still wins
25
+ * over the file. That "shell beats file" precedence is intentional and
26
+ * matches what the server does after its own load.
27
+ *
28
+ * @param {string} appDir
29
+ */
30
+ export function loadAppEnv(appDir) {
31
+ try {
32
+ if (typeof process.loadEnvFile === 'function') {
33
+ process.loadEnvFile(join(appDir, '.env'));
34
+ }
35
+ } catch {
36
+ // No .env, malformed file, or a Node without loadEnvFile. Fall through
37
+ // silently: the app may not need any env vars, or they may be set via
38
+ // the shell.
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Resolve the server port with precedence `--port` flag > `PORT` (shell env
44
+ * or `.env`, whichever landed in `process.env`) > 8080.
45
+ *
46
+ * Kept pure (no `.env` loading, no `process.env` mutation) so it is trivially
47
+ * testable: the caller loads `.env` first via `loadAppEnv`, then passes the
48
+ * resulting `process.env` in. A non-numeric or empty `--port` / `PORT`
49
+ * surfaces as `NaN`, same as the previous inline `Number(...)`, so behaviour
50
+ * for bad input is unchanged.
51
+ *
52
+ * @param {string | undefined} portFlag The `--port` value, or undefined.
53
+ * @param {NodeJS.ProcessEnv} [env] Defaults to `process.env`.
54
+ * @returns {number}
55
+ */
56
+ export function resolvePort(portFlag, env = process.env) {
57
+ if (portFlag !== undefined) return Number(portFlag);
58
+ if (env.PORT) return Number(env.PORT);
59
+ return 8080;
60
+ }