@webjsdev/cli 0.10.58 → 0.10.60
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 +1 -1
- package/bin/webjs.js +116 -11
- package/lib/audit.js +218 -0
- package/lib/browser-test-files.js +125 -0
- package/lib/create.js +57 -24
- package/lib/db-rewrite.js +137 -0
- package/lib/dev-reload.js +382 -0
- package/lib/dev-supervisor.js +43 -42
- package/lib/doctor/codes.js +1 -0
- package/lib/doctor/probes/workspace-overrides.js +79 -0
- package/lib/doctor/runner.js +2 -0
- package/lib/package-manager.js +93 -0
- package/package.json +2 -2
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +11 -0
- package/templates/.agents/skills/webjs/references/built-ins.md +38 -0
- package/templates/.agents/skills/webjs/references/data-and-actions.md +1 -1
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +1 -0
- package/templates/.agents/skills/webjs/references/runtime.md +7 -3
- package/templates/.agents/skills/webjs/references/testing.md +2 -0
- package/templates/.dockerignore +1 -1
- package/templates/Dockerfile +3 -3
- package/templates/compose.yaml +3 -3
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ npx @webjsdev/cli create my-app
|
|
|
33
33
|
cd my-app && npm run dev
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
Both `webjs create` and `create-webjs-app` auto-install dependencies in the new directory using your detected package manager (npm / pnpm / yarn / bun). Pass `--no-install` to opt out.
|
|
36
|
+
Both `webjs create` and `create-webjs-app` auto-install dependencies in the new directory using your detected package manager (npm / pnpm / yarn / bun: the invoking tool, else the enclosing project's lockfile). Pass `--no-install` to opt out.
|
|
37
37
|
|
|
38
38
|
## Commands
|
|
39
39
|
|
package/bin/webjs.js
CHANGED
|
@@ -103,6 +103,8 @@ const USAGE = `webjs commands:
|
|
|
103
103
|
--json emits the structured results (with stable codes). --strict additionally fails on every remaining warning.
|
|
104
104
|
Per-check severity is CONFIG: map a code to off/warn/error under "webjs": { "doctor": { "gate": {...} } }
|
|
105
105
|
in package.json, so CI gates on a chosen subset without every warning becoming fatal
|
|
106
|
+
webjs audit [--json] Run the npm / bun dependency audit, failing at "webjs": { "audit": { "level" } } (default high)
|
|
107
|
+
and above, minus the advisories "webjs.audit.ignore" accepts, each with its reason
|
|
106
108
|
webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
|
|
107
109
|
webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors)
|
|
108
110
|
webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install] [--skip-ci] Scaffold a new webjs app
|
|
@@ -226,6 +228,22 @@ const HELP = {
|
|
|
226
228
|
],
|
|
227
229
|
examples: ['webjs elision', 'webjs elision --json', 'webjs elision --verify', 'webjs elision --verify --routes /,/blog/hello'],
|
|
228
230
|
},
|
|
231
|
+
audit: {
|
|
232
|
+
usage: 'webjs audit [--json]',
|
|
233
|
+
summary:
|
|
234
|
+
'Run the package manager\'s dependency audit (npm or bun, found by the nearest lockfile) and fail on any advisory at or above ' +
|
|
235
|
+
'webjs.audit.level (default high), except the ones webjs.audit.ignore lists, each with the reason it is safe to accept.',
|
|
236
|
+
options: [
|
|
237
|
+
{ flag: '--json', description: 'Emit { ok, manager, level, failing[], ignored[], stale[] } as JSON.' },
|
|
238
|
+
],
|
|
239
|
+
notes: [
|
|
240
|
+
'Config lives in package.json, in one place:',
|
|
241
|
+
'"webjs": { "audit": { "level": "high", "ignore": [{ "id": "GHSA-...", "reason": "..." }] } }.',
|
|
242
|
+
'An entry with no reason, an unknown key, or a bad level exits 1 naming it. An ignored id',
|
|
243
|
+
'the audit no longer reports is printed as stale, so the list shrinks when a fix ships.',
|
|
244
|
+
],
|
|
245
|
+
examples: ['webjs audit', 'webjs audit --json'],
|
|
246
|
+
},
|
|
229
247
|
doctor: {
|
|
230
248
|
usage: 'webjs doctor [--json] [--strict]',
|
|
231
249
|
summary: 'Verify project health. Each result carries a stable code so an agent branches on the failure kind.',
|
|
@@ -482,6 +500,14 @@ async function main() {
|
|
|
482
500
|
// too, but that runs too late to affect the port the CLI computes.
|
|
483
501
|
loadAppEnv(process.cwd());
|
|
484
502
|
const port = resolvePort(flag(rest, '--port'));
|
|
503
|
+
// Exit with the supervisor (#1521): when its IPC channel closes, the
|
|
504
|
+
// parent is gone (killed outright, or crashed), and a child left
|
|
505
|
+
// running would hold the port against the next `webjs dev`. Unref'd
|
|
506
|
+
// so the channel itself never keeps this process alive.
|
|
507
|
+
if (process.connected) {
|
|
508
|
+
process.channel?.unref?.();
|
|
509
|
+
process.on('disconnect', () => process.exit(0));
|
|
510
|
+
}
|
|
485
511
|
await startServer({ appDir: process.cwd(), port, dev: true });
|
|
486
512
|
break;
|
|
487
513
|
}
|
|
@@ -502,22 +528,21 @@ async function main() {
|
|
|
502
528
|
loadAppEnv(process.cwd());
|
|
503
529
|
await runPhaseBeforeSteps('dev', devTasks.dev.before, process.cwd());
|
|
504
530
|
const killTasks = await startDevParallelTasks(devTasks.dev.parallel, process.cwd());
|
|
505
|
-
process.on('SIGINT', () => { killTasks(); process.exit(0); });
|
|
506
|
-
process.on('SIGTERM', () => { killTasks(); process.exit(0); });
|
|
507
531
|
|
|
508
|
-
// Decide how to run: in-process (`--no-hot`), or
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
|
|
532
|
+
// Decide how to run: in-process (`--no-hot`), or in a child under WebJs's
|
|
533
|
+
// reload supervisor (#1521), which restarts it on a change on Node and
|
|
534
|
+
// runs it under `bun --hot` on Bun (#514), and brings a crashed child
|
|
535
|
+
// back on either. The branch logic lives in the pure `planDevSupervisor`
|
|
536
|
+
// so it is unit-testable without spawning a process.
|
|
513
537
|
const plan = planDevSupervisor({
|
|
514
538
|
isBun: !!process.versions.bun,
|
|
515
539
|
argv: process.argv.slice(1),
|
|
516
540
|
noHot: rest.includes('--no-hot'),
|
|
517
|
-
exists: (p) => existsSync(p),
|
|
518
541
|
});
|
|
519
542
|
|
|
520
543
|
if (plan.mode === 'inline') {
|
|
544
|
+
process.on('SIGINT', () => { killTasks(); process.exit(0); });
|
|
545
|
+
process.on('SIGTERM', () => { killTasks(); process.exit(0); });
|
|
521
546
|
const { startServer } = await import('@webjsdev/server');
|
|
522
547
|
loadAppEnv(process.cwd());
|
|
523
548
|
const port = resolvePort(flag(rest, '--port'));
|
|
@@ -526,12 +551,13 @@ async function main() {
|
|
|
526
551
|
break;
|
|
527
552
|
}
|
|
528
553
|
|
|
529
|
-
const
|
|
530
|
-
|
|
554
|
+
const { superviseDevServer } = await import('../lib/dev-reload.js');
|
|
555
|
+
superviseDevServer({
|
|
531
556
|
cwd: process.cwd(),
|
|
557
|
+
plan,
|
|
532
558
|
env: { ...process.env, __WEBJS_DEV_CHILD: '1' },
|
|
559
|
+
onExit: (code) => { killTasks(); process.exit(code); },
|
|
533
560
|
});
|
|
534
|
-
child.on('exit', (code) => { killTasks(); process.exit(code ?? 0); });
|
|
535
561
|
break;
|
|
536
562
|
}
|
|
537
563
|
case 'start': {
|
|
@@ -749,7 +775,32 @@ async function main() {
|
|
|
749
775
|
const useBrowserDir = !hasConfig && !serverOnly && existsSync(join(cwd, 'test', 'browser'));
|
|
750
776
|
// Only resolve + run when there is actually something to run, so a
|
|
751
777
|
// `webjs test` with no browser tests stays a no-op (not a hard error).
|
|
778
|
+
// Zero browser test files is a pass, not a failure (#1491). WTR throws
|
|
779
|
+
// `Could not find any test files` on an empty match, so an app with no
|
|
780
|
+
// browser tests yet (what `gallery:clear` leaves) would fail its own CI
|
|
781
|
+
// on this layer. Check the globs first and skip with a note. `null`
|
|
782
|
+
// patterns mean the config's `files` is not a plain literal, so WTR
|
|
783
|
+
// runs unchanged and reports for itself.
|
|
784
|
+
let noBrowserTests = false;
|
|
752
785
|
if (hasConfig || useBrowserDir) {
|
|
786
|
+
const { readFile } = await import('node:fs/promises');
|
|
787
|
+
const { readWtrFilePatterns, findBrowserTestFiles } = await import('../lib/browser-test-files.js');
|
|
788
|
+
let patterns = ['test/browser/**/*.test.js'];
|
|
789
|
+
if (hasConfig) {
|
|
790
|
+
const cfg = existsSync(join(cwd, 'web-test-runner.config.js'))
|
|
791
|
+
? 'web-test-runner.config.js' : 'web-test-runner.config.mjs';
|
|
792
|
+
patterns = readWtrFilePatterns(await readFile(join(cwd, cfg), 'utf8'));
|
|
793
|
+
}
|
|
794
|
+
if (patterns && (await findBrowserTestFiles(cwd, patterns)).length === 0) {
|
|
795
|
+
noBrowserTests = true;
|
|
796
|
+
console.log(
|
|
797
|
+
'\nwebjs test: no browser tests yet (no file matches '
|
|
798
|
+
+ (patterns.length ? patterns.join(', ') : 'the configured `files`')
|
|
799
|
+
+ '), skipping the browser layer.',
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if ((hasConfig || useBrowserDir) && !noBrowserTests) {
|
|
753
804
|
// Resolve the app's @web/test-runner bin and spawn it with the current
|
|
754
805
|
// runtime, dropping `npx` (#570; absent in a pure oven/bun image).
|
|
755
806
|
let wtrPath;
|
|
@@ -977,6 +1028,60 @@ async function main() {
|
|
|
977
1028
|
}
|
|
978
1029
|
break;
|
|
979
1030
|
}
|
|
1031
|
+
case 'audit': {
|
|
1032
|
+
// Dependency audit with a reviewable allowlist (#1492). npm has no ignore
|
|
1033
|
+
// flag, so both managers' JSON reports are parsed and filtered the same
|
|
1034
|
+
// way here (see lib/audit.js for why each part fails closed).
|
|
1035
|
+
const cwd = process.cwd();
|
|
1036
|
+
const json = rest.includes('--json');
|
|
1037
|
+
const { readAuditConfigFrom, detectAuditManager, parseAuditReport, applyAuditConfig } = await import('../lib/audit.js');
|
|
1038
|
+
const { config, errors } = readAuditConfigFrom(cwd);
|
|
1039
|
+
if (errors.length) {
|
|
1040
|
+
for (const e of errors) console.error(`webjs audit: ${e}`);
|
|
1041
|
+
process.exit(1);
|
|
1042
|
+
}
|
|
1043
|
+
const pm = detectAuditManager(cwd);
|
|
1044
|
+
if (pm !== 'npm' && pm !== 'bun') {
|
|
1045
|
+
console.error(`webjs audit: drives npm and bun audits; this project uses ${pm}. Run \`${pm} audit\` directly.`);
|
|
1046
|
+
process.exit(1);
|
|
1047
|
+
}
|
|
1048
|
+
const { spawnSync } = await import('node:child_process');
|
|
1049
|
+
const res = spawnSync(pm, ['audit', '--json'], {
|
|
1050
|
+
cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, shell: process.platform === 'win32',
|
|
1051
|
+
});
|
|
1052
|
+
const advisories = res.error ? null : parseAuditReport(pm, res.stdout || '');
|
|
1053
|
+
if (!advisories) {
|
|
1054
|
+
console.error(`webjs audit: \`${pm} audit --json\` produced no report${res.error ? ` (${res.error.message})` : ''}.`);
|
|
1055
|
+
if (res.stderr) console.error(res.stderr.trim());
|
|
1056
|
+
process.exit(1);
|
|
1057
|
+
}
|
|
1058
|
+
const { failing, ignored, stale } = applyAuditConfig(advisories, config);
|
|
1059
|
+
const reasonOf = (id) => config.ignore.find((i) => i.id.toUpperCase() === id.toUpperCase())?.reason || '';
|
|
1060
|
+
if (json) {
|
|
1061
|
+
console.log(JSON.stringify({
|
|
1062
|
+
ok: failing.length === 0, manager: pm, level: config.level, failing,
|
|
1063
|
+
ignored: ignored.map((a) => ({ ...a, reason: reasonOf(a.id) })), stale,
|
|
1064
|
+
}, null, 2));
|
|
1065
|
+
process.exit(failing.length ? 1 : 0);
|
|
1066
|
+
}
|
|
1067
|
+
console.log(`webjs audit: ${pm} audit, failing at ${config.level} and above`);
|
|
1068
|
+
for (const a of ignored) {
|
|
1069
|
+
console.log(` ignored ${a.id} (${a.severity}, ${a.packages.join(', ')}): ${reasonOf(a.id)}`);
|
|
1070
|
+
}
|
|
1071
|
+
for (const i of stale) {
|
|
1072
|
+
console.log(` stale ${i.id} is no longer reported; remove it from webjs.audit.ignore`);
|
|
1073
|
+
}
|
|
1074
|
+
if (failing.length) {
|
|
1075
|
+
for (const a of failing) {
|
|
1076
|
+
console.log(` FAIL ${a.id} (${a.severity}, ${a.packages.join(', ')}): ${a.title}${a.url ? ` ${a.url}` : ''}`);
|
|
1077
|
+
}
|
|
1078
|
+
console.log(`\nwebjs audit: ${failing.length} advisor${failing.length === 1 ? 'y' : 'ies'} at ${config.level} or above. ` +
|
|
1079
|
+
'Upgrade the dependency, or, when no patched release exists and it cannot reach users, add it to webjs.audit.ignore with a reason.');
|
|
1080
|
+
process.exit(1);
|
|
1081
|
+
}
|
|
1082
|
+
console.log(`webjs audit: no ${config.level}+ advisories outside the allowlist ✓`);
|
|
1083
|
+
break;
|
|
1084
|
+
}
|
|
980
1085
|
case 'doctor': {
|
|
981
1086
|
// Project-health checklist (#266). The checks are PURE (in lib/doctor.js);
|
|
982
1087
|
// this branch only renders them and owns the exit code. The exit is
|
package/lib/audit.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `webjs audit`: the dependency audit with a reviewable allowlist (#1492).
|
|
3
|
+
*
|
|
4
|
+
* A freshly generated app used to fail its own `Security: dependency audit`
|
|
5
|
+
* CI step on the day it was created, because the dev toolchain reaches an
|
|
6
|
+
* advisory with NO patched release (braces, GHSA-vfj7-8cjw-p6xm, through the
|
|
7
|
+
* Tailwind watcher and the test runner's globber). Nothing can be upgraded,
|
|
8
|
+
* so the step was red until someone deleted it, which also throws away the
|
|
9
|
+
* signal for every advisory that DOES have a fix.
|
|
10
|
+
*
|
|
11
|
+
* So the app declares the advisories it accepts, in ONE place, each with the
|
|
12
|
+
* reason it is safe, under `webjs.audit` in package.json:
|
|
13
|
+
*
|
|
14
|
+
* "audit": {
|
|
15
|
+
* "level": "high",
|
|
16
|
+
* "ignore": [{ "id": "GHSA-...", "reason": "why this cannot reach users" }]
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* and `webjs audit` runs the package manager's own audit (`npm audit --json`
|
|
20
|
+
* or `bun audit --json`) and filters that report here, the same way for both,
|
|
21
|
+
* since npm has no ignore flag at all. Every other advisory at or above
|
|
22
|
+
* `level` still fails.
|
|
23
|
+
*
|
|
24
|
+
* The config fails CLOSED, like the doctor gate: an entry without an id or a
|
|
25
|
+
* reason, an unknown key, or a bad level exits 1 naming the problem, because
|
|
26
|
+
* an allowlist that silently stopped applying (or silently applied to
|
|
27
|
+
* everything) would be worse than none. An ignored id that no longer appears in
|
|
28
|
+
* the report is printed as stale, so the list shrinks when upstream ships a fix.
|
|
29
|
+
*
|
|
30
|
+
* @module audit
|
|
31
|
+
*/
|
|
32
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
33
|
+
import { dirname, join } from 'node:path';
|
|
34
|
+
|
|
35
|
+
export const AUDIT_LEVELS = ['low', 'moderate', 'high', 'critical'];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {{ id: string, reason: string }} AuditIgnore
|
|
39
|
+
* @typedef {{ level: string, ignore: AuditIgnore[] }} AuditConfig
|
|
40
|
+
* @typedef {{ id: string, url: string, severity: string, title: string, packages: string[] }} Advisory
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read and validate `webjs.audit` from a parsed package.json.
|
|
45
|
+
*
|
|
46
|
+
* @param {Record<string, any>} pkg
|
|
47
|
+
* @returns {{ config: AuditConfig, errors: string[] }}
|
|
48
|
+
*/
|
|
49
|
+
export function readAuditConfig(pkg) {
|
|
50
|
+
/** @type {AuditConfig} */
|
|
51
|
+
const config = { level: 'high', ignore: [] };
|
|
52
|
+
/** @type {string[]} */
|
|
53
|
+
const errors = [];
|
|
54
|
+
const raw = pkg?.webjs?.audit;
|
|
55
|
+
if (raw === undefined) return { config, errors };
|
|
56
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
57
|
+
return { config, errors: ['webjs.audit must be an object'] };
|
|
58
|
+
}
|
|
59
|
+
for (const key of Object.keys(raw)) {
|
|
60
|
+
if (key !== 'level' && key !== 'ignore') errors.push(`webjs.audit: unknown key "${key}" (expected "level" or "ignore")`);
|
|
61
|
+
}
|
|
62
|
+
if (raw.level !== undefined) {
|
|
63
|
+
if (!AUDIT_LEVELS.includes(raw.level)) errors.push(`webjs.audit.level must be one of ${AUDIT_LEVELS.join(', ')}`);
|
|
64
|
+
else config.level = raw.level;
|
|
65
|
+
}
|
|
66
|
+
if (raw.ignore !== undefined) {
|
|
67
|
+
if (!Array.isArray(raw.ignore)) {
|
|
68
|
+
errors.push('webjs.audit.ignore must be an array of { id, reason }');
|
|
69
|
+
} else {
|
|
70
|
+
raw.ignore.forEach((entry, i) => {
|
|
71
|
+
const at = `webjs.audit.ignore[${i}]`;
|
|
72
|
+
if (!entry || typeof entry !== 'object') { errors.push(`${at} must be an object { id, reason }`); return; }
|
|
73
|
+
const extra = Object.keys(entry).filter((k) => k !== 'id' && k !== 'reason');
|
|
74
|
+
if (extra.length) errors.push(`${at}: unknown key "${extra[0]}" (expected "id" and "reason")`);
|
|
75
|
+
if (typeof entry.id !== 'string' || !/^(GHSA(-[0-9a-z]{4}){3}|CVE-\d{4}-\d+)$/i.test(entry.id)) {
|
|
76
|
+
errors.push(`${at}.id must be an advisory id such as GHSA-xxxx-xxxx-xxxx or CVE-2024-12345`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (typeof entry.reason !== 'string' || entry.reason.trim() === '') {
|
|
80
|
+
errors.push(`${at} (${entry.id}) needs a non-empty "reason" saying why it is safe to accept`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
config.ignore.push({ id: entry.id, reason: entry.reason });
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { config, errors };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Which package manager's audit to run. Walks up from `cwd` to the first
|
|
92
|
+
* lockfile, since an app inside a workspace has its lockfile at the root.
|
|
93
|
+
* Only bun and npm have an audit this command drives; anything else is
|
|
94
|
+
* reported as unsupported by the caller.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} cwd
|
|
97
|
+
* @returns {'bun' | 'npm' | 'pnpm' | 'yarn'}
|
|
98
|
+
*/
|
|
99
|
+
export function detectAuditManager(cwd) {
|
|
100
|
+
let dir = cwd;
|
|
101
|
+
for (;;) {
|
|
102
|
+
if (existsSync(join(dir, 'bun.lock')) || existsSync(join(dir, 'bun.lockb'))) return 'bun';
|
|
103
|
+
if (existsSync(join(dir, 'package-lock.json')) || existsSync(join(dir, 'npm-shrinkwrap.json'))) return 'npm';
|
|
104
|
+
if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
105
|
+
if (existsSync(join(dir, 'yarn.lock'))) return 'yarn';
|
|
106
|
+
const parent = dirname(dir);
|
|
107
|
+
if (parent === dir) break;
|
|
108
|
+
dir = parent;
|
|
109
|
+
}
|
|
110
|
+
return process.versions.bun ? 'bun' : 'npm';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The advisory id at the end of a GitHub advisory url. */
|
|
114
|
+
function idFromUrl(url) {
|
|
115
|
+
const m = /(GHSA(?:-[0-9a-z]{4}){3})/i.exec(url || '');
|
|
116
|
+
return m ? m[1] : '';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The source advisories in an `npm audit --json` (v2 report format) report,
|
|
121
|
+
* deduplicated by id. A vulnerability entry whose `via` holds only package
|
|
122
|
+
* NAMES is a dependent of one of these, not an advisory of its own, so it is
|
|
123
|
+
* covered by filtering the advisories it inherits from.
|
|
124
|
+
*
|
|
125
|
+
* @param {any} report
|
|
126
|
+
* @returns {Advisory[]}
|
|
127
|
+
*/
|
|
128
|
+
export function npmAdvisories(report) {
|
|
129
|
+
/** @type {Map<string, Advisory>} */
|
|
130
|
+
const byId = new Map();
|
|
131
|
+
for (const [name, vuln] of Object.entries(report?.vulnerabilities || {})) {
|
|
132
|
+
for (const via of /** @type {any} */ (vuln).via || []) {
|
|
133
|
+
if (!via || typeof via !== 'object') continue;
|
|
134
|
+
const id = idFromUrl(via.url) || String(via.source ?? via.url);
|
|
135
|
+
const cur = byId.get(id);
|
|
136
|
+
if (cur) { if (!cur.packages.includes(name)) cur.packages.push(name); continue; }
|
|
137
|
+
byId.set(id, { id, url: via.url || '', severity: via.severity || 'low', title: via.title || '', packages: [name] });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return [...byId.values()];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Split advisories into the ones that still fail and the ones the allowlist
|
|
145
|
+
* accepted, keeping only those at or above `level`.
|
|
146
|
+
*
|
|
147
|
+
* @param {Advisory[]} advisories
|
|
148
|
+
* @param {AuditConfig} config
|
|
149
|
+
* @returns {{ failing: Advisory[], ignored: Advisory[], stale: AuditIgnore[] }}
|
|
150
|
+
*/
|
|
151
|
+
export function applyAuditConfig(advisories, config) {
|
|
152
|
+
const floor = AUDIT_LEVELS.indexOf(config.level);
|
|
153
|
+
const atLevel = advisories.filter((a) => AUDIT_LEVELS.indexOf(a.severity) >= floor);
|
|
154
|
+
const ids = new Set(config.ignore.map((i) => i.id.toUpperCase()));
|
|
155
|
+
const failing = atLevel.filter((a) => !ids.has(a.id.toUpperCase()));
|
|
156
|
+
const ignored = atLevel.filter((a) => ids.has(a.id.toUpperCase()));
|
|
157
|
+
const seen = new Set(advisories.map((a) => a.id.toUpperCase()));
|
|
158
|
+
const stale = config.ignore.filter((i) => !seen.has(i.id.toUpperCase()));
|
|
159
|
+
return { failing, ignored, stale };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The source advisories in a `bun audit --json` report, which is keyed by
|
|
164
|
+
* package name with a list of advisories each.
|
|
165
|
+
*
|
|
166
|
+
* @param {any} report
|
|
167
|
+
* @returns {Advisory[]}
|
|
168
|
+
*/
|
|
169
|
+
export function bunAdvisories(report) {
|
|
170
|
+
/** @type {Map<string, Advisory>} */
|
|
171
|
+
const byId = new Map();
|
|
172
|
+
for (const [name, list] of Object.entries(report || {})) {
|
|
173
|
+
if (!Array.isArray(list)) continue;
|
|
174
|
+
for (const adv of list) {
|
|
175
|
+
const id = idFromUrl(adv?.url) || String(adv?.id ?? adv?.url);
|
|
176
|
+
const cur = byId.get(id);
|
|
177
|
+
if (cur) { if (!cur.packages.includes(name)) cur.packages.push(name); continue; }
|
|
178
|
+
byId.set(id, { id, url: adv?.url || '', severity: adv?.severity || 'low', title: adv?.title || '', packages: [name] });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return [...byId.values()];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Parse a package manager's `audit --json` stdout into advisories. Both
|
|
186
|
+
* managers exit non-zero when they find anything, so the exit code says
|
|
187
|
+
* nothing; an unparseable stdout (a registry outage, an old manager) returns
|
|
188
|
+
* `null` and the caller fails closed.
|
|
189
|
+
*
|
|
190
|
+
* @param {'bun' | 'npm'} pm
|
|
191
|
+
* @param {string} stdout
|
|
192
|
+
* @returns {Advisory[] | null}
|
|
193
|
+
*/
|
|
194
|
+
export function parseAuditReport(pm, stdout) {
|
|
195
|
+
let report;
|
|
196
|
+
try { report = JSON.parse(stdout); } catch { return null; }
|
|
197
|
+
if (!report || typeof report !== 'object') return null;
|
|
198
|
+
if (pm === 'npm') {
|
|
199
|
+
if (report.error) return null;
|
|
200
|
+
return npmAdvisories(report);
|
|
201
|
+
}
|
|
202
|
+
return bunAdvisories(report);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Read `webjs.audit` from the package.json in `cwd`.
|
|
207
|
+
*
|
|
208
|
+
* @param {string} cwd
|
|
209
|
+
*/
|
|
210
|
+
export function readAuditConfigFrom(cwd) {
|
|
211
|
+
const p = join(cwd, 'package.json');
|
|
212
|
+
if (!existsSync(p)) return { config: { level: 'high', ignore: [] }, errors: ['no package.json in this directory'] };
|
|
213
|
+
try {
|
|
214
|
+
return readAuditConfig(JSON.parse(readFileSync(p, 'utf8')));
|
|
215
|
+
} catch (e) {
|
|
216
|
+
return { config: { level: 'high', ignore: [] }, errors: [`package.json is not valid JSON: ${/** @type {Error} */ (e).message}`] };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide whether `webjs test --browser` has anything to run (#1491).
|
|
3
|
+
*
|
|
4
|
+
* web-test-runner throws `Could not find any test files with pattern(s)` when
|
|
5
|
+
* its `files` globs match nothing, so an app with no browser tests yet (the
|
|
6
|
+
* state `npm run gallery:clear` leaves behind) fails its own CI on the browser
|
|
7
|
+
* layer. A missing layer is not a failing layer: the server layer already
|
|
8
|
+
* passes with zero files, and the browser layer should too. WTR raises the
|
|
9
|
+
* error while building its session groups, after it has started, so the check
|
|
10
|
+
* has to happen here, before it is spawned.
|
|
11
|
+
*
|
|
12
|
+
* The globs are read from the config's SOURCE rather than by importing it,
|
|
13
|
+
* because the scaffold's config top-level-awaits a warmed webjs handler (the
|
|
14
|
+
* whole app boots) just to answer a question a file walk can answer. When the
|
|
15
|
+
* `files` value is not a plain literal (computed, imported, spread), the
|
|
16
|
+
* patterns come back `null` and the caller runs WTR exactly as before, so a
|
|
17
|
+
* config this reader does not understand never turns into a silent skip.
|
|
18
|
+
*
|
|
19
|
+
* @module browser-test-files
|
|
20
|
+
*/
|
|
21
|
+
import { readdir } from 'node:fs/promises';
|
|
22
|
+
import { join, relative, sep, matchesGlob } from 'node:path';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pull the `files` globs out of a web-test-runner config's source text.
|
|
26
|
+
* Accepts `files: 'one/glob'` and `files: ['a', "b", ...]` with only string
|
|
27
|
+
* literals inside the array. Anything else returns `null` (unknown).
|
|
28
|
+
*
|
|
29
|
+
* @param {string} source
|
|
30
|
+
* @returns {string[] | null}
|
|
31
|
+
*/
|
|
32
|
+
export function readWtrFilePatterns(source) {
|
|
33
|
+
// Strip line + block comments first, so a commented-out `files:` cannot
|
|
34
|
+
// match and a comment inside the array does not read as a non-literal.
|
|
35
|
+
const code = stripComments(source);
|
|
36
|
+
const single = code.match(/\bfiles\s*:\s*(['"])([^'"]+)\1\s*[,}\n]/);
|
|
37
|
+
if (single) return [single[2]];
|
|
38
|
+
const arr = code.match(/\bfiles\s*:\s*\[([\s\S]*?)\]/);
|
|
39
|
+
if (!arr) return null;
|
|
40
|
+
const body = arr[1].trim();
|
|
41
|
+
if (body === '') return [];
|
|
42
|
+
const out = [];
|
|
43
|
+
// Every comma-separated element must be a quoted string literal.
|
|
44
|
+
for (const raw of body.split(',')) {
|
|
45
|
+
const el = raw.trim();
|
|
46
|
+
if (el === '') continue; // trailing comma
|
|
47
|
+
const m = el.match(/^(['"])([^'"]*)\1$/);
|
|
48
|
+
if (!m) return null;
|
|
49
|
+
out.push(m[2]);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Walk `cwd` and return the app-relative paths (forward slashes) matching
|
|
56
|
+
* the include globs and none of the `!`-prefixed exclude globs. Skips
|
|
57
|
+
* `node_modules` and dot directories, which WTR's globber skips too.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} cwd
|
|
60
|
+
* @param {string[]} patterns
|
|
61
|
+
* @returns {Promise<string[]>}
|
|
62
|
+
*/
|
|
63
|
+
export async function findBrowserTestFiles(cwd, patterns) {
|
|
64
|
+
const include = patterns.filter((p) => !p.startsWith('!')).map(normalize);
|
|
65
|
+
const exclude = patterns.filter((p) => p.startsWith('!')).map((p) => normalize(p.slice(1)));
|
|
66
|
+
if (include.length === 0) return [];
|
|
67
|
+
const found = [];
|
|
68
|
+
const walk = async (dir) => {
|
|
69
|
+
let entries;
|
|
70
|
+
try { entries = await readdir(dir, { withFileTypes: true }); }
|
|
71
|
+
catch { return; }
|
|
72
|
+
for (const ent of entries) {
|
|
73
|
+
if (ent.name === 'node_modules' || ent.name.startsWith('.')) continue;
|
|
74
|
+
const full = join(dir, ent.name);
|
|
75
|
+
if (ent.isDirectory()) { await walk(full); continue; }
|
|
76
|
+
if (!ent.isFile()) continue;
|
|
77
|
+
const rel = relative(cwd, full).split(sep).join('/');
|
|
78
|
+
if (include.some((g) => matchesGlob(rel, g)) && !exclude.some((g) => matchesGlob(rel, g))) {
|
|
79
|
+
found.push(rel);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
await walk(cwd);
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Remove `//` and `/* *\/` comments while leaving string literals intact. A
|
|
89
|
+
* regex strip is not enough: a glob such as `test/**\/browser` contains the
|
|
90
|
+
* very `/*` that opens a block comment.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} src
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
function stripComments(src) {
|
|
96
|
+
let out = '';
|
|
97
|
+
let quote = '';
|
|
98
|
+
for (let i = 0; i < src.length; i++) {
|
|
99
|
+
const c = src[i];
|
|
100
|
+
if (quote) {
|
|
101
|
+
out += c;
|
|
102
|
+
if (c === '\\') { out += src[++i] ?? ''; continue; }
|
|
103
|
+
if (c === quote) quote = '';
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (c === '"' || c === "'" || c === '`') { quote = c; out += c; continue; }
|
|
107
|
+
if (c === '/' && src[i + 1] === '/') {
|
|
108
|
+
while (i < src.length && src[i] !== '\n') i++;
|
|
109
|
+
out += '\n';
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (c === '/' && src[i + 1] === '*') {
|
|
113
|
+
const end = src.indexOf('*/', i + 2);
|
|
114
|
+
i = end === -1 ? src.length : end + 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
out += c;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @param {string} p */
|
|
123
|
+
function normalize(p) {
|
|
124
|
+
return p.replace(/^\.\//, '');
|
|
125
|
+
}
|