@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 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
- const runtime = opts.runtime || (detectPackageManager() === 'bun' ? 'bun' : 'node');
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
- ...(isApi ? {} : { '@tailwindcss/cli': '^4.1.0' }),
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
- title: 'Security: dependency audit',
598
- run: isBun ? 'bun audit --audit-level=high' : 'npm audit --audit-level=high',
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
- const pm = isBun ? 'bun' : detectPackageManager();
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
+ }
@@ -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
+ }
@@ -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.58",
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.67",
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
  ```
@@ -18,7 +18,7 @@ build
18
18
  out
19
19
  .cache
20
20
 
21
- # Local env files. The container gets its env from compose / uncloud, not a
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
@@ -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 is the same
4
- # artifact the webdeploy hosting tool (ubicloud + uncloud) builds and ships.
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 / uncloud / Railway set it.
44
+ # webjs start reads $PORT (default 8080). compose and most hosts set it.
45
45
  ENV PORT=8080
46
46
  EXPOSE 8080
47
47
 
@@ -2,9 +2,9 @@
2
2
  #
3
3
  # docker compose up --build → http://localhost:8080
4
4
  #
5
- # In production the webdeploy tool (ubicloud + uncloud) provisions a managed
6
- # Postgres and injects DATABASE_URL + AUTH_SECRET for you. Locally this uses
7
- # the scaffold's SQLite file on a named volume so data survives `compose down`.
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: .