@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 +5 -3
- package/bin/webjs.js +64 -17
- package/lib/create.js +24 -18
- package/lib/doctor.js +277 -0
- package/lib/port.js +60 -0
- package/lib/prisma-preflight.js +168 -0
- package/package.json +3 -7
- package/templates/.claude.json +1 -1
- package/templates/AGENTS.md +66 -17
- package/templates/CONVENTIONS.md +10 -7
- package/lib/check-json.js +0 -47
- package/lib/mcp-docs.js +0 -400
- package/lib/mcp-source.js +0 -244
- package/lib/mcp.js +0 -557
- package/resources/AGENTS.md +0 -404
- package/resources/agent-docs/advanced.md +0 -1090
- package/resources/agent-docs/built-ins.md +0 -367
- package/resources/agent-docs/components.md +0 -486
- package/resources/agent-docs/configuration.md +0 -207
- package/resources/agent-docs/framework-dev.md +0 -65
- package/resources/agent-docs/lit-muscle-memory-gotchas.md +0 -456
- package/resources/agent-docs/metadata.md +0 -334
- package/resources/agent-docs/recipes.md +0 -440
- package/resources/agent-docs/service-worker.md +0 -100
- package/resources/agent-docs/ssr-partial-nav-design.md +0 -214
- package/resources/agent-docs/styling.md +0 -235
- package/resources/agent-docs/testing.md +0 -372
- package/resources/agent-docs/typescript.md +0 -334
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
|
|
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
|
|
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
|
}
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
651
|
-
// so nothing here may write to stdout: the data functions are
|
|
652
|
-
// and `runMcpServer` routes all diagnostics to stderr. The
|
|
653
|
-
//
|
|
654
|
-
|
|
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
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
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/
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
// •
|
|
354
|
-
// •
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
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/
|
|
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
|
+
}
|