@webjsdev/cli 0.10.58 → 0.10.59
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 +97 -0
- 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/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 +37 -0
- 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.',
|
|
@@ -749,7 +767,32 @@ async function main() {
|
|
|
749
767
|
const useBrowserDir = !hasConfig && !serverOnly && existsSync(join(cwd, 'test', 'browser'));
|
|
750
768
|
// Only resolve + run when there is actually something to run, so a
|
|
751
769
|
// `webjs test` with no browser tests stays a no-op (not a hard error).
|
|
770
|
+
// Zero browser test files is a pass, not a failure (#1491). WTR throws
|
|
771
|
+
// `Could not find any test files` on an empty match, so an app with no
|
|
772
|
+
// browser tests yet (what `gallery:clear` leaves) would fail its own CI
|
|
773
|
+
// on this layer. Check the globs first and skip with a note. `null`
|
|
774
|
+
// patterns mean the config's `files` is not a plain literal, so WTR
|
|
775
|
+
// runs unchanged and reports for itself.
|
|
776
|
+
let noBrowserTests = false;
|
|
752
777
|
if (hasConfig || useBrowserDir) {
|
|
778
|
+
const { readFile } = await import('node:fs/promises');
|
|
779
|
+
const { readWtrFilePatterns, findBrowserTestFiles } = await import('../lib/browser-test-files.js');
|
|
780
|
+
let patterns = ['test/browser/**/*.test.js'];
|
|
781
|
+
if (hasConfig) {
|
|
782
|
+
const cfg = existsSync(join(cwd, 'web-test-runner.config.js'))
|
|
783
|
+
? 'web-test-runner.config.js' : 'web-test-runner.config.mjs';
|
|
784
|
+
patterns = readWtrFilePatterns(await readFile(join(cwd, cfg), 'utf8'));
|
|
785
|
+
}
|
|
786
|
+
if (patterns && (await findBrowserTestFiles(cwd, patterns)).length === 0) {
|
|
787
|
+
noBrowserTests = true;
|
|
788
|
+
console.log(
|
|
789
|
+
'\nwebjs test: no browser tests yet (no file matches '
|
|
790
|
+
+ (patterns.length ? patterns.join(', ') : 'the configured `files`')
|
|
791
|
+
+ '), skipping the browser layer.',
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if ((hasConfig || useBrowserDir) && !noBrowserTests) {
|
|
753
796
|
// Resolve the app's @web/test-runner bin and spawn it with the current
|
|
754
797
|
// runtime, dropping `npx` (#570; absent in a pure oven/bun image).
|
|
755
798
|
let wtrPath;
|
|
@@ -977,6 +1020,60 @@ async function main() {
|
|
|
977
1020
|
}
|
|
978
1021
|
break;
|
|
979
1022
|
}
|
|
1023
|
+
case 'audit': {
|
|
1024
|
+
// Dependency audit with a reviewable allowlist (#1492). npm has no ignore
|
|
1025
|
+
// flag, so both managers' JSON reports are parsed and filtered the same
|
|
1026
|
+
// way here (see lib/audit.js for why each part fails closed).
|
|
1027
|
+
const cwd = process.cwd();
|
|
1028
|
+
const json = rest.includes('--json');
|
|
1029
|
+
const { readAuditConfigFrom, detectAuditManager, parseAuditReport, applyAuditConfig } = await import('../lib/audit.js');
|
|
1030
|
+
const { config, errors } = readAuditConfigFrom(cwd);
|
|
1031
|
+
if (errors.length) {
|
|
1032
|
+
for (const e of errors) console.error(`webjs audit: ${e}`);
|
|
1033
|
+
process.exit(1);
|
|
1034
|
+
}
|
|
1035
|
+
const pm = detectAuditManager(cwd);
|
|
1036
|
+
if (pm !== 'npm' && pm !== 'bun') {
|
|
1037
|
+
console.error(`webjs audit: drives npm and bun audits; this project uses ${pm}. Run \`${pm} audit\` directly.`);
|
|
1038
|
+
process.exit(1);
|
|
1039
|
+
}
|
|
1040
|
+
const { spawnSync } = await import('node:child_process');
|
|
1041
|
+
const res = spawnSync(pm, ['audit', '--json'], {
|
|
1042
|
+
cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, shell: process.platform === 'win32',
|
|
1043
|
+
});
|
|
1044
|
+
const advisories = res.error ? null : parseAuditReport(pm, res.stdout || '');
|
|
1045
|
+
if (!advisories) {
|
|
1046
|
+
console.error(`webjs audit: \`${pm} audit --json\` produced no report${res.error ? ` (${res.error.message})` : ''}.`);
|
|
1047
|
+
if (res.stderr) console.error(res.stderr.trim());
|
|
1048
|
+
process.exit(1);
|
|
1049
|
+
}
|
|
1050
|
+
const { failing, ignored, stale } = applyAuditConfig(advisories, config);
|
|
1051
|
+
const reasonOf = (id) => config.ignore.find((i) => i.id.toUpperCase() === id.toUpperCase())?.reason || '';
|
|
1052
|
+
if (json) {
|
|
1053
|
+
console.log(JSON.stringify({
|
|
1054
|
+
ok: failing.length === 0, manager: pm, level: config.level, failing,
|
|
1055
|
+
ignored: ignored.map((a) => ({ ...a, reason: reasonOf(a.id) })), stale,
|
|
1056
|
+
}, null, 2));
|
|
1057
|
+
process.exit(failing.length ? 1 : 0);
|
|
1058
|
+
}
|
|
1059
|
+
console.log(`webjs audit: ${pm} audit, failing at ${config.level} and above`);
|
|
1060
|
+
for (const a of ignored) {
|
|
1061
|
+
console.log(` ignored ${a.id} (${a.severity}, ${a.packages.join(', ')}): ${reasonOf(a.id)}`);
|
|
1062
|
+
}
|
|
1063
|
+
for (const i of stale) {
|
|
1064
|
+
console.log(` stale ${i.id} is no longer reported; remove it from webjs.audit.ignore`);
|
|
1065
|
+
}
|
|
1066
|
+
if (failing.length) {
|
|
1067
|
+
for (const a of failing) {
|
|
1068
|
+
console.log(` FAIL ${a.id} (${a.severity}, ${a.packages.join(', ')}): ${a.title}${a.url ? ` ${a.url}` : ''}`);
|
|
1069
|
+
}
|
|
1070
|
+
console.log(`\nwebjs audit: ${failing.length} advisor${failing.length === 1 ? 'y' : 'ies'} at ${config.level} or above. ` +
|
|
1071
|
+
'Upgrade the dependency, or, when no patched release exists and it cannot reach users, add it to webjs.audit.ignore with a reason.');
|
|
1072
|
+
process.exit(1);
|
|
1073
|
+
}
|
|
1074
|
+
console.log(`webjs audit: no ${config.level}+ advisories outside the allowlist ✓`);
|
|
1075
|
+
break;
|
|
1076
|
+
}
|
|
980
1077
|
case 'doctor': {
|
|
981
1078
|
// Project-health checklist (#266). The checks are PURE (in lib/doctor.js);
|
|
982
1079
|
// 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
|
+
}
|
package/lib/create.js
CHANGED
|
@@ -18,24 +18,10 @@ import { existsSync } from 'node:fs';
|
|
|
18
18
|
import { createRequire } from 'node:module';
|
|
19
19
|
import { spawnSync } from 'node:child_process';
|
|
20
20
|
import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi } from './runtime-rewrite.js';
|
|
21
|
+
import { postgresCompose, postgresCi } from './db-rewrite.js';
|
|
21
22
|
import { assertValidAppName, toDatabaseName } from './app-name.js';
|
|
22
23
|
import { isGalleryAppShellFile } from './gallery-shell-files.js';
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Detect which package manager invoked us. Reads `npm_config_user_agent`,
|
|
26
|
-
* which npm / pnpm / yarn / bun all set when running scripts or `npx`.
|
|
27
|
-
* Falls back to `npm` when nothing is detected (matches what most users
|
|
28
|
-
* actually have installed).
|
|
29
|
-
*
|
|
30
|
-
* @returns {'npm'|'pnpm'|'yarn'|'bun'}
|
|
31
|
-
*/
|
|
32
|
-
function detectPackageManager() {
|
|
33
|
-
const ua = process.env.npm_config_user_agent || '';
|
|
34
|
-
if (ua.startsWith('pnpm/')) return 'pnpm';
|
|
35
|
-
if (ua.startsWith('yarn/')) return 'yarn';
|
|
36
|
-
if (ua.startsWith('bun/')) return 'bun';
|
|
37
|
-
return 'npm';
|
|
38
|
-
}
|
|
24
|
+
import { detectPackageManager } from './package-manager.js';
|
|
39
25
|
|
|
40
26
|
/**
|
|
41
27
|
* Run `<pm> install` inside the scaffolded app. Returns true on success.
|
|
@@ -327,7 +313,10 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
327
313
|
// with the explicit flag winning over detection. A bun-flavored app SERVES on
|
|
328
314
|
// Bun (its dev/start scripts force `--bun`), commits `bun.lock`, sets
|
|
329
315
|
// `trustedDependencies`, and ships a bun Dockerfile / CI / agent docs.
|
|
330
|
-
|
|
316
|
+
// Runtime detection reads ONLY the invoking tool (no lockfile walk): a
|
|
317
|
+
// global `webjs create` run inside someone else's Bun workspace should not
|
|
318
|
+
// silently flip the new app to serving on Bun.
|
|
319
|
+
const runtime = opts.runtime || (detectPackageManager({ cwd: null, prefer: 'agent' }) === 'bun' ? 'bun' : 'node');
|
|
331
320
|
const VALID_RUNTIMES = ['node', 'bun'];
|
|
332
321
|
if (!VALID_RUNTIMES.includes(runtime)) {
|
|
333
322
|
throw new Error(`Unknown --runtime '${runtime}'. Only ${VALID_RUNTIMES.join(' / ')} are supported.`);
|
|
@@ -475,7 +464,12 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
475
464
|
// The Tailwind v4 CLI that css:build runs to compile public/input.css into
|
|
476
465
|
// the static public/tailwind.css the layout links. UI templates only (the
|
|
477
466
|
// api template has no CSS). Build tooling, never shipped to the runtime.
|
|
478
|
-
|
|
467
|
+
// `tailwindcss` itself is declared too (#1493): public/input.css starts
|
|
468
|
+
// with `@import "tailwindcss"`, so the app imports that package directly.
|
|
469
|
+
// Leaving it transitive (via @tailwindcss/cli) breaks under bun's isolated
|
|
470
|
+
// linker and pnpm, which link only declared packages into the app's
|
|
471
|
+
// node_modules, so the compile fails with `Can't resolve 'tailwindcss'`.
|
|
472
|
+
...(isApi ? {} : { '@tailwindcss/cli': '^4.1.0', tailwindcss: '^4.1.0' }),
|
|
479
473
|
// tsserver plugin, wired into tsconfig below. Gives the language
|
|
480
474
|
// INTELLIGENCE (go-to-def, completions, diagnostics, hover inside html``
|
|
481
475
|
// templates) in any tsserver editor with NO editor plugin installed,
|
|
@@ -523,8 +517,18 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
523
517
|
// `npm audit fix --force` proposes: its @web/test-runner-chrome@1 still
|
|
524
518
|
// declares puppeteer-core ^24, so the same vulnerable chain resolves and
|
|
525
519
|
// the audit stays red after a breaking major.
|
|
520
|
+
//
|
|
521
|
+
// basic-ftp (#1492) is the same kind of floor for the same runner: it is
|
|
522
|
+
// reached through puppeteer-core's proxy-agent chain and 6.2.2 is its fixed
|
|
523
|
+
// release (GHSA-c475-qrg2-pj4r), so an install that still resolves an older
|
|
524
|
+
// chain cannot land below it.
|
|
525
|
+
//
|
|
526
|
+
// Overrides are honoured ONLY at a workspace root. When this app is a
|
|
527
|
+
// member of an npm or bun workspace, move this block into the root
|
|
528
|
+
// package.json (`webjs doctor` warns with WORKSPACE_OVERRIDES until then).
|
|
526
529
|
overrides: {
|
|
527
530
|
'puppeteer-core': '^25.7.0',
|
|
531
|
+
'basic-ftp': '^6.2.2',
|
|
528
532
|
},
|
|
529
533
|
// Dev + start task orchestration (#550). `webjs dev` / `webjs start` read
|
|
530
534
|
// `before` and run it in-process, so `npm run dev` / `start` (thin aliases
|
|
@@ -576,6 +580,23 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
576
580
|
// everything else keeps its default warn. Add a code with "off" to
|
|
577
581
|
// silence it, or "error" to make it fatal too.
|
|
578
582
|
doctor: { gate: { UNMARKED_ASSET_LINKS: 'error' } },
|
|
583
|
+
// The dependency audit's allowlist (#1492), the ONE place an accepted
|
|
584
|
+
// advisory is listed, each with the reason it is safe. `webjs audit`
|
|
585
|
+
// (the CI step below) fails on every other advisory at `level` or above,
|
|
586
|
+
// and prints an entry as stale once the audit stops reporting it, so
|
|
587
|
+
// remove it then. Only accept an advisory with NO patched release that
|
|
588
|
+
// the app's users cannot reach; anything with a fix gets upgraded.
|
|
589
|
+
audit: {
|
|
590
|
+
level: 'high',
|
|
591
|
+
ignore: [
|
|
592
|
+
{
|
|
593
|
+
id: 'GHSA-vfj7-8cjw-p6xm',
|
|
594
|
+
reason: 'braces <=3.0.3 (no patched release) is reached only through dev tooling: the Tailwind '
|
|
595
|
+
+ 'CLI file watcher and the test runner globber, on glob patterns this repo writes. Nothing '
|
|
596
|
+
+ 'in the served app expands a pattern from request input.',
|
|
597
|
+
},
|
|
598
|
+
],
|
|
599
|
+
},
|
|
579
600
|
// Local CI (#1471), the Rails `bin/ci` posture. `npm run ci` runs this
|
|
580
601
|
// list on a developer machine and the generated GitHub workflow runs the
|
|
581
602
|
// SAME list through the same command, so the two cannot drift. Bare
|
|
@@ -593,10 +614,10 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
593
614
|
{ title: 'Conventions', run: 'webjs check' },
|
|
594
615
|
{ title: 'Health', run: 'webjs doctor' },
|
|
595
616
|
{ title: 'Types', run: 'webjs typecheck' },
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
},
|
|
617
|
+
// `webjs audit` (#1492) runs `npm audit` or `bun audit` (by the
|
|
618
|
+
// nearest lockfile) and fails at webjs.audit.level, minus the
|
|
619
|
+
// advisories webjs.audit.ignore accepts below.
|
|
620
|
+
{ title: 'Security: dependency audit', run: 'webjs audit' },
|
|
600
621
|
{
|
|
601
622
|
title: 'Tests',
|
|
602
623
|
steps: [
|
|
@@ -733,6 +754,14 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
733
754
|
'compose.yaml': bunifyCompose,
|
|
734
755
|
'.github/workflows/ci.yml': bunifyCi,
|
|
735
756
|
};
|
|
757
|
+
// Database axis (#1490): the compose + CI templates are the SQLite shape, so
|
|
758
|
+
// a --db postgres app derives its variant (a Postgres service, DATABASE_URL
|
|
759
|
+
// pointed at it) by a pure transform, like the Bun rewrite above. Applied
|
|
760
|
+
// FIRST; the two touch disjoint lines, so they compose. SQLite copies as is.
|
|
761
|
+
const DB_REWRITE = dialect === 'postgres' ? {
|
|
762
|
+
'compose.yaml': (c) => postgresCompose(c, toDatabaseName(name)),
|
|
763
|
+
'.github/workflows/ci.yml': (c) => postgresCi(c, toDatabaseName(name)),
|
|
764
|
+
} : {};
|
|
736
765
|
for (const f of templateFiles) {
|
|
737
766
|
// `--skip-ci` drops only the workflow; the PR template still ships.
|
|
738
767
|
if (skipCi && f === '.github/workflows/ci.yml') continue;
|
|
@@ -754,6 +783,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
754
783
|
const playbook = await readFile(join(TEMPLATES, 'partials', playbookFile), 'utf8');
|
|
755
784
|
content = content.replace('{{PLAYBOOK}}', () => playbook.trimEnd());
|
|
756
785
|
}
|
|
786
|
+
if (DB_REWRITE[f]) content = DB_REWRITE[f](content);
|
|
757
787
|
if (isBun) {
|
|
758
788
|
if (PROSE_REWRITE.has(f)) content = bunifyProse(content);
|
|
759
789
|
else if (FILE_REWRITE[f]) content = FILE_REWRITE[f](content);
|
|
@@ -1663,8 +1693,11 @@ ThemeToggle.register('theme-toggle');
|
|
|
1663
1693
|
// that exercise the scaffold without paying the install cost.
|
|
1664
1694
|
// In bun mode, install with bun regardless of the invoking PM, so the app
|
|
1665
1695
|
// commits `bun.lock` (text JSONC, git-diffable) instead of `package-lock.json`
|
|
1666
|
-
// (#541). Otherwise honour the invoking PM (npm / pnpm / yarn / bun)
|
|
1667
|
-
|
|
1696
|
+
// (#541). Otherwise honour the invoking PM (npm / pnpm / yarn / bun), and when
|
|
1697
|
+
// nothing invoked us through a package manager (a global `webjs` bin), the
|
|
1698
|
+
// lockfile of the enclosing project or workspace (#1494), so an app created
|
|
1699
|
+
// inside a bun workspace does not get a stray package-lock.json.
|
|
1700
|
+
const pm = isBun ? 'bun' : detectPackageManager({ cwd: dirname(appDir), prefer: 'agent' });
|
|
1668
1701
|
let installed = false;
|
|
1669
1702
|
let generatedMigration = false;
|
|
1670
1703
|
if (shouldInstall) {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database-dialect rewrites for the deploy files (#1490).
|
|
3
|
+
*
|
|
4
|
+
* The canonical `compose.yaml` and `.github/workflows/ci.yml` templates are the
|
|
5
|
+
* SQLite shape (a `file:` DATABASE_URL, a named volume for the db file). A
|
|
6
|
+
* `--db postgres` app needs a real Postgres in both places, so these pure
|
|
7
|
+
* transforms DERIVE the Postgres variant from the canonical template, the same
|
|
8
|
+
* way `runtime-rewrite.js` derives the Bun variant. There is no parallel
|
|
9
|
+
* Postgres template to drift, and SQLite output stays byte-identical because
|
|
10
|
+
* nothing here runs for it.
|
|
11
|
+
*
|
|
12
|
+
* Order: create.js applies these BEFORE the Bun rewrites. They only touch the
|
|
13
|
+
* DATABASE_URL lines, the volume, and add a database service, none of which
|
|
14
|
+
* the Bun rewrites match (those swap `node -e` healthchecks, `npm` commands and
|
|
15
|
+
* the setup-node block), so the two axes compose in either order. Every anchor
|
|
16
|
+
* is asserted, so a template edit that moves one fails loudly in the scaffold
|
|
17
|
+
* tests instead of shipping a half-rewritten file.
|
|
18
|
+
*
|
|
19
|
+
* The credentials are local-only (a throwaway compose volume, an ephemeral CI
|
|
20
|
+
* service container), never a production value; production points
|
|
21
|
+
* DATABASE_URL at its own managed Postgres.
|
|
22
|
+
*
|
|
23
|
+
* @module db-rewrite
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Local Postgres user + password for compose and CI (never production). */
|
|
27
|
+
export const LOCAL_PG_USER = 'webjs';
|
|
28
|
+
export const LOCAL_PG_PASSWORD = 'webjs';
|
|
29
|
+
/** The Postgres image both files run. */
|
|
30
|
+
export const PG_IMAGE = 'postgres:17-alpine';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} s
|
|
34
|
+
* @param {string} from
|
|
35
|
+
* @param {string} to
|
|
36
|
+
* @param {string} file
|
|
37
|
+
*/
|
|
38
|
+
function replaceOnce(s, from, to, file) {
|
|
39
|
+
if (!s.includes(from)) {
|
|
40
|
+
throw new Error(`db-rewrite: ${file} template no longer contains the anchor ${JSON.stringify(from.slice(0, 60))}`);
|
|
41
|
+
}
|
|
42
|
+
return s.replace(from, () => to);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Rewrite compose.yaml for a Postgres app: a sibling `db` service with a
|
|
47
|
+
* `pg_isready` healthcheck and its own named volume, the app's DATABASE_URL
|
|
48
|
+
* pointed at it, and `depends_on` with `service_healthy` so the app's boot-time
|
|
49
|
+
* `webjs db migrate` never races the database's startup.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} s
|
|
52
|
+
* @param {string} dbName fold-stable database name (toDatabaseName(appName))
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function postgresCompose(s, dbName) {
|
|
56
|
+
const url = `postgres://${LOCAL_PG_USER}:${LOCAL_PG_PASSWORD}@db:5432/${dbName}`;
|
|
57
|
+
let out = s;
|
|
58
|
+
out = replaceOnce(out, 'using the same Dockerfile, one service.',
|
|
59
|
+
'using the same Dockerfile, plus a Postgres service.', 'compose.yaml');
|
|
60
|
+
out = replaceOnce(out,
|
|
61
|
+
"# In production your host provides DATABASE_URL + AUTH_SECRET. Locally this\n" +
|
|
62
|
+
"# uses the scaffold's SQLite file on a named volume so data survives\n" +
|
|
63
|
+
'# `compose down`.',
|
|
64
|
+
'# In production your host provides DATABASE_URL + AUTH_SECRET. Locally this\n' +
|
|
65
|
+
'# runs Postgres in the `db` service on a named volume so data survives\n' +
|
|
66
|
+
'# `compose down`.',
|
|
67
|
+
'compose.yaml');
|
|
68
|
+
out = replaceOnce(out,
|
|
69
|
+
' # SQLite on a volume for local dev. For production, scaffold with\n' +
|
|
70
|
+
' # --db postgres (or swap db/columns.server.ts + db/connection.server.ts\n' +
|
|
71
|
+
' # for the pg variant) and point DATABASE_URL at your managed Postgres.\n' +
|
|
72
|
+
' DATABASE_URL: file:/data/dev.db\n',
|
|
73
|
+
' # The `db` service below. In production, point DATABASE_URL at your\n' +
|
|
74
|
+
' # managed Postgres instead.\n' +
|
|
75
|
+
` DATABASE_URL: ${url}\n`,
|
|
76
|
+
'compose.yaml');
|
|
77
|
+
// The app no longer owns a db file, so it needs no volume; it waits for a
|
|
78
|
+
// healthy database instead, because `webjs start` migrates before serving.
|
|
79
|
+
out = replaceOnce(out,
|
|
80
|
+
' volumes:\n - app-data:/data\n',
|
|
81
|
+
' depends_on:\n db:\n condition: service_healthy\n',
|
|
82
|
+
'compose.yaml');
|
|
83
|
+
out = replaceOnce(out,
|
|
84
|
+
'\nvolumes:\n app-data:\n',
|
|
85
|
+
'\n' +
|
|
86
|
+
' db:\n' +
|
|
87
|
+
` image: ${PG_IMAGE}\n` +
|
|
88
|
+
' environment:\n' +
|
|
89
|
+
` POSTGRES_USER: ${LOCAL_PG_USER}\n` +
|
|
90
|
+
` POSTGRES_PASSWORD: ${LOCAL_PG_PASSWORD}\n` +
|
|
91
|
+
` POSTGRES_DB: ${dbName}\n` +
|
|
92
|
+
' volumes:\n' +
|
|
93
|
+
' - db-data:/var/lib/postgresql/data\n' +
|
|
94
|
+
' healthcheck:\n' +
|
|
95
|
+
` test: ["CMD-SHELL", "pg_isready -U ${LOCAL_PG_USER} -d ${dbName}"]\n` +
|
|
96
|
+
' interval: 5s\n' +
|
|
97
|
+
' timeout: 3s\n' +
|
|
98
|
+
' retries: 10\n' +
|
|
99
|
+
'\n' +
|
|
100
|
+
'volumes:\n' +
|
|
101
|
+
' db-data:\n',
|
|
102
|
+
'compose.yaml');
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Rewrite the GitHub Actions CI workflow for a Postgres app: a `postgres`
|
|
108
|
+
* service container with a health check (the job waits until it is healthy
|
|
109
|
+
* before the first step) and DATABASE_URL pointed at it on localhost.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} s
|
|
112
|
+
* @param {string} dbName
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
export function postgresCi(s, dbName) {
|
|
116
|
+
return replaceOnce(s,
|
|
117
|
+
' env:\n DATABASE_URL: file:./ci.db\n',
|
|
118
|
+
' env:\n' +
|
|
119
|
+
` DATABASE_URL: postgres://${LOCAL_PG_USER}:${LOCAL_PG_PASSWORD}@localhost:5432/${dbName}\n` +
|
|
120
|
+
' # The app is scaffolded with --db postgres, so CI runs against a real\n' +
|
|
121
|
+
' # Postgres. The job waits for the health check before the first step.\n' +
|
|
122
|
+
' services:\n' +
|
|
123
|
+
' postgres:\n' +
|
|
124
|
+
` image: ${PG_IMAGE}\n` +
|
|
125
|
+
' env:\n' +
|
|
126
|
+
` POSTGRES_USER: ${LOCAL_PG_USER}\n` +
|
|
127
|
+
` POSTGRES_PASSWORD: ${LOCAL_PG_PASSWORD}\n` +
|
|
128
|
+
` POSTGRES_DB: ${dbName}\n` +
|
|
129
|
+
' ports:\n' +
|
|
130
|
+
' - 5432:5432\n' +
|
|
131
|
+
' options: >-\n' +
|
|
132
|
+
` --health-cmd "pg_isready -U ${LOCAL_PG_USER} -d ${dbName}"\n` +
|
|
133
|
+
' --health-interval 5s\n' +
|
|
134
|
+
' --health-timeout 5s\n' +
|
|
135
|
+
' --health-retries 10\n',
|
|
136
|
+
'.github/workflows/ci.yml');
|
|
137
|
+
}
|
package/lib/doctor/codes.js
CHANGED
|
@@ -49,6 +49,7 @@ export const DOCTOR_CODES = {
|
|
|
49
49
|
'Component elision (what the browser drops)': 'ELISION_COMPONENTS',
|
|
50
50
|
'Static build outputs (dev.regenerate freshness)': 'STATIC_ASSET_FRESHNESS',
|
|
51
51
|
'Asset urls (unmarked stylesheet links)': 'UNMARKED_ASSET_LINKS',
|
|
52
|
+
'workspace-overrides': 'WORKSPACE_OVERRIDES',
|
|
52
53
|
};
|
|
53
54
|
|
|
54
55
|
/**
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, sep, matchesGlob } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {import('../codes.js').DoctorResult} DoctorResult
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** @param {string} p */
|
|
9
|
+
function readJson(p) {
|
|
10
|
+
try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The workspace globs a root package.json declares (npm / bun / yarn), in
|
|
15
|
+
* either the array form or yarn's `{ packages: [...] }` form, else null.
|
|
16
|
+
* @param {any} pkg
|
|
17
|
+
* @returns {string[] | null}
|
|
18
|
+
*/
|
|
19
|
+
function workspaceGlobs(pkg) {
|
|
20
|
+
const ws = pkg?.workspaces;
|
|
21
|
+
if (Array.isArray(ws)) return ws;
|
|
22
|
+
if (ws && Array.isArray(ws.packages)) return ws.packages;
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Find the workspace root that `appDir` is a MEMBER of: the nearest ancestor
|
|
28
|
+
* whose package.json `workspaces` globs match the app's relative path. A plain
|
|
29
|
+
* parent with a package.json but no matching glob is not a workspace for this
|
|
30
|
+
* app, so it does not count.
|
|
31
|
+
* @param {string} appDir
|
|
32
|
+
* @returns {string | null}
|
|
33
|
+
*/
|
|
34
|
+
export function findWorkspaceRoot(appDir) {
|
|
35
|
+
let dir = dirname(appDir);
|
|
36
|
+
for (;;) {
|
|
37
|
+
const pkg = existsSync(join(dir, 'package.json')) ? readJson(join(dir, 'package.json')) : null;
|
|
38
|
+
const globs = workspaceGlobs(pkg);
|
|
39
|
+
if (globs) {
|
|
40
|
+
const rel = relative(dir, appDir).split(sep).join('/');
|
|
41
|
+
const included = globs.filter((g) => !g.startsWith('!')).some((g) => matchesGlob(rel, g.replace(/^\.\//, '').replace(/\/$/, '')));
|
|
42
|
+
const excluded = globs.filter((g) => g.startsWith('!')).some((g) => matchesGlob(rel, g.slice(1).replace(/^\.\//, '')));
|
|
43
|
+
if (included && !excluded) return dir;
|
|
44
|
+
}
|
|
45
|
+
const parent = dirname(dir);
|
|
46
|
+
if (parent === dir) return null;
|
|
47
|
+
dir = parent;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* CHECK (#1492), dependency overrides declared in a workspace MEMBER. npm and
|
|
53
|
+
* bun honour `overrides` (and yarn / bun `resolutions`) only in the workspace
|
|
54
|
+
* ROOT package.json, so the same block in a member is silently ignored and the
|
|
55
|
+
* security floor it encodes (the scaffold's puppeteer-core and basic-ftp
|
|
56
|
+
* floors) never applies. WARN naming the root to move it to; PASS otherwise.
|
|
57
|
+
* @param {string} appDir
|
|
58
|
+
* @returns {DoctorResult}
|
|
59
|
+
*/
|
|
60
|
+
export function checkWorkspaceOverrides(appDir) {
|
|
61
|
+
const name = 'workspace-overrides';
|
|
62
|
+
const pkg = readJson(join(appDir, 'package.json'));
|
|
63
|
+
const keys = ['overrides', 'resolutions'].filter((k) => pkg && pkg[k] && typeof pkg[k] === 'object' && Object.keys(pkg[k]).length);
|
|
64
|
+
if (keys.length === 0) {
|
|
65
|
+
return { name, status: 'pass', message: 'No dependency overrides in this package.json.' };
|
|
66
|
+
}
|
|
67
|
+
const root = findWorkspaceRoot(appDir);
|
|
68
|
+
if (!root) {
|
|
69
|
+
return { name, status: 'pass', message: `\`${keys.join('` / `')}\` apply: this app is not a workspace member.` };
|
|
70
|
+
}
|
|
71
|
+
const rel = relative(appDir, join(root, 'package.json')) || 'package.json';
|
|
72
|
+
return {
|
|
73
|
+
name,
|
|
74
|
+
status: 'warn',
|
|
75
|
+
message: `package.json declares \`${keys.join('` / `')}\`, but this app is a member of the workspace at ${rel}, `
|
|
76
|
+
+ 'and package managers honour overrides only at the workspace root, so these are ignored.',
|
|
77
|
+
fix: `Move the \`${keys.join('` / `')}\` block into ${rel} (merge it with any the root already has).`,
|
|
78
|
+
};
|
|
79
|
+
}
|
package/lib/doctor/runner.js
CHANGED
|
@@ -11,6 +11,7 @@ import { checkElisionCarriers, checkElisionComponents } from './probes/elision.j
|
|
|
11
11
|
import { checkStaticAssetFreshness } from './probes/static-asset-freshness.js';
|
|
12
12
|
import { checkUnmarkedAssetLinks } from './probes/unmarked-asset-links.js';
|
|
13
13
|
import { checkFrameworkResolves, checkFrameworkLinks } from './probes/framework-resolves.js';
|
|
14
|
+
import { checkWorkspaceOverrides } from './probes/workspace-overrides.js';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* @typedef {import('./codes.js').DoctorResult} DoctorResult
|
|
@@ -64,6 +65,7 @@ export async function runDoctorChecks(appDir, opts = {}) {
|
|
|
64
65
|
checkElisionComponents(elision),
|
|
65
66
|
checkStaticAssetFreshness(appDir),
|
|
66
67
|
checkUnmarkedAssetLinks(appDir),
|
|
68
|
+
Promise.resolve(checkWorkspaceOverrides(appDir)),
|
|
67
69
|
]);
|
|
68
70
|
// Attach the stable machine code to every result (#975). Centralized here so
|
|
69
71
|
// each check function stays free of the code-contract concern.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-manager detection for `webjs create` (#1494).
|
|
3
|
+
*
|
|
4
|
+
* KEEP IN SYNC with `packages/ui/src/utils/package-manager.js`, the copy
|
|
5
|
+
* `webjs ui add` uses. The two published
|
|
6
|
+
* packages carry the same small module rather than one importing the other,
|
|
7
|
+
* so a `@webjsdev/cli` release can never fail at import time against an older
|
|
8
|
+
* `@webjsdev/ui` that lacks the export. `packages/cli/test/package-manager.test.mjs`
|
|
9
|
+
* asserts both copies agree on every fixture.
|
|
10
|
+
*
|
|
11
|
+
* Detection order (with `prefer: 'lockfile'`, the default):
|
|
12
|
+
* 1. A lockfile in `cwd` or any ancestor. The walk stops at the first
|
|
13
|
+
* workspace root (a package.json declaring `workspaces`, or a
|
|
14
|
+
* `pnpm-workspace.yaml`) or at the filesystem root, so an app nested in a
|
|
15
|
+
* workspace finds the root's lockfile. Within one directory the order is
|
|
16
|
+
* pnpm, yarn, bun (`bun.lock`, the text lockfile Bun writes since 1.2,
|
|
17
|
+
* and the older binary `bun.lockb`), then npm.
|
|
18
|
+
* 2. `npm_config_user_agent`, which npm, pnpm, yarn and bun set when they
|
|
19
|
+
* run a script or a `dlx` / `bunx` / `npx` binary.
|
|
20
|
+
* 3. `npm`.
|
|
21
|
+
* With `prefer: 'agent'` steps 1 and 2 swap, which suits `webjs create`: the
|
|
22
|
+
* tool that invoked it is the strongest signal for a directory that has no
|
|
23
|
+
* lockfile yet.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
26
|
+
import { dirname, join, resolve } from 'node:path';
|
|
27
|
+
|
|
28
|
+
/** @typedef {'npm'|'pnpm'|'yarn'|'bun'} PackageManager */
|
|
29
|
+
|
|
30
|
+
/** Lockfile name to manager, in per-directory precedence order. */
|
|
31
|
+
const LOCKFILES = /** @type {const} */ ([
|
|
32
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
33
|
+
['yarn.lock', 'yarn'],
|
|
34
|
+
['bun.lock', 'bun'],
|
|
35
|
+
['bun.lockb', 'bun'],
|
|
36
|
+
['package-lock.json', 'npm'],
|
|
37
|
+
['npm-shrinkwrap.json', 'npm'],
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether `dir` is a workspace root, where the lockfile walk stops.
|
|
42
|
+
* @param {string} dir
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
function isWorkspaceRoot(dir) {
|
|
46
|
+
if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return true;
|
|
47
|
+
const pkgPath = join(dir, 'package.json');
|
|
48
|
+
if (!existsSync(pkgPath)) return false;
|
|
49
|
+
try {
|
|
50
|
+
return Boolean(JSON.parse(readFileSync(pkgPath, 'utf8')).workspaces);
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Walk up from `cwd` looking for a lockfile.
|
|
58
|
+
* @param {string} cwd
|
|
59
|
+
* @returns {PackageManager | null}
|
|
60
|
+
*/
|
|
61
|
+
export function managerFromLockfile(cwd) {
|
|
62
|
+
let dir = resolve(cwd);
|
|
63
|
+
for (;;) {
|
|
64
|
+
for (const [file, manager] of LOCKFILES) {
|
|
65
|
+
if (existsSync(join(dir, file))) return manager;
|
|
66
|
+
}
|
|
67
|
+
if (isWorkspaceRoot(dir)) return null;
|
|
68
|
+
const parent = dirname(dir);
|
|
69
|
+
if (parent === dir) return null;
|
|
70
|
+
dir = parent;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read the manager from an `npm_config_user_agent` value such as
|
|
76
|
+
* `bun/1.3.14 npm/? node/v24.0.0 linux x64`.
|
|
77
|
+
* @param {string | undefined} ua
|
|
78
|
+
* @returns {PackageManager | null}
|
|
79
|
+
*/
|
|
80
|
+
export function managerFromUserAgent(ua) {
|
|
81
|
+
const name = String(ua || '').split('/')[0];
|
|
82
|
+
return name === 'pnpm' || name === 'yarn' || name === 'bun' || name === 'npm' ? name : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {{ cwd?: string | null, env?: Record<string, string | undefined>, prefer?: 'lockfile' | 'agent' }} [opts]
|
|
87
|
+
* @returns {PackageManager}
|
|
88
|
+
*/
|
|
89
|
+
export function detectPackageManager({ cwd = null, env = process.env, prefer = 'lockfile' } = {}) {
|
|
90
|
+
const fromLock = () => (cwd ? managerFromLockfile(cwd) : null);
|
|
91
|
+
const fromAgent = () => managerFromUserAgent(env.npm_config_user_agent);
|
|
92
|
+
return (prefer === 'agent' ? fromAgent() ?? fromLock() : fromLock() ?? fromAgent()) ?? 'npm';
|
|
93
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.59",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
|
|
6
6
|
"bin": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@webjsdev/mcp": "^0.1.0",
|
|
21
|
-
"@webjsdev/server": "^0.8.
|
|
21
|
+
"@webjsdev/server": "^0.8.68",
|
|
22
22
|
"@webjsdev/ui": "^0.3.15"
|
|
23
23
|
},
|
|
24
24
|
"publishConfig": {
|
|
@@ -113,6 +113,17 @@ export const POST = handlers.POST;
|
|
|
113
113
|
<form method="POST" action="/api/auth/signout"><button>Log out</button></form>
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
+
**OAuth sign-in returns the user where they started, with the same one form.** POST to `/api/auth/signin/github` (or `google`) with a hidden `redirectTo`, or link to `GET /api/auth/signin/github?redirectTo=/dashboard/x`; `signIn('github', undefined, { redirectTo })` does the same from an action. The target rides through the provider round trip in a short-lived signed cookie and the callback lands on it, so no wrapper around the auth route is needed:
|
|
117
|
+
|
|
118
|
+
```html
|
|
119
|
+
<form method="POST" action="/api/auth/signin/github">
|
|
120
|
+
<input type="hidden" name="redirectTo" value="/dashboard/x">
|
|
121
|
+
<button>Sign in with GitHub</button>
|
|
122
|
+
</form>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A `redirectTo` that arrives from a request (a form field or a query param, for OAuth or credentials) must be a same-origin local path: one leading `/`, not followed by `/` or `\`. An absolute URL, a protocol-relative `//host`, or a backslash variant is dropped (not repaired) and the sign-in lands on `/`, so the field is never an open redirect. A denied sign-in still goes to `pages.error`.
|
|
126
|
+
|
|
116
127
|
For a programmatic sign-in (the auto-login-after-signup pattern), `signIn('credentials', creds, { redirectTo })` returns a `302` `Response` that a form-bound action can return directly (the framework honors a returned `Response` verbatim).
|
|
117
128
|
|
|
118
129
|
Sessions are JWT by default (stateless, scales horizontally). OAuth
|
|
@@ -22,6 +22,24 @@ Read this when wiring caching or rate limiting, storing uploads, hardening heade
|
|
|
22
22
|
| `REDIS_URL` | When set, sessions, rate limit, and cache use Redis instead of memory |
|
|
23
23
|
| `SESSION_SECRET` / `AUTH_SECRET` | Session and auth signing (see `auth-and-sessions.md`) |
|
|
24
24
|
| `PORT` | Listen port. Precedence `--port` flag, then `PORT` (real env or `.env`), then `8080` |
|
|
25
|
+
| `WEBJS_SOURCE_LOCATIONS` | `webjs dev` only. `1` stamps `data-webjs-src="<app-relative-file>:<line>"` on the elements of the app's `html` templates (see below). Ignored by `webjs start` |
|
|
26
|
+
| `WEBJS_EMBED_ORIGINS` | `webjs dev` only. Comma-separated parent origins (`https://builder.dev,http://localhost:8080`) allowed to frame the dev server and receive the embed bridge's messages (see below). Ignored by `webjs start` |
|
|
27
|
+
|
|
28
|
+
**Source locations for tooling (`WEBJS_SOURCE_LOCATIONS=1`, dev only).** A tool that hosts the app (an inspector, click-to-edit in an embedding builder) can map a clicked element back to the line that wrote it. With the variable set, `webjs dev` adds `data-webjs-src="components/todo-list.ts:12"` to every element opening tag written in an `html` template inside the app, both in the SSR markup and in client renders (one source transform applied to the served module and to the module the server imports, so the two agree and hydration is unaffected). Read it with `el.closest('[data-webjs-src]')`. Not annotated: `*.server.*` modules, `node_modules`, `css` / `svg` tagged templates, `html` / `head` / `body` / head-only and raw-text elements, and the descendants of `svg` / `math`. Lines are exact; nothing reaches production. The importmap `<script>` in `<head>` carries an unrelated `data-webjs-src` (the app-source deploy id), so match the `file:line` value shape when querying the whole document.
|
|
29
|
+
|
|
30
|
+
**Embed bridge for iframe previews (`WEBJS_EMBED_ORIGINS`, dev only).** A tool that previews the app inside an iframe (an app builder, a docs playground) sets `WEBJS_EMBED_ORIGINS` to its own origin(s). `webjs dev` then (1) drops `X-Frame-Options` and adds those origins to a CSP `frame-ancestors`, so the frame loads without the app stripping headers in `webjs.headers`, and (2) inlines a small nonce-signed script into every document that, when framed by a listed origin, posts to `window.parent` (with that exact target origin, never `*`):
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
{ source: 'webjs-embed', type: 'ready', path, title } // document parsed
|
|
34
|
+
{ source: 'webjs-embed', type: 'navigate', path, title } // every client-router navigation + popstate
|
|
35
|
+
{ source: 'webjs-embed', type: 'console', level: 'error' | 'warn', message, dropped? } // max 20/s
|
|
36
|
+
{ source: 'webjs-embed', type: 'error', message, stack, file, line, column } // window error + unhandledrejection
|
|
37
|
+
{ source: 'webjs-embed', type: 'network', method, url, status, error? } // fetch/XHR status >= 500, or status 0 on failure
|
|
38
|
+
{ source: 'webjs-embed', type: 'server-error', kind, message, file, line, path } // the dev error overlay went up
|
|
39
|
+
{ source: 'webjs-embed', type: 'select', src, tag, text, rect: { x, y, width, height } } // a click in inspect mode
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
and accepts, only from `window.parent` on a listed origin: `{ source: 'webjs-embed-host', type: 'navigate', path }` (a local path; a soft navigation when the client router is on), `{ source: 'webjs-embed-host', type: 'reload' }`, and `{ source: 'webjs-embed-host', type: 'inspect', enabled: true | false }` (hover highlight plus click capture that posts `select`; `src` is the nearest `data-webjs-src`, so pair it with `WEBJS_SOURCE_LOCATIONS=1` for click-to-edit). Paths are app paths (`/api/x`, `/components/x.ts`). Unset adds zero bytes; `webjs start` never injects it nor relaxes a header.
|
|
25
43
|
|
|
26
44
|
Defaults are single-instance memory stores. To scale horizontally, switch the store once at startup: `setStore(redisStore({ url: process.env.REDIS_URL }))`.
|
|
27
45
|
|
|
@@ -275,6 +293,25 @@ Three levels, the same scale ESLint uses: `error` fails the exit, `warn` reports
|
|
|
275
293
|
|
|
276
294
|
Two guarantees worth knowing. A result that could not check (a network or toolchain outage) is capped at `warn` and can never be escalated, so a jspm or npm outage cannot red your CI. And a malformed gate exits 1 naming the offender rather than being ignored, so a typo cannot silently un-gate the build. That covers an unknown code, a bad severity, a wrong shape (a non-object `doctor` or `gate`), and a misspelled sibling of `gate` such as `gates`, since every one of those would otherwise leave the build un-gated while the `package.json` looks gated. Under `--json` the offenders come back as a `configErrors` array alongside an empty `results`, each entry a `{ kind }` of `malformed` / `unknown-key` / `unknown-code` / `bad-severity`. Wire it up with one workflow step, `npm run doctor`, and change what is fatal in `package.json` rather than in the workflow.
|
|
277
295
|
|
|
296
|
+
### Dependency audit allowlist
|
|
297
|
+
|
|
298
|
+
`webjs audit` runs `npm audit` or `bun audit` (by the nearest lockfile, so a workspace member uses the root's) and fails on any advisory at or above `webjs.audit.level` (default `high`) that `webjs.audit.ignore` does not list. The scaffold's `Security: dependency audit` CI step runs it.
|
|
299
|
+
|
|
300
|
+
```jsonc
|
|
301
|
+
{ "webjs": {
|
|
302
|
+
"audit": {
|
|
303
|
+
"level": "high",
|
|
304
|
+
"ignore": [
|
|
305
|
+
{ "id": "GHSA-vfj7-8cjw-p6xm", "reason": "braces has no patched release; reached only through dev tooling" }
|
|
306
|
+
]
|
|
307
|
+
}
|
|
308
|
+
} }
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
The allowlist is the ONE place an accepted advisory lives, each with its reason. Accept only an advisory with no patched release that the app's users cannot reach; upgrade anything that has a fix. Never "fix" a red audit with `npm audit fix --force`, which proposes breaking majors that often keep the same vulnerable chain. A malformed block (unknown key, bad level, an entry without an id or a reason) exits 1, and an id the audit stops reporting prints as stale, so remove it then.
|
|
312
|
+
|
|
313
|
+
Overrides apply only at a WORKSPACE ROOT. The scaffold's `overrides` block (the `puppeteer-core` and `basic-ftp` security floors) is ignored once the app is a member of an npm or bun workspace, so move it into the root `package.json`; `webjs doctor` warns with `WORKSPACE_OVERRIDES` until you do.
|
|
314
|
+
|
|
278
315
|
## Observability
|
|
279
316
|
|
|
280
317
|
Wired at the single response funnel, covering pages, routes, actions, and assets uniformly.
|
|
@@ -63,6 +63,8 @@ WEBJS_E2E=1 npm run test # adds the e2e layer
|
|
|
63
63
|
|
|
64
64
|
`npm run test` dispatches on the runtime (`node --test` on Node, `bun test` on Bun). The scaffold's `web-test-runner.config.js` globs `test/**/browser/**/*.test.js` and is already wired, so you do not set it up.
|
|
65
65
|
|
|
66
|
+
An app with no browser tests yet (for example right after `npm run gallery:clear`) is not a failure: `webjs test --browser` sees that no file matches the config's `files` globs, prints `no browser tests yet`, and exits 0, the same way the server layer passes with zero files. So `npm run ci` stays green until you write the first browser test, and from then on the browser layer runs as normal.
|
|
67
|
+
|
|
66
68
|
A scaffolded app has one root `test/` directory shaped the same way (feature first, kind second):
|
|
67
69
|
|
|
68
70
|
```
|
package/templates/.dockerignore
CHANGED
|
@@ -18,7 +18,7 @@ build
|
|
|
18
18
|
out
|
|
19
19
|
.cache
|
|
20
20
|
|
|
21
|
-
# Local env files. The container gets its env from compose
|
|
21
|
+
# Local env files. The container gets its env from compose or the host, not a
|
|
22
22
|
# committed file. Keep the example for reference.
|
|
23
23
|
.env
|
|
24
24
|
!.env.example
|
package/templates/Dockerfile
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Production image for the {{APP_NAME}} webjs app.
|
|
2
2
|
#
|
|
3
|
-
# Works with a plain `docker build` / `docker compose up`, and
|
|
4
|
-
#
|
|
3
|
+
# Works with a plain `docker build` / `docker compose up`, and with any host
|
|
4
|
+
# that builds from a Dockerfile.
|
|
5
5
|
#
|
|
6
6
|
# webjs serves .ts directly by stripping types at the runtime layer, so there is
|
|
7
7
|
# NO JavaScript build step (webjs is buildless end to end; there is no bundler or
|
|
@@ -41,7 +41,7 @@ COPY . .
|
|
|
41
41
|
# step runs `webjs db migrate`). See the CMD note below.
|
|
42
42
|
|
|
43
43
|
ENV NODE_ENV=production
|
|
44
|
-
# webjs start reads $PORT (default 8080). compose
|
|
44
|
+
# webjs start reads $PORT (default 8080). compose and most hosts set it.
|
|
45
45
|
ENV PORT=8080
|
|
46
46
|
EXPOSE 8080
|
|
47
47
|
|
package/templates/compose.yaml
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
#
|
|
3
3
|
# docker compose up --build → http://localhost:8080
|
|
4
4
|
#
|
|
5
|
-
# In production
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# In production your host provides DATABASE_URL + AUTH_SECRET. Locally this
|
|
6
|
+
# uses the scaffold's SQLite file on a named volume so data survives
|
|
7
|
+
# `compose down`.
|
|
8
8
|
services:
|
|
9
9
|
app:
|
|
10
10
|
build: .
|