@webjsdev/cli 0.10.13 → 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 +4 -2
- package/bin/webjs.js +45 -4
- package/lib/doctor.js +277 -0
- package/lib/port.js +60 -0
- package/lib/prisma-preflight.js +168 -0
- package/package.json +1 -1
- package/templates/AGENTS.md +50 -3
- package/templates/CONVENTIONS.md +5 -2
package/README.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -417,7 +434,7 @@ Full docs: https://docs.webjs.com`);
|
|
|
417
434
|
const sub = rest[0];
|
|
418
435
|
const args = rest.slice(1);
|
|
419
436
|
const appDir = process.cwd();
|
|
420
|
-
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');
|
|
421
438
|
|
|
422
439
|
// Parse `--from <provider>` once at the top so subcommands share it.
|
|
423
440
|
// Mirrors importmap-rails's `bin/importmap pin foo --from jsdelivr`.
|
|
@@ -498,6 +515,30 @@ Full docs: https://docs.webjs.com`);
|
|
|
498
515
|
(downloaded ? ` + ${downloaded} bundle${downloaded === 1 ? '' : 's'}` : '') + '.';
|
|
499
516
|
const pruneMsg = pruned.length ? ` Pruned ${pruned.length} orphan${pruned.length === 1 ? '' : 's'}.` : '';
|
|
500
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
|
+
}
|
|
501
542
|
break;
|
|
502
543
|
}
|
|
503
544
|
|
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
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prisma-client preflight for `webjs dev` (#452).
|
|
3
|
+
*
|
|
4
|
+
* The scaffold's `dev` npm script is `webjs dev`, and `npm run dev` runs the
|
|
5
|
+
* `predev` hook (`prisma generate`) FIRST. Invoking the `webjs dev` binary
|
|
6
|
+
* directly (easy to do, and tempting for an AI/CLI) skips `predev`, so the dev
|
|
7
|
+
* server boots against an ungenerated `@prisma/client` and crashes with a raw
|
|
8
|
+
* "did not initialize yet" error and no hint that the canonical command is
|
|
9
|
+
* `npm run dev`. This turns that crash into a one-line, actionable message.
|
|
10
|
+
*
|
|
11
|
+
* Scope is deliberately narrow: it only fires for an app that actually uses
|
|
12
|
+
* Prisma (a `prisma/schema.prisma` OR an `@prisma/client` dependency), and it
|
|
13
|
+
* only HINTS. It never auto-runs an arbitrary `predev` script and never shells
|
|
14
|
+
* out to `prisma generate` on its own, keeping the no-build promise intact.
|
|
15
|
+
*
|
|
16
|
+
* Detection (verified against a real Prisma 6 install): the GENERATED
|
|
17
|
+
* `.prisma/client` target is resolved through standard Node resolution from the
|
|
18
|
+
* app (so a hoisted monorepo, where the client lives at a PARENT `node_modules`,
|
|
19
|
+
* resolves correctly), then read. An ABSENT target, or a present-but-stub target
|
|
20
|
+
* (the ungenerated client whose `PrismaClient` constructor throws the init
|
|
21
|
+
* error), is "ungenerated". A real generated target older than the schema is
|
|
22
|
+
* "stale". We do NOT grep the static `@prisma/client` re-export shim: it is
|
|
23
|
+
* present in both states and never carries the init-error string itself.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, statSync, readFileSync } from 'node:fs';
|
|
26
|
+
import { join, dirname } from 'node:path';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Does this app use Prisma? True if a schema is checked in OR `@prisma/client`
|
|
31
|
+
* is a declared dependency. Either alone is enough; a non-Prisma app has
|
|
32
|
+
* neither and gets no warning.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} cwd
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
export function usesPrisma(cwd) {
|
|
38
|
+
if (existsSync(join(cwd, 'prisma', 'schema.prisma'))) return true;
|
|
39
|
+
try {
|
|
40
|
+
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
|
|
41
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
42
|
+
return Boolean(deps && deps['@prisma/client']);
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Marker the ungenerated `prisma-client-js` stub embeds in its generated target
|
|
49
|
+
// (`node_modules/.prisma/client/index.js`). Verified against a real Prisma 6
|
|
50
|
+
// install: after `npm i @prisma/client` but before `prisma generate`, the
|
|
51
|
+
// generated `.prisma/client` entry IS present but its `PrismaClient` constructor
|
|
52
|
+
// throws `@prisma/client did not initialize yet. Please run "prisma generate"`.
|
|
53
|
+
// A real `prisma generate` replaces that stub with the generated client, which
|
|
54
|
+
// does NOT contain this string. So the marker, read from the GENERATED target
|
|
55
|
+
// (not the static `@prisma/client` shim), is the reliable ungenerated signal.
|
|
56
|
+
const UNGENERATED_MARKER = 'did not initialize yet';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the GENERATED Prisma client entry (`.prisma/client/index.js`) for an
|
|
60
|
+
* app, following standard Node resolution so a hoisted monorepo layout (the
|
|
61
|
+
* generated client at a PARENT `node_modules`, the app under `apps/<x>`) still
|
|
62
|
+
* resolves. Returns a discriminated result so the caller can tell the three
|
|
63
|
+
* cases apart:
|
|
64
|
+
* - `{ kind: 'unresolved' }` - `@prisma/client` itself is not resolvable.
|
|
65
|
+
* - `{ kind: 'no-target' }` - the package resolves but `.prisma/client`
|
|
66
|
+
* does not (a custom `output`, ambiguous).
|
|
67
|
+
* - `{ kind: 'target', path }` - the generated target resolves.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} cwd
|
|
70
|
+
* @returns {{ kind: 'unresolved' } | { kind: 'no-target' } | { kind: 'target', path: string }}
|
|
71
|
+
*/
|
|
72
|
+
function resolveGeneratedClient(cwd) {
|
|
73
|
+
let clientDir;
|
|
74
|
+
try {
|
|
75
|
+
// Resolve @prisma/client AS THE APP would (hoisting-aware), then locate its
|
|
76
|
+
// package dir. The shim itself loads `.prisma/client/default` relative to
|
|
77
|
+
// here, so resolving from this dir follows the same (possibly hoisted) path.
|
|
78
|
+
const appRequire = createRequire(join(cwd, 'noop.js'));
|
|
79
|
+
clientDir = dirname(appRequire.resolve('@prisma/client'));
|
|
80
|
+
} catch {
|
|
81
|
+
return { kind: 'unresolved' };
|
|
82
|
+
}
|
|
83
|
+
const shimRequire = createRequire(join(clientDir, 'noop.js'));
|
|
84
|
+
for (const entry of ['.prisma/client/index.js', '.prisma/client/default.js']) {
|
|
85
|
+
try {
|
|
86
|
+
return { kind: 'target', path: shimRequire.resolve(entry) };
|
|
87
|
+
} catch { /* try the next entry */ }
|
|
88
|
+
}
|
|
89
|
+
return { kind: 'no-target' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Inspect the generated Prisma client state for a Prisma app.
|
|
94
|
+
*
|
|
95
|
+
* Returns one of:
|
|
96
|
+
* - `{ status: 'ok' }` - client generated and not older than the schema.
|
|
97
|
+
* - `{ status: 'missing' }` - schema/dep present but no generated client.
|
|
98
|
+
* - `{ status: 'stale' }` - client exists but the schema is newer than it.
|
|
99
|
+
*
|
|
100
|
+
* Detection resolves the GENERATED `.prisma/client` target through standard Node
|
|
101
|
+
* resolution (so hoisted monorepos are handled) and reads it: an absent target,
|
|
102
|
+
* or a present-but-stub target (the ungenerated `PrismaClient` that throws on
|
|
103
|
+
* construction), is `missing`. A real generated client that is older than the
|
|
104
|
+
* schema is `stale`. A custom-`output` generator whose target Node cannot
|
|
105
|
+
* resolve falls back to `ok` rather than nag a working app (false positives are
|
|
106
|
+
* worse than a missed hint here).
|
|
107
|
+
*
|
|
108
|
+
* @param {string} cwd
|
|
109
|
+
* @returns {{ status: 'ok' | 'missing' | 'stale' }}
|
|
110
|
+
*/
|
|
111
|
+
export function prismaClientState(cwd) {
|
|
112
|
+
const resolved = resolveGeneratedClient(cwd);
|
|
113
|
+
|
|
114
|
+
// @prisma/client not resolvable: the app declared the dep (usesPrisma gated
|
|
115
|
+
// us here) but it is not installed/generated. That is the boot-crash case.
|
|
116
|
+
if (resolved.kind === 'unresolved') return { status: 'missing' };
|
|
117
|
+
|
|
118
|
+
// The package resolves but the default `.prisma/client` target does not: a
|
|
119
|
+
// custom `output` whose location we cannot cheaply verify. Fall back to `ok`
|
|
120
|
+
// rather than nag a working app (false positives are worse than a missed hint).
|
|
121
|
+
if (resolved.kind === 'no-target') return { status: 'ok' };
|
|
122
|
+
|
|
123
|
+
const generatedIndex = resolved.path;
|
|
124
|
+
|
|
125
|
+
// The generated target exists. Is it still the ungenerated stub (its
|
|
126
|
+
// PrismaClient constructor throws the init error)?
|
|
127
|
+
try {
|
|
128
|
+
const body = readFileSync(generatedIndex, 'utf8');
|
|
129
|
+
if (body.includes(UNGENERATED_MARKER)) return { status: 'missing' };
|
|
130
|
+
} catch { /* unreadable: fall through to the stale check, then ok */ }
|
|
131
|
+
|
|
132
|
+
// Generated for real. Is it older than the schema (a stale client)?
|
|
133
|
+
const schema = join(cwd, 'prisma', 'schema.prisma');
|
|
134
|
+
try {
|
|
135
|
+
if (existsSync(schema)) {
|
|
136
|
+
const schemaMtime = statSync(schema).mtimeMs;
|
|
137
|
+
const clientMtime = statSync(generatedIndex).mtimeMs;
|
|
138
|
+
if (schemaMtime > clientMtime) return { status: 'stale' };
|
|
139
|
+
}
|
|
140
|
+
} catch { /* if we can't stat, treat as ok */ }
|
|
141
|
+
|
|
142
|
+
return { status: 'ok' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build the actionable hint for an ungenerated/stale client, or `null` when the
|
|
147
|
+
* app is fine or does not use Prisma. The caller prints it (a warning, not a
|
|
148
|
+
* hard exit) before booting the dev server.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} cwd
|
|
151
|
+
* @returns {string | null}
|
|
152
|
+
*/
|
|
153
|
+
export function prismaDevHint(cwd) {
|
|
154
|
+
if (!usesPrisma(cwd)) return null;
|
|
155
|
+
const { status } = prismaClientState(cwd);
|
|
156
|
+
if (status === 'ok') return null;
|
|
157
|
+
|
|
158
|
+
const reason =
|
|
159
|
+
status === 'stale'
|
|
160
|
+
? 'Your Prisma client looks stale (the schema changed since it was generated).'
|
|
161
|
+
: 'Your Prisma client is not generated yet.';
|
|
162
|
+
return (
|
|
163
|
+
`webjs: ${reason}\n` +
|
|
164
|
+
` The dev server will crash on an ungenerated client. Fix it with either:\n` +
|
|
165
|
+
` npm run dev # canonical: runs \`prisma generate\` (predev) first\n` +
|
|
166
|
+
` webjs db generate # just regenerate the client, then re-run\n`
|
|
167
|
+
);
|
|
168
|
+
}
|
package/package.json
CHANGED
package/templates/AGENTS.md
CHANGED
|
@@ -96,6 +96,52 @@ node_modules/@webjsdev/
|
|
|
96
96
|
Reaching straight for the source is the fastest way to resolve "why
|
|
97
97
|
doesn't X work?" with no documentation guesswork and no stale blog posts.
|
|
98
98
|
|
|
99
|
+
## Use the webjs MCP server (introspection + framework knowledge)
|
|
100
|
+
|
|
101
|
+
This project ships a **read-only Model Context Protocol server** that gives
|
|
102
|
+
you (the AI agent) live, version-accurate facts about THIS app and the
|
|
103
|
+
framework. Prefer it over guessing or recalling webjs from training data,
|
|
104
|
+
which drifts. It mutates nothing.
|
|
105
|
+
|
|
106
|
+
**It is already available, no install needed:** the webjs CLI (a project
|
|
107
|
+
dependency) has it built in as `webjs mcp`. It is an MCP STDIO server (JSON-RPC
|
|
108
|
+
over stdout), so you do not run it in a terminal and read its output. Your MCP
|
|
109
|
+
host (Claude Code, Cursor, etc.) launches it and surfaces its tools, then you
|
|
110
|
+
invoke those tools through the MCP protocol.
|
|
111
|
+
|
|
112
|
+
Claude Code is pre-wired (see `.claude.json`). For another host, register the
|
|
113
|
+
server by pointing it at the CLI (or the equivalent standalone package):
|
|
114
|
+
|
|
115
|
+
```jsonc
|
|
116
|
+
// Cursor: .cursor/mcp.json (or your host's MCP config)
|
|
117
|
+
{ "mcpServers": { "webjs": {
|
|
118
|
+
"command": "npx", "args": ["@webjsdev/cli", "mcp"] // the built-in CLI route
|
|
119
|
+
// equivalent: "command": "npx", "args": ["@webjsdev/mcp"]
|
|
120
|
+
} } }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
What it serves:
|
|
124
|
+
|
|
125
|
+
- **Introspection of this app** (read-only, no module load, no DB side
|
|
126
|
+
effects): `list_routes` (the route table), `list_actions` (server actions
|
|
127
|
+
with their `/__webjs/action/<hash>/<fn>` RPC endpoints), `list_components`
|
|
128
|
+
(registered custom-element tags), `check` (the structured `webjs check`
|
|
129
|
+
violations). Use these to learn the real route/action/component surface
|
|
130
|
+
before editing, instead of grepping or assuming.
|
|
131
|
+
- **Framework knowledge**: an `init` primer (the read-first mental model +
|
|
132
|
+
invariants), a `docs` tool (retrieve a topic or search the `agent-docs`
|
|
133
|
+
corpus), MCP `resources` (the docs corpus + this AGENTS.md), recipe
|
|
134
|
+
`prompts` (guided page/route/action/component workflows), and a `source`
|
|
135
|
+
tool that reads the framework's OWN no-build source from
|
|
136
|
+
`node_modules/@webjsdev/*/src` (what actually runs).
|
|
137
|
+
|
|
138
|
+
You have TWO complementary ways to understand the framework, use whichever
|
|
139
|
+
helps (or both): (1) **grep the full framework source** under
|
|
140
|
+
`node_modules/@webjsdev/*/src`, which is the real no-build code that runs (no
|
|
141
|
+
sourcemaps, no guessing), and (2) **the MCP** for live app introspection plus
|
|
142
|
+
the curated `init` / `docs` / `source` knowledge tools. Reach for either before
|
|
143
|
+
guessing from training data or asking the user.
|
|
144
|
+
|
|
99
145
|
## Editor TS plugin: `@webjsdev/intellisense`
|
|
100
146
|
|
|
101
147
|
This scaffold's `tsconfig.json` lists a single tsserver plugin. It is
|
|
@@ -474,9 +520,10 @@ Production then has no importmap.json and the server falls back to
|
|
|
474
520
|
calling api.jspm.io on every cold start. The `**/` prefix matters too:
|
|
475
521
|
it ignores `.webjs/` at any depth, so an app nested below its repo root
|
|
476
522
|
(a monorepo package) does not leak its generated `.webjs/routes.d.ts`
|
|
477
|
-
into `git status`. The `
|
|
478
|
-
|
|
479
|
-
|
|
523
|
+
into `git status`. The `vendor-gitignore` check (`webjs doctor`)
|
|
524
|
+
verifies the pattern with `git check-ignore` and warns if it regresses
|
|
525
|
+
(it is a project-config / setup concern, not a source-code-correctness
|
|
526
|
+
CI gate).
|
|
480
527
|
|
|
481
528
|
## Imports
|
|
482
529
|
|
package/templates/CONVENTIONS.md
CHANGED
|
@@ -66,7 +66,10 @@ even if the user doesn't explicitly ask.**
|
|
|
66
66
|
Run `npm run doctor` (which runs `webjs doctor`) once after cloning to assert
|
|
67
67
|
the project is set up correctly: the Node major (the strip-types floor), the
|
|
68
68
|
tsconfig `erasableSyntaxOnly` flag, `.env` drift vs `.env.example`, vendor-pin
|
|
69
|
-
freshness,
|
|
69
|
+
freshness, the `.gitignore` keeping `.webjs/vendor/` committable
|
|
70
|
+
(`vendor-gitignore`), importmap-coherence (the resolved client deps agree on a
|
|
71
|
+
shared transitive version), `@webjsdev/*` version coherence, and the git
|
|
72
|
+
pre-commit hook. It
|
|
70
73
|
prints `[pass]` / `[warn]` / `[fail]` per check with an actionable fix line and
|
|
71
74
|
exits non-zero only on a hard fail (a broken toolchain), so a green run means
|
|
72
75
|
`npm run dev` will boot. It is a local onboarding/setup-verify tool, not a CI
|
|
@@ -348,7 +351,7 @@ variables control infrastructure (no config files needed):
|
|
|
348
351
|
| `AUTH_SECRET` | Required for auth JWT signing (32+ random chars) |
|
|
349
352
|
| `AUTH_GOOGLE_ID` | Google OAuth client ID (optional) |
|
|
350
353
|
| `AUTH_GITHUB_ID` | GitHub OAuth client ID (optional) |
|
|
351
|
-
| `PORT` | Server port
|
|
354
|
+
| `PORT` | Server port. Precedence: `--port` flag > `PORT` (a real exported env var or a `PORT` in `.env`) > 8080. |
|
|
352
355
|
| `WEBJS_PUBLIC_*` | Any env var starting with this prefix is exposed to the browser as `process.env.WEBJS_PUBLIC_X`. Components can read it directly. No build step, no transform. Use for API base URLs, Stripe publishable keys, analytics IDs, anything that is intended to be visible client-side. |
|
|
353
356
|
|
|
354
357
|
**Server-only by default.** Any env var without the `WEBJS_PUBLIC_` prefix never reaches the browser. Reading `process.env.DATABASE_URL` from a component returns `undefined`, the same as a typo. The prefix is fail-closed: secrets cannot accidentally leak.
|