@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/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
|
|
|
@@ -48,8 +48,12 @@ webjs start # production server (no build step, serves source
|
|
|
48
48
|
webjs check # validate source-code conventions (CI gate)
|
|
49
49
|
webjs doctor # verify the project/toolchain setup (per-check severity via webjs.doctor.gate, so CI can gate a subset)
|
|
50
50
|
webjs test # run server + browser tests
|
|
51
|
+
webjs ci # local CI: run the webjs.ci step list in package.json (check, doctor,
|
|
52
|
+
# typecheck, audit, every test layer), one result line per step;
|
|
53
|
+
# --only <title>, --fail-fast, --json, --signoff (gh signoff after a green run)
|
|
54
|
+
webjs create <name> --skip-ci # omit the GitHub workflow (the local ci list always ships)
|
|
51
55
|
webjs vendor pin [--download] # pin client deps to a committable importmap (offline/reproducible)
|
|
52
|
-
webjs db <generate|migrate|push|studio|seed> # drizzle-kit passthrough (+ seed)
|
|
56
|
+
webjs db <generate|migrate|push|studio|seed> # drizzle-kit passthrough (+ seed) by default; a package.json webjs.db block maps any verb to your own ORM's command
|
|
53
57
|
|
|
54
58
|
webjs ui init # initialise @webjsdev/ui in this project
|
|
55
59
|
webjs ui add <names...> # copy components from the registry (https://webjs.dev/ui/registry/<name>.json)
|
|
@@ -70,6 +74,7 @@ The scaffold seeds opinionated defaults so AI agents produce consistent code:
|
|
|
70
74
|
|
|
71
75
|
- `AGENTS.md` + `CONVENTIONS.md` + `.agents/skills/webjs/` (single cross-agent source of truth)
|
|
72
76
|
- `.agents/rules/workflow.md` & `.claude/` protective hooks
|
|
77
|
+
- A `webjs.ci` step list in `package.json` (`npm run ci`, local CI) and a one-job GitHub workflow that runs the same list
|
|
73
78
|
- `test/<feature>/` (with optional `browser/` / `e2e/` subfolders per kind) with example tests
|
|
74
79
|
- Tailwind CSS via CLI (no browser runtime at build time)
|
|
75
80
|
- TypeScript, `.editorconfig`, `.gitignore`
|
package/bin/webjs.js
CHANGED
|
@@ -90,6 +90,11 @@ const USAGE = `webjs commands:
|
|
|
90
90
|
webjs start [--port 8080] Start production server (serves source directly, no build step)
|
|
91
91
|
webjs test [--server|--browser] Run server + browser tests
|
|
92
92
|
webjs check [--json] Run correctness checks (--json emits structured violations)
|
|
93
|
+
webjs ci [-f|--fail-fast] [--only <title>] [--json] [--signoff]
|
|
94
|
+
Run the local CI steps declared under "webjs": { "ci": { "steps": [...] } }
|
|
95
|
+
in package.json (timed, one result line per step, parallel groups replayed whole,
|
|
96
|
+
exit 1 on any failure). The same list a cloud pipeline runs via "npm run ci".
|
|
97
|
+
--only runs one step or group by title; --signoff runs "gh signoff" after a green run
|
|
93
98
|
webjs routes [--json|--table] [--no-headers] Print the route table (path / owner file / methods). Default tree; --json matches the MCP list_routes shape; --no-headers drops the --table header
|
|
94
99
|
webjs elision [--json] [--verify] Report which component modules are elided and why each shipped one ships;
|
|
95
100
|
--verify diffs SSR output with elision on vs off (exits non-zero on a divergence)
|
|
@@ -98,9 +103,11 @@ const USAGE = `webjs commands:
|
|
|
98
103
|
--json emits the structured results (with stable codes). --strict additionally fails on every remaining warning.
|
|
99
104
|
Per-check severity is CONFIG: map a code to off/warn/error under "webjs": { "doctor": { "gate": {...} } }
|
|
100
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
|
|
101
108
|
webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
|
|
102
109
|
webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors)
|
|
103
|
-
webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install] Scaffold a new webjs app
|
|
110
|
+
webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install] [--skip-ci] Scaffold a new webjs app
|
|
104
111
|
<name> must be a valid package name (letters, digits, - . _, starts with a letter or digit)
|
|
105
112
|
(only 2 templates exist. default: full-stack, Drizzle, --db sqlite, --runtime node)
|
|
106
113
|
--runtime bun emits a Bun-flavored app (bun.lock, bun Dockerfile/CI, bun docs);
|
|
@@ -112,6 +119,9 @@ const USAGE = `webjs commands:
|
|
|
112
119
|
webjs db push Push the schema straight to the dev DB (drizzle-kit push)
|
|
113
120
|
webjs db studio Open the database browser (drizzle-kit studio)
|
|
114
121
|
webjs db seed Run the app's db/seed.server.ts
|
|
122
|
+
webjs db <verb> Any verb "webjs": { "db": { "<verb>": "<command>" } } maps in
|
|
123
|
+
package.json runs that command instead (bring your own ORM);
|
|
124
|
+
an unmapped verb keeps the drizzle-kit default above
|
|
115
125
|
webjs ui <subcmd> AI-first component library CLI
|
|
116
126
|
(init / add / list / view / diff / info)
|
|
117
127
|
Requires @webjsdev/ui installed in the project
|
|
@@ -171,6 +181,25 @@ const HELP = {
|
|
|
171
181
|
],
|
|
172
182
|
examples: ['webjs check', 'webjs check --json', 'webjs check --rules'],
|
|
173
183
|
},
|
|
184
|
+
ci: {
|
|
185
|
+
usage: 'webjs ci [-f|--fail-fast] [--only <title>]... [--json] [--signoff]',
|
|
186
|
+
summary:
|
|
187
|
+
'Run the local CI steps declared in package.json under "webjs": { "ci": { "steps": [...] } }: ' +
|
|
188
|
+
'each step is timed and reported, a parallel group replays each step\'s output whole, and the exit is 1 on any failure. ' +
|
|
189
|
+
'A cloud pipeline runs the same list through `npm run ci`, so the two cannot drift.',
|
|
190
|
+
options: [
|
|
191
|
+
{ flag: '-f, --fail-fast', description: 'Stop after the first failing step instead of running every step and listing every failure.' },
|
|
192
|
+
{ flag: '--only <title>', description: 'Run only the step or group with this title (repeatable, case-insensitive; a matched group runs whole).' },
|
|
193
|
+
{ flag: '--json', description: 'Emit one JSON document on stdout ({ ok, seconds, steps[] }, failed steps carry their output); the human output goes to stderr.' },
|
|
194
|
+
{ flag: '--signoff', description: 'After a green run, run `gh signoff` (basecamp/gh-signoff) to post a green commit status; a red run prints the do-not-merge heading instead.' },
|
|
195
|
+
],
|
|
196
|
+
notesTitle: 'Environment:',
|
|
197
|
+
notes: [
|
|
198
|
+
'Every step runs with CI=true and node_modules/.bin on PATH, plus the step\'s own `env`.',
|
|
199
|
+
'Under GitHub Actions each step is a log group and a failure is annotated; a $GITHUB_STEP_SUMMARY table is appended.',
|
|
200
|
+
],
|
|
201
|
+
examples: ['webjs ci', 'webjs ci --fail-fast', 'webjs ci --only Tests', 'webjs ci --json', 'webjs ci --signoff'],
|
|
202
|
+
},
|
|
174
203
|
routes: {
|
|
175
204
|
usage: 'webjs routes [--json | --table] [--no-headers]',
|
|
176
205
|
summary: 'Print the route table: each page/route path, its owner file, and (for route handlers) its HTTP methods.',
|
|
@@ -199,6 +228,22 @@ const HELP = {
|
|
|
199
228
|
],
|
|
200
229
|
examples: ['webjs elision', 'webjs elision --json', 'webjs elision --verify', 'webjs elision --verify --routes /,/blog/hello'],
|
|
201
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
|
+
},
|
|
202
247
|
doctor: {
|
|
203
248
|
usage: 'webjs doctor [--json] [--strict]',
|
|
204
249
|
summary: 'Verify project health. Each result carries a stable code so an agent branches on the failure kind.',
|
|
@@ -228,7 +273,7 @@ const HELP = {
|
|
|
228
273
|
examples: ['webjs typecheck', 'webjs typecheck --watch'],
|
|
229
274
|
},
|
|
230
275
|
create: {
|
|
231
|
-
usage: 'webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install]',
|
|
276
|
+
usage: 'webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install] [--skip-ci]',
|
|
232
277
|
summary: 'Scaffold a new app. Defaults: full-stack template, Drizzle + SQLite, Node runtime.',
|
|
233
278
|
options: [
|
|
234
279
|
// Kept to one terminal line like every other row: printHelp does not
|
|
@@ -241,6 +286,7 @@ const HELP = {
|
|
|
241
286
|
{ flag: '--db <d>', description: 'sqlite (default) or postgres.' },
|
|
242
287
|
{ flag: '--runtime <r>', description: 'node (default) or bun.' },
|
|
243
288
|
{ flag: '--no-install', description: 'Skip the package-manager install step.' },
|
|
289
|
+
{ flag: '--skip-ci', description: 'Omit the GitHub workflow (.github/workflows/ci.yml); the local `npm run ci` list is always emitted.' },
|
|
244
290
|
],
|
|
245
291
|
examples: [
|
|
246
292
|
'webjs create my-app',
|
|
@@ -250,12 +296,19 @@ const HELP = {
|
|
|
250
296
|
],
|
|
251
297
|
},
|
|
252
298
|
db: {
|
|
253
|
-
usage: 'webjs db <generate|migrate|push|studio|seed>',
|
|
254
|
-
summary: 'Database tasks (wraps drizzle-kit); seed runs db/seed.server.ts.',
|
|
299
|
+
usage: 'webjs db <generate|migrate|push|studio|seed|verb> [args...]',
|
|
300
|
+
summary: 'Database tasks (wraps drizzle-kit by default); seed runs db/seed.server.ts.',
|
|
301
|
+
notes: [
|
|
302
|
+
'A "webjs": { "db": { "<verb>": "<command>" } } block in package.json maps a verb to',
|
|
303
|
+
'a shell command (run with node_modules/.bin on PATH, extra args appended), so',
|
|
304
|
+
'another ORM keeps the same spelling: { "migrate": "prisma migrate deploy" }.',
|
|
305
|
+
'Any key is a verb ("reset" adds `webjs db reset`); an unmapped one keeps its',
|
|
306
|
+
'drizzle-kit / seed default, so an app with no block is unchanged.',
|
|
307
|
+
],
|
|
255
308
|
examples: ['webjs db generate', 'webjs db migrate', 'webjs db studio', 'webjs db seed'],
|
|
256
309
|
},
|
|
257
310
|
ui: {
|
|
258
|
-
usage: 'webjs ui <init|add|list|view|diff|info> [names...]',
|
|
311
|
+
usage: 'webjs ui <init|add|list|view|diff|info|lint> [names...]',
|
|
259
312
|
summary: 'AI-first component library CLI. Requires @webjsdev/ui installed in the project.',
|
|
260
313
|
examples: ['webjs ui init', 'webjs ui add button card', 'webjs ui list'],
|
|
261
314
|
},
|
|
@@ -516,6 +569,31 @@ async function main() {
|
|
|
516
569
|
case 'db': {
|
|
517
570
|
const sub = rest[0];
|
|
518
571
|
const args = rest.slice(1);
|
|
572
|
+
// A verb the app's `webjs.db` block maps (#1468) runs that command
|
|
573
|
+
// through the shell, the way a `before` step does (node_modules/.bin on
|
|
574
|
+
// PATH, so a bare `prisma migrate deploy` resolves), with the extra args
|
|
575
|
+
// appended. This is what makes Drizzle a default rather than lock-in:
|
|
576
|
+
// `webjs db migrate` stays the one spelling the scaffolded start.before,
|
|
577
|
+
// the Dockerfile, and the deploy docs use, whatever ORM is behind it.
|
|
578
|
+
// Checked FIRST so a mapped `seed` overrides the seed-file runner too.
|
|
579
|
+
if (!sub) {
|
|
580
|
+
console.error('webjs db: missing subcommand.\n' + USAGE);
|
|
581
|
+
process.exit(1);
|
|
582
|
+
}
|
|
583
|
+
const { readDbCommands } = await import('../lib/app-tasks.js');
|
|
584
|
+
const dbCommands = readDbCommands(process.cwd());
|
|
585
|
+
// Own-property lookup: the map is a plain object, so a bare index would
|
|
586
|
+
// answer `webjs db constructor` with an inherited function.
|
|
587
|
+
const mapped = Object.hasOwn(dbCommands, sub) ? dbCommands[sub] : undefined;
|
|
588
|
+
if (mapped) {
|
|
589
|
+
const { runBeforeSteps, shellQuote } = await import('../lib/run-tasks.js');
|
|
590
|
+
// Each arg is quoted so it reaches the ORM as one word, unexpanded,
|
|
591
|
+
// the way the drizzle-kit default's real argv already does.
|
|
592
|
+
const full = [mapped, ...args.map(shellQuote)].join(' ');
|
|
593
|
+
console.log(`webjs db ${sub}: running \`${full}\` (from package.json webjs.db)`);
|
|
594
|
+
const r = await runBeforeSteps([full], process.cwd());
|
|
595
|
+
process.exit(r.ok ? 0 : r.code);
|
|
596
|
+
}
|
|
519
597
|
// `webjs db seed` runs the app's own seed script directly (not a
|
|
520
598
|
// drizzle-kit command); Drizzle has no codegen, so there is no
|
|
521
599
|
// `generate`-the-client step, only schema-to-SQL `generate`.
|
|
@@ -535,8 +613,14 @@ async function main() {
|
|
|
535
613
|
// schema sync), studio. All wrap drizzle-kit; the verbose name stays
|
|
536
614
|
// hidden behind `webjs db`.
|
|
537
615
|
const map = { generate: ['generate'], migrate: ['migrate'], push: ['push'], studio: ['studio'] };
|
|
538
|
-
const kitArgs = map[sub];
|
|
539
|
-
if (!kitArgs) {
|
|
616
|
+
const kitArgs = Object.hasOwn(map, sub) ? map[sub] : undefined;
|
|
617
|
+
if (!kitArgs) {
|
|
618
|
+
console.error(
|
|
619
|
+
`Unknown db subcommand "${sub}". Map it in package.json to add it: ` +
|
|
620
|
+
`"webjs": { "db": { "${sub}": "<command>" } }\n` + USAGE,
|
|
621
|
+
);
|
|
622
|
+
process.exit(1);
|
|
623
|
+
}
|
|
540
624
|
// Resolve the app's own drizzle-kit bin and spawn it with the CURRENT
|
|
541
625
|
// runtime (process.execPath). This drops the hard `npx` dependency (#570):
|
|
542
626
|
// `npx` is absent in a pure oven/bun image, which broke `webjs db migrate`
|
|
@@ -548,7 +632,9 @@ async function main() {
|
|
|
548
632
|
} catch {
|
|
549
633
|
console.error(
|
|
550
634
|
'webjs db: drizzle-kit is not installed in this project.\n' +
|
|
551
|
-
'Install it with `npm install -D drizzle-kit`, then re-run `webjs db ' + sub + '
|
|
635
|
+
'Install it with `npm install -D drizzle-kit`, then re-run `webjs db ' + sub + '`.\n' +
|
|
636
|
+
'Using another ORM? Map the verb in package.json and the same command runs it:\n' +
|
|
637
|
+
' "webjs": { "db": { "' + sub + '": "<your ORM\'s ' + sub + ' command>" } }',
|
|
552
638
|
);
|
|
553
639
|
process.exit(1);
|
|
554
640
|
}
|
|
@@ -681,7 +767,32 @@ async function main() {
|
|
|
681
767
|
const useBrowserDir = !hasConfig && !serverOnly && existsSync(join(cwd, 'test', 'browser'));
|
|
682
768
|
// Only resolve + run when there is actually something to run, so a
|
|
683
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;
|
|
684
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) {
|
|
685
796
|
// Resolve the app's @web/test-runner bin and spawn it with the current
|
|
686
797
|
// runtime, dropping `npx` (#570; absent in a pure oven/bun image).
|
|
687
798
|
let wtrPath;
|
|
@@ -707,6 +818,146 @@ async function main() {
|
|
|
707
818
|
console.log('\nwebjs test: done ✓');
|
|
708
819
|
break;
|
|
709
820
|
}
|
|
821
|
+
case 'ci': {
|
|
822
|
+
// Local CI (#1471): run the step list `webjs.ci` declares, the Rails
|
|
823
|
+
// `bin/ci` posture. The predicate is the CONFIG, not an `app/` dir (unlike
|
|
824
|
+
// `webjs check`), because a workspace root is a legitimate target: this
|
|
825
|
+
// monorepo declares its own list. Nothing declared is exit 1, not 0, since
|
|
826
|
+
// "ran zero steps" would read as green.
|
|
827
|
+
const cwd = process.cwd();
|
|
828
|
+
const json = rest.includes('--json');
|
|
829
|
+
const failFast = rest.includes('--fail-fast') || rest.includes('-f');
|
|
830
|
+
const signoff = rest.includes('--signoff');
|
|
831
|
+
const only = [];
|
|
832
|
+
let onlyMissing = false;
|
|
833
|
+
for (let i = 0; i < rest.length; i++) {
|
|
834
|
+
if (rest[i] !== '--only') continue;
|
|
835
|
+
const value = rest[i + 1];
|
|
836
|
+
// A bare `--only` (or one followed by another flag) must NOT fall
|
|
837
|
+
// through to the whole list: that inverts the flag. Refuse it below.
|
|
838
|
+
if (value === undefined || value.startsWith('--')) onlyMissing = true;
|
|
839
|
+
else only.push(rest[++i]);
|
|
840
|
+
}
|
|
841
|
+
const { readCiConfig, selectSteps, noCiConfigMessage } = await import('../lib/ci-config.js');
|
|
842
|
+
const { runCi, formatSummary, stepSummaryMarkdown, colorize } = await import('../lib/ci-runner.js');
|
|
843
|
+
// Under --json stdout carries exactly one document, so the human output
|
|
844
|
+
// moves to stderr; every step is captured so its output can ride the
|
|
845
|
+
// document (failed steps only) instead of the terminal.
|
|
846
|
+
const out = json ? (s) => { process.stderr.write(s); } : (s) => { process.stdout.write(s); };
|
|
847
|
+
const isTTY = !json && !!process.stdout.isTTY;
|
|
848
|
+
// Colour on a TTY unless NO_COLOR is set; the runner passes the same
|
|
849
|
+
// decision down as FORCE_COLOR for captured children.
|
|
850
|
+
const color = isTTY && !process.env.NO_COLOR;
|
|
851
|
+
const refuse = (message, code, extra) => {
|
|
852
|
+
if (json) console.log(JSON.stringify({ error: { code, message, cwd, ...extra } }));
|
|
853
|
+
else console.error(message);
|
|
854
|
+
process.exitCode = 1;
|
|
855
|
+
};
|
|
856
|
+
if (onlyMissing) {
|
|
857
|
+
refuse('webjs ci: --only needs a step or group title (webjs ci --only "Tests")', 'INVALID_ONLY', {});
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const cfg = readCiConfig(cwd);
|
|
862
|
+
if (!cfg.declared) {
|
|
863
|
+
const { workspaceApps } = await import('../lib/check-target.js');
|
|
864
|
+
const members = (await workspaceApps(cwd)).filter((app) => readCiConfig(join(cwd, app)).declared);
|
|
865
|
+
refuse(noCiConfigMessage(cwd, members), 'NO_CI_CONFIG', { apps: members });
|
|
866
|
+
break;
|
|
867
|
+
}
|
|
868
|
+
if (cfg.problems.length > 0) {
|
|
869
|
+
refuse(
|
|
870
|
+
`webjs ci: package.json has ${cfg.problems.length} problem(s) in the "webjs": { "ci" } block, so nothing ran:\n` +
|
|
871
|
+
cfg.problems.map((p) => ` - ${p}`).join('\n'),
|
|
872
|
+
'INVALID_CI_CONFIG',
|
|
873
|
+
{ problems: cfg.problems },
|
|
874
|
+
);
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
const selected = selectSteps(cfg.steps, only);
|
|
878
|
+
if (selected.problems.length > 0) {
|
|
879
|
+
refuse(`webjs ci: ${selected.problems.join('; ')}`, 'UNKNOWN_ONLY', { problems: selected.problems });
|
|
880
|
+
break;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// `.env` first, like dev / start (#447), so a local `webjs db migrate`
|
|
884
|
+
// step reads DATABASE_URL from it. A real env var still wins (loadEnvFile
|
|
885
|
+
// never overrides), so a CI runner's explicit env is untouched.
|
|
886
|
+
loadAppEnv(cwd);
|
|
887
|
+
const title = 'Continuous Integration';
|
|
888
|
+
out(`${colorize(title, 'banner', color)}\n${colorize('Running the steps declared in package.json under webjs.ci', 'subtitle', color)}\n`);
|
|
889
|
+
const run = runCi(selected.steps, cwd, {
|
|
890
|
+
write: out,
|
|
891
|
+
isTTY,
|
|
892
|
+
color,
|
|
893
|
+
failFast,
|
|
894
|
+
captureAll: json,
|
|
895
|
+
actions: !!process.env.GITHUB_ACTIONS,
|
|
896
|
+
});
|
|
897
|
+
// The first signal winds the run down (children killed, results kept).
|
|
898
|
+
// A SECOND one exits outright, the way Rails' bin/ci lets a repeated
|
|
899
|
+
// interrupt through: a captured child that ignores SIGTERM (a hung
|
|
900
|
+
// browser under web-test-runner) would otherwise leave no way out.
|
|
901
|
+
let interrupting = false;
|
|
902
|
+
const onSignal = () => {
|
|
903
|
+
if (interrupting) process.exit(130);
|
|
904
|
+
interrupting = true;
|
|
905
|
+
run.interrupt();
|
|
906
|
+
};
|
|
907
|
+
process.on('SIGINT', onSignal);
|
|
908
|
+
process.on('SIGTERM', onSignal);
|
|
909
|
+
const result = await run.done;
|
|
910
|
+
process.off('SIGINT', onSignal);
|
|
911
|
+
process.off('SIGTERM', onSignal);
|
|
912
|
+
out(formatSummary(result, title, color));
|
|
913
|
+
|
|
914
|
+
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
915
|
+
const { appendFileSync } = await import('node:fs');
|
|
916
|
+
try { appendFileSync(process.env.GITHUB_STEP_SUMMARY, stepSummaryMarkdown(result)); } catch {}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// The Rails signoff step, opt-in. A green run posts a green commit status
|
|
920
|
+
// (`gh signoff`, which branch protection can require); a red run says so
|
|
921
|
+
// and posts nothing. It goes through the same runner so it reads as one
|
|
922
|
+
// more step, and a missing `gh` is a failed step, never a silent skip.
|
|
923
|
+
let signoffOk = true;
|
|
924
|
+
if (signoff) {
|
|
925
|
+
if (result.ok) {
|
|
926
|
+
const so = runCi(
|
|
927
|
+
[{ kind: 'step', title: 'Signoff: All systems go. Ready for merge and deploy.', run: 'gh signoff', env: {} }],
|
|
928
|
+
cwd,
|
|
929
|
+
{ write: out, isTTY, color, captureAll: json },
|
|
930
|
+
);
|
|
931
|
+
signoffOk = (await so.done).ok;
|
|
932
|
+
} else {
|
|
933
|
+
out(`\n\n${colorize('Signoff: CI failed. Do not merge or deploy.', 'error', color)}\n${colorize('Fix the issues and try again.', 'subtitle', color)}\n`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (json) {
|
|
938
|
+
console.log(JSON.stringify({
|
|
939
|
+
ok: result.ok && signoffOk,
|
|
940
|
+
seconds: result.seconds,
|
|
941
|
+
interrupted: result.interrupted,
|
|
942
|
+
steps: result.steps.map((s) => ({
|
|
943
|
+
title: s.title,
|
|
944
|
+
run: s.run,
|
|
945
|
+
group: s.group,
|
|
946
|
+
ok: s.ok,
|
|
947
|
+
code: s.code,
|
|
948
|
+
signal: s.signal,
|
|
949
|
+
interrupted: s.interrupted,
|
|
950
|
+
seconds: s.seconds,
|
|
951
|
+
...(s.ok ? {} : { output: s.output ?? '' }),
|
|
952
|
+
})),
|
|
953
|
+
}));
|
|
954
|
+
}
|
|
955
|
+
// exitCode rather than a process.exit() call, because a run writes far
|
|
956
|
+
// more than `check` does and exit() truncates pending pipe writes when
|
|
957
|
+
// stdout is not a TTY.
|
|
958
|
+
process.exitCode = result.interrupted ? 130 : result.ok && signoffOk ? 0 : 1;
|
|
959
|
+
break;
|
|
960
|
+
}
|
|
710
961
|
case 'check': {
|
|
711
962
|
const { checkConventions, RULES } = await import('@webjsdev/server/check');
|
|
712
963
|
|
|
@@ -769,6 +1020,60 @@ async function main() {
|
|
|
769
1020
|
}
|
|
770
1021
|
break;
|
|
771
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
|
+
}
|
|
772
1077
|
case 'doctor': {
|
|
773
1078
|
// Project-health checklist (#266). The checks are PURE (in lib/doctor.js);
|
|
774
1079
|
// this branch only renders them and owns the exit code. The exit is
|
|
@@ -1301,6 +1606,8 @@ Full docs: https://webjs.dev/docs`);
|
|
|
1301
1606
|
process.exit(1);
|
|
1302
1607
|
}
|
|
1303
1608
|
const noInstall = rest.includes('--no-install');
|
|
1609
|
+
// --skip-ci omits the GitHub workflow (#1471); the local ci list stays.
|
|
1610
|
+
const skipCi = rest.includes('--skip-ci');
|
|
1304
1611
|
// --db picks the database dialect: sqlite (default) or postgres.
|
|
1305
1612
|
const db = flag(rest, '--db', 'sqlite');
|
|
1306
1613
|
// --runtime picks the target runtime: node (default) or bun. Orthogonal
|
|
@@ -1312,7 +1619,7 @@ Full docs: https://webjs.dev/docs`);
|
|
|
1312
1619
|
process.exit(1);
|
|
1313
1620
|
}
|
|
1314
1621
|
const { scaffoldApp } = await import('../lib/create.js');
|
|
1315
|
-
await scaffoldApp(name, process.cwd(), { template, db, runtime, install: !noInstall });
|
|
1622
|
+
await scaffoldApp(name, process.cwd(), { template, db, runtime, install: !noInstall, skipCi });
|
|
1316
1623
|
break;
|
|
1317
1624
|
}
|
|
1318
1625
|
case 'vendor': {
|
package/lib/app-tasks.js
CHANGED
|
@@ -38,16 +38,9 @@ import { join } from 'node:path';
|
|
|
38
38
|
* @returns {{ dev: { before: string[], parallel: string[] }, start: { before: string[] } }}
|
|
39
39
|
*/
|
|
40
40
|
export function readAppTasks(appDir, readFile) {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
pkg = JSON.parse(read(join(appDir, 'package.json')));
|
|
45
|
-
} catch {
|
|
46
|
-
// No package.json, or unparseable: a plain run with no orchestration.
|
|
47
|
-
return emptyTasks();
|
|
48
|
-
}
|
|
49
|
-
const webjs = pkg && typeof pkg === 'object' ? pkg.webjs : null;
|
|
50
|
-
if (!webjs || typeof webjs !== 'object') return emptyTasks();
|
|
41
|
+
const webjs = readWebjsBlock(appDir, readFile);
|
|
42
|
+
// No package.json, unparseable, or no block: a plain run with no orchestration.
|
|
43
|
+
if (!webjs) return emptyTasks();
|
|
51
44
|
|
|
52
45
|
/** Keep only non-empty string entries; drop anything else defensively. */
|
|
53
46
|
const cmds = (v) =>
|
|
@@ -62,7 +55,74 @@ export function readAppTasks(appDir, readFile) {
|
|
|
62
55
|
};
|
|
63
56
|
}
|
|
64
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The app's `package.json` `"webjs"` block, or `null` when there is no
|
|
60
|
+
* package.json, it does not parse, or the block is absent / not an object.
|
|
61
|
+
* Shared by every CLI-side reader here so the file is read one way.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} appDir
|
|
64
|
+
* @param {(p: string) => string} [readFile] injectable reader for tests
|
|
65
|
+
* @returns {Record<string, unknown> | null}
|
|
66
|
+
*/
|
|
67
|
+
function readWebjsBlock(appDir, readFile) {
|
|
68
|
+
const read = readFile || ((p) => readFileSync(p, 'utf8'));
|
|
69
|
+
let pkg;
|
|
70
|
+
try {
|
|
71
|
+
pkg = JSON.parse(read(join(appDir, 'package.json')));
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const webjs = pkg && typeof pkg === 'object' ? pkg.webjs : null;
|
|
76
|
+
return webjs && typeof webjs === 'object' ? webjs : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
65
79
|
/** @returns {{ dev: { before: string[], parallel: string[] }, start: { before: string[] } }} */
|
|
66
80
|
function emptyTasks() {
|
|
67
81
|
return { dev: { before: [], parallel: [] }, start: { before: [] } };
|
|
68
82
|
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read the `webjs db` verb map from an app's `package.json` `"webjs"` block
|
|
86
|
+
* (#1468). Drizzle is the scaffold DEFAULT, never lock-in: the runtime never
|
|
87
|
+
* imports it and `db/connection.server.ts` is the app's own file. But without
|
|
88
|
+
* this map the `webjs db` verbs contradicted that, since `generate` / `migrate`
|
|
89
|
+
* / `push` / `studio` resolved the app's drizzle-kit binary and exited 1 with
|
|
90
|
+
* any other ORM installed, while `webjs db migrate` is the spelling baked into
|
|
91
|
+
* the scaffolded `dev.before` / `start.before` tasks, the Dockerfile, and the
|
|
92
|
+
* deployment docs. Mapping a verb here keeps that spelling stable across ORMs,
|
|
93
|
+
* so an ORM swap is one config block plus the app's own `db/` files.
|
|
94
|
+
*
|
|
95
|
+
* Shape:
|
|
96
|
+
* "webjs": {
|
|
97
|
+
* "db": {
|
|
98
|
+
* "migrate": "prisma migrate deploy",
|
|
99
|
+
* "studio": "prisma studio",
|
|
100
|
+
* "reset": "prisma migrate reset --force"
|
|
101
|
+
* }
|
|
102
|
+
* }
|
|
103
|
+
*
|
|
104
|
+
* Any key is a verb: a mapped verb runs its command through the shell (the
|
|
105
|
+
* same way a `before` step does, so a local-only binary resolves), with the
|
|
106
|
+
* extra CLI args appended. A verb the map does not name keeps its default (the
|
|
107
|
+
* drizzle-kit passthrough for the four kit verbs, `db/seed.server.ts` for
|
|
108
|
+
* `seed`), so an app with no block is unchanged. Non-string / blank values are
|
|
109
|
+
* dropped defensively, the same posture as `readAppTasks`.
|
|
110
|
+
*
|
|
111
|
+
* Pure (reads one file, never spawns / prints / exits) so it is unit-testable
|
|
112
|
+
* without a process.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} appDir
|
|
115
|
+
* @param {(p: string) => string} [readFile] injectable reader for tests
|
|
116
|
+
* @returns {Record<string, string>} verb -> shell command (empty when unset)
|
|
117
|
+
*/
|
|
118
|
+
export function readDbCommands(appDir, readFile) {
|
|
119
|
+
const webjs = readWebjsBlock(appDir, readFile);
|
|
120
|
+
const db = webjs ? webjs.db : null;
|
|
121
|
+
if (!db || typeof db !== 'object' || Array.isArray(db)) return {};
|
|
122
|
+
/** @type {Record<string, string>} */
|
|
123
|
+
const out = {};
|
|
124
|
+
for (const [verb, cmd] of Object.entries(db)) {
|
|
125
|
+
if (typeof cmd === 'string' && cmd.trim().length > 0 && verb.trim().length > 0) out[verb] = cmd;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|