@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/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
+ }
@@ -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'));
@@ -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
+ }