@webjsdev/cli 0.10.57 → 0.10.58
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 +6 -1
- package/bin/webjs.js +219 -9
- package/lib/app-tasks.js +70 -10
- 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 +51 -1
- 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/built-ins.md +42 -0
- package/templates/.agents/skills/webjs/references/styling.md +1 -1
- package/templates/.agents/skills/webjs/references/testing.md +11 -0
- package/templates/.agents/skills/webjs/references/ui-kit.md +25 -0
- 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/partials/agents-playbook-api.md +14 -8
- package/templates/partials/agents-playbook-fullstack.md +16 -10
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
|
+
}
|