@webjsdev/cli 0.10.57 → 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 +7 -2
- package/bin/webjs.js +316 -9
- package/lib/app-tasks.js +70 -10
- package/lib/audit.js +218 -0
- package/lib/browser-test-files.js +125 -0
- package/lib/check-target.js +1 -1
- package/lib/ci-config.js +250 -0
- package/lib/ci-runner.js +499 -0
- package/lib/create.js +104 -21
- 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/lib/run-tasks.js +23 -3
- package/package.json +3 -3
- package/templates/.agents/rules/workflow.md +15 -9
- package/templates/.agents/skills/webjs/SKILL.md +1 -0
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +11 -0
- package/templates/.agents/skills/webjs/references/built-ins.md +79 -0
- package/templates/.agents/skills/webjs/references/styling.md +1 -1
- package/templates/.agents/skills/webjs/references/testing.md +13 -0
- package/templates/.agents/skills/webjs/references/ui-kit.md +25 -0
- package/templates/.dockerignore +1 -1
- package/templates/.github/pull_request_template.md +3 -4
- package/templates/.github/workflows/ci.yml +38 -88
- package/templates/.hooks/pre-commit +5 -4
- package/templates/Dockerfile +3 -3
- package/templates/compose.yaml +3 -3
- package/templates/partials/agents-playbook-api.md +14 -8
- package/templates/partials/agents-playbook-fullstack.md +16 -10
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/check-target.js
CHANGED
|
@@ -69,7 +69,7 @@ export async function findCheckTarget(cwd) {
|
|
|
69
69
|
* @param {string} cwd
|
|
70
70
|
* @returns {Promise<string[]>}
|
|
71
71
|
*/
|
|
72
|
-
async function workspaceApps(cwd) {
|
|
72
|
+
export async function workspaceApps(cwd) {
|
|
73
73
|
let patterns;
|
|
74
74
|
try {
|
|
75
75
|
const pkg = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8'));
|
package/lib/ci-config.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read the local-CI step list from an app's `package.json` `"webjs": { "ci" }`
|
|
6
|
+
* block (#1471), the list `webjs ci` runs. Modeled on Rails 8.1's `config/ci.rb`
|
|
7
|
+
* and shaped like the #550 `dev` / `start` orchestration: data in the `webjs`
|
|
8
|
+
* block, read by the CLI, never by the server, so every tool (the JSON Schema,
|
|
9
|
+
* `webjs doctor`, a cloud workflow that calls `npm run ci`) learns the list
|
|
10
|
+
* without importing app code.
|
|
11
|
+
*
|
|
12
|
+
* Shape:
|
|
13
|
+
* "webjs": { "ci": { "steps": [
|
|
14
|
+
* "webjs check", // shorthand: title = command
|
|
15
|
+
* { "title": "Types", "run": "webjs typecheck" },
|
|
16
|
+
* { "title": "Checks", "parallel": 2, "steps": [
|
|
17
|
+
* { "title": "Tests", "steps": [ // a nested group takes ONE slot
|
|
18
|
+
* { "title": "e2e", "run": "webjs test --server", "env": { "WEBJS_E2E": "1" } }
|
|
19
|
+
* ] }
|
|
20
|
+
* ] }
|
|
21
|
+
* ] } }
|
|
22
|
+
*
|
|
23
|
+
* The boot validator (`@webjsdev/server` webjs-config-validate.js) checks only
|
|
24
|
+
* top-level key membership and never follows the schema's `$ref`, so this
|
|
25
|
+
* reader validates the step shapes itself and reports every problem with its
|
|
26
|
+
* JSON path, rather than silently skipping a malformed entry: a step that is
|
|
27
|
+
* dropped is a check that never ran, which is the exact false green local CI
|
|
28
|
+
* exists to prevent. The bin refuses to run on any problem.
|
|
29
|
+
*
|
|
30
|
+
* Pure (reads one file, never spawns / prints / exits), with the reader
|
|
31
|
+
* injectable, matching `app-tasks.js`.
|
|
32
|
+
*
|
|
33
|
+
* @typedef {{ kind: 'step', title: string, run: string, env: Record<string, string> }} CiStep
|
|
34
|
+
* @typedef {{ kind: 'group', title: string, parallel: number, steps: CiNode[] }} CiGroup
|
|
35
|
+
* @typedef {CiStep | CiGroup} CiNode
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} appDir
|
|
40
|
+
* @param {(p: string) => string} [readFile] injectable reader for tests
|
|
41
|
+
* @returns {{ declared: boolean, steps: CiNode[], problems: string[] }}
|
|
42
|
+
* `declared` is false when there is no `webjs.ci` block at all (the bin
|
|
43
|
+
* turns that into a "nothing declared" error naming where to declare one),
|
|
44
|
+
* as opposed to a block that is present but malformed (`problems`).
|
|
45
|
+
*/
|
|
46
|
+
export function readCiConfig(appDir, readFile) {
|
|
47
|
+
const read = readFile || ((p) => readFileSync(p, 'utf8'));
|
|
48
|
+
let pkg;
|
|
49
|
+
try {
|
|
50
|
+
pkg = JSON.parse(read(join(appDir, 'package.json')));
|
|
51
|
+
} catch {
|
|
52
|
+
return { declared: false, steps: [], problems: [] };
|
|
53
|
+
}
|
|
54
|
+
const webjs = pkg && typeof pkg === 'object' ? pkg.webjs : null;
|
|
55
|
+
const ci = webjs && typeof webjs === 'object' ? webjs.ci : undefined;
|
|
56
|
+
if (ci === undefined) return { declared: false, steps: [], problems: [] };
|
|
57
|
+
if (!isPlainObject(ci)) {
|
|
58
|
+
return { declared: true, steps: [], problems: ['webjs.ci must be an object holding a `steps` array'] };
|
|
59
|
+
}
|
|
60
|
+
const problems = [];
|
|
61
|
+
for (const key of Object.keys(ci)) {
|
|
62
|
+
if (key !== 'steps') problems.push(`webjs.ci has an unknown key "${key}" (only \`steps\` is read)`);
|
|
63
|
+
}
|
|
64
|
+
if (ci.steps === undefined) {
|
|
65
|
+
problems.push('webjs.ci.steps is missing');
|
|
66
|
+
return { declared: true, steps: [], problems };
|
|
67
|
+
}
|
|
68
|
+
const r = normalizeSteps(ci.steps, 'webjs.ci.steps', false);
|
|
69
|
+
problems.push(...r.problems);
|
|
70
|
+
return { declared: true, steps: r.steps, problems };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Normalize a raw step array into `CiNode`s, collecting every shape problem
|
|
75
|
+
* with its JSON path. A string is shorthand for a command titled by itself; an
|
|
76
|
+
* object with `steps` is a group; an object with `run` is a command. Only a
|
|
77
|
+
* TOP-LEVEL group may declare `parallel`: a nested group takes one slot of
|
|
78
|
+
* its parent and runs its steps in order, so `parallel` on it is reported
|
|
79
|
+
* rather than honoured (the Rails rule: sub-groups cannot be parallelized),
|
|
80
|
+
* which is exactly what the JSON Schema's `ciNestedStep` and the
|
|
81
|
+
* `WebjsCiNestedGroup` type say.
|
|
82
|
+
*
|
|
83
|
+
* @param {unknown} raw
|
|
84
|
+
* @param {string} path JSON path used in problem messages
|
|
85
|
+
* @param {boolean} nested whether these steps sit inside a group
|
|
86
|
+
* @returns {{ steps: CiNode[], problems: string[] }}
|
|
87
|
+
*/
|
|
88
|
+
export function normalizeSteps(raw, path = 'webjs.ci.steps', nested = false) {
|
|
89
|
+
/** @type {CiNode[]} */
|
|
90
|
+
const steps = [];
|
|
91
|
+
/** @type {string[]} */
|
|
92
|
+
const problems = [];
|
|
93
|
+
if (!Array.isArray(raw)) {
|
|
94
|
+
return { steps, problems: [`${path} must be an array of steps`] };
|
|
95
|
+
}
|
|
96
|
+
raw.forEach((item, i) => {
|
|
97
|
+
const at = `${path}[${i}]`;
|
|
98
|
+
if (typeof item === 'string') {
|
|
99
|
+
const run = item.trim();
|
|
100
|
+
if (!run) problems.push(`${at} is an empty command`);
|
|
101
|
+
else steps.push({ kind: 'step', title: run, run, env: {} });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (!isPlainObject(item)) {
|
|
105
|
+
problems.push(`${at} must be a command string, a { title, run } object, or a { title, steps } group`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const title = typeof item.title === 'string' ? item.title.trim() : '';
|
|
109
|
+
if (Object.prototype.hasOwnProperty.call(item, 'steps')) {
|
|
110
|
+
for (const key of Object.keys(item)) {
|
|
111
|
+
if (!['title', 'steps', 'parallel'].includes(key)) {
|
|
112
|
+
problems.push(`${at} has an unknown key "${key}" (a group takes title, steps, parallel)`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!title) problems.push(`${at} (a group) needs a non-empty title`);
|
|
116
|
+
let parallel = 1;
|
|
117
|
+
if (item.parallel !== undefined) {
|
|
118
|
+
if (nested) {
|
|
119
|
+
// One rule on every surface (the schema's ciNestedStep, the
|
|
120
|
+
// WebjsCiNestedGroup type, the docs): a nested group never declares
|
|
121
|
+
// parallel, whatever its parent is. It takes one slot and runs in order.
|
|
122
|
+
problems.push(
|
|
123
|
+
`${at}.parallel is not allowed on a nested group (it takes one slot of its parent and runs its steps in order)`,
|
|
124
|
+
);
|
|
125
|
+
} else if (!Number.isInteger(item.parallel) || item.parallel < 1) {
|
|
126
|
+
problems.push(`${at}.parallel must be an integer of at least 1`);
|
|
127
|
+
} else {
|
|
128
|
+
parallel = item.parallel;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const inner = normalizeSteps(item.steps, `${at}.steps`, true);
|
|
132
|
+
problems.push(...inner.problems);
|
|
133
|
+
if (Array.isArray(item.steps) && item.steps.length === 0) problems.push(`${at}.steps is empty`);
|
|
134
|
+
steps.push({ kind: 'group', title: title || `group ${i}`, parallel, steps: inner.steps });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
for (const key of Object.keys(item)) {
|
|
138
|
+
if (!['title', 'run', 'env'].includes(key)) {
|
|
139
|
+
problems.push(`${at} has an unknown key "${key}" (a command takes title, run, env)`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const run = typeof item.run === 'string' ? item.run.trim() : '';
|
|
143
|
+
if (!run) problems.push(`${at}.run must be a non-empty command string`);
|
|
144
|
+
if (!title) problems.push(`${at}.title must be a non-empty string`);
|
|
145
|
+
/** @type {Record<string, string>} */
|
|
146
|
+
const env = {};
|
|
147
|
+
if (item.env !== undefined) {
|
|
148
|
+
if (!isPlainObject(item.env)) {
|
|
149
|
+
problems.push(`${at}.env must be an object of string values`);
|
|
150
|
+
} else {
|
|
151
|
+
for (const [k, v] of Object.entries(item.env)) {
|
|
152
|
+
if (typeof v === 'string') env[k] = v;
|
|
153
|
+
else problems.push(`${at}.env.${k} must be a string`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (run && title) steps.push({ kind: 'step', title, run, env });
|
|
158
|
+
});
|
|
159
|
+
return { steps, problems };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Select the steps `--only <title>` names. A matched group is taken WHOLE (its
|
|
164
|
+
* children are not searched further); matching is case-insensitive on the
|
|
165
|
+
* trimmed title. A title that matches nothing is a problem rather than a
|
|
166
|
+
* silent empty run, since "ran zero steps" reads as green.
|
|
167
|
+
*
|
|
168
|
+
* @param {CiNode[]} steps
|
|
169
|
+
* @param {string[]} only
|
|
170
|
+
* @returns {{ steps: CiNode[], problems: string[] }}
|
|
171
|
+
*/
|
|
172
|
+
export function selectSteps(steps, only) {
|
|
173
|
+
if (!only || only.length === 0) return { steps, problems: [] };
|
|
174
|
+
const wanted = only.map((t) => t.trim().toLowerCase());
|
|
175
|
+
const hit = new Set();
|
|
176
|
+
/** @type {CiNode[]} */
|
|
177
|
+
const picked = [];
|
|
178
|
+
const walk = (nodes) => {
|
|
179
|
+
for (const node of nodes) {
|
|
180
|
+
const key = node.title.trim().toLowerCase();
|
|
181
|
+
const idx = wanted.indexOf(key);
|
|
182
|
+
if (idx !== -1) {
|
|
183
|
+
hit.add(idx);
|
|
184
|
+
picked.push(node);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (node.kind === 'group') walk(node.steps);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
walk(steps);
|
|
191
|
+
const problems = only
|
|
192
|
+
.filter((_, i) => !hit.has(i))
|
|
193
|
+
.map((t) => `--only "${t}" matches no step or group title`);
|
|
194
|
+
return { steps: picked, problems };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Every command step in tree order (groups flattened), for counting and for
|
|
199
|
+
* the JSON report.
|
|
200
|
+
*
|
|
201
|
+
* @param {CiNode[]} steps
|
|
202
|
+
* @returns {CiStep[]}
|
|
203
|
+
*/
|
|
204
|
+
export function flattenSteps(steps) {
|
|
205
|
+
/** @type {CiStep[]} */
|
|
206
|
+
const out = [];
|
|
207
|
+
for (const node of steps) {
|
|
208
|
+
if (node.kind === 'group') out.push(...flattenSteps(node.steps));
|
|
209
|
+
else out.push(node);
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The refusal `webjs ci` prints when the directory declares no `webjs.ci`
|
|
216
|
+
* block: what is missing, where it goes, and (at a workspace root) which
|
|
217
|
+
* member apps already declare one. Mirrors `notAnAppMessage` in
|
|
218
|
+
* check-target.js. A run with nothing declared exits 1 rather than 0, because
|
|
219
|
+
* "ran zero steps" would read as green.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} cwd
|
|
222
|
+
* @param {string[]} apps workspace members that DO declare a `webjs.ci` block
|
|
223
|
+
*/
|
|
224
|
+
export function noCiConfigMessage(cwd, apps) {
|
|
225
|
+
const lines = [
|
|
226
|
+
'webjs ci: nothing to run, this package.json declares no "webjs": { "ci" } block.',
|
|
227
|
+
'',
|
|
228
|
+
` ${cwd}`,
|
|
229
|
+
'',
|
|
230
|
+
'Declare the steps once and every tool reads the same list:',
|
|
231
|
+
'',
|
|
232
|
+
' "webjs": { "ci": { "steps": [',
|
|
233
|
+
' "webjs check",',
|
|
234
|
+
' { "title": "Tests", "run": "webjs test" }',
|
|
235
|
+
' ] } }',
|
|
236
|
+
'',
|
|
237
|
+
];
|
|
238
|
+
if (apps.length > 0) {
|
|
239
|
+
lines.push('These workspace members declare one. Run it inside each:', '');
|
|
240
|
+
for (const app of apps) lines.push(` ( cd ${app} && npx webjs ci )`);
|
|
241
|
+
lines.push('');
|
|
242
|
+
}
|
|
243
|
+
lines.push('`webjs help ci` shows the flags.');
|
|
244
|
+
return lines.join('\n');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** @param {unknown} v */
|
|
248
|
+
function isPlainObject(v) {
|
|
249
|
+
return !!v && typeof v === 'object' && !Array.isArray(v);
|
|
250
|
+
}
|