@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 CHANGED
@@ -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)
@@ -100,7 +105,7 @@ const USAGE = `webjs commands:
100
105
  in package.json, so CI gates on a chosen subset without every warning becoming fatal
101
106
  webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
102
107
  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
108
+ webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install] [--skip-ci] Scaffold a new webjs app
104
109
  <name> must be a valid package name (letters, digits, - . _, starts with a letter or digit)
105
110
  (only 2 templates exist. default: full-stack, Drizzle, --db sqlite, --runtime node)
106
111
  --runtime bun emits a Bun-flavored app (bun.lock, bun Dockerfile/CI, bun docs);
@@ -112,6 +117,9 @@ const USAGE = `webjs commands:
112
117
  webjs db push Push the schema straight to the dev DB (drizzle-kit push)
113
118
  webjs db studio Open the database browser (drizzle-kit studio)
114
119
  webjs db seed Run the app's db/seed.server.ts
120
+ webjs db <verb> Any verb "webjs": { "db": { "<verb>": "<command>" } } maps in
121
+ package.json runs that command instead (bring your own ORM);
122
+ an unmapped verb keeps the drizzle-kit default above
115
123
  webjs ui <subcmd> AI-first component library CLI
116
124
  (init / add / list / view / diff / info)
117
125
  Requires @webjsdev/ui installed in the project
@@ -171,6 +179,25 @@ const HELP = {
171
179
  ],
172
180
  examples: ['webjs check', 'webjs check --json', 'webjs check --rules'],
173
181
  },
182
+ ci: {
183
+ usage: 'webjs ci [-f|--fail-fast] [--only <title>]... [--json] [--signoff]',
184
+ summary:
185
+ 'Run the local CI steps declared in package.json under "webjs": { "ci": { "steps": [...] } }: ' +
186
+ 'each step is timed and reported, a parallel group replays each step\'s output whole, and the exit is 1 on any failure. ' +
187
+ 'A cloud pipeline runs the same list through `npm run ci`, so the two cannot drift.',
188
+ options: [
189
+ { flag: '-f, --fail-fast', description: 'Stop after the first failing step instead of running every step and listing every failure.' },
190
+ { flag: '--only <title>', description: 'Run only the step or group with this title (repeatable, case-insensitive; a matched group runs whole).' },
191
+ { flag: '--json', description: 'Emit one JSON document on stdout ({ ok, seconds, steps[] }, failed steps carry their output); the human output goes to stderr.' },
192
+ { 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.' },
193
+ ],
194
+ notesTitle: 'Environment:',
195
+ notes: [
196
+ 'Every step runs with CI=true and node_modules/.bin on PATH, plus the step\'s own `env`.',
197
+ 'Under GitHub Actions each step is a log group and a failure is annotated; a $GITHUB_STEP_SUMMARY table is appended.',
198
+ ],
199
+ examples: ['webjs ci', 'webjs ci --fail-fast', 'webjs ci --only Tests', 'webjs ci --json', 'webjs ci --signoff'],
200
+ },
174
201
  routes: {
175
202
  usage: 'webjs routes [--json | --table] [--no-headers]',
176
203
  summary: 'Print the route table: each page/route path, its owner file, and (for route handlers) its HTTP methods.',
@@ -228,7 +255,7 @@ const HELP = {
228
255
  examples: ['webjs typecheck', 'webjs typecheck --watch'],
229
256
  },
230
257
  create: {
231
- usage: 'webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install]',
258
+ usage: 'webjs create <name> [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install] [--skip-ci]',
232
259
  summary: 'Scaffold a new app. Defaults: full-stack template, Drizzle + SQLite, Node runtime.',
233
260
  options: [
234
261
  // Kept to one terminal line like every other row: printHelp does not
@@ -241,6 +268,7 @@ const HELP = {
241
268
  { flag: '--db <d>', description: 'sqlite (default) or postgres.' },
242
269
  { flag: '--runtime <r>', description: 'node (default) or bun.' },
243
270
  { flag: '--no-install', description: 'Skip the package-manager install step.' },
271
+ { flag: '--skip-ci', description: 'Omit the GitHub workflow (.github/workflows/ci.yml); the local `npm run ci` list is always emitted.' },
244
272
  ],
245
273
  examples: [
246
274
  'webjs create my-app',
@@ -250,12 +278,19 @@ const HELP = {
250
278
  ],
251
279
  },
252
280
  db: {
253
- usage: 'webjs db <generate|migrate|push|studio|seed>',
254
- summary: 'Database tasks (wraps drizzle-kit); seed runs db/seed.server.ts.',
281
+ usage: 'webjs db <generate|migrate|push|studio|seed|verb> [args...]',
282
+ summary: 'Database tasks (wraps drizzle-kit by default); seed runs db/seed.server.ts.',
283
+ notes: [
284
+ 'A "webjs": { "db": { "<verb>": "<command>" } } block in package.json maps a verb to',
285
+ 'a shell command (run with node_modules/.bin on PATH, extra args appended), so',
286
+ 'another ORM keeps the same spelling: { "migrate": "prisma migrate deploy" }.',
287
+ 'Any key is a verb ("reset" adds `webjs db reset`); an unmapped one keeps its',
288
+ 'drizzle-kit / seed default, so an app with no block is unchanged.',
289
+ ],
255
290
  examples: ['webjs db generate', 'webjs db migrate', 'webjs db studio', 'webjs db seed'],
256
291
  },
257
292
  ui: {
258
- usage: 'webjs ui <init|add|list|view|diff|info> [names...]',
293
+ usage: 'webjs ui <init|add|list|view|diff|info|lint> [names...]',
259
294
  summary: 'AI-first component library CLI. Requires @webjsdev/ui installed in the project.',
260
295
  examples: ['webjs ui init', 'webjs ui add button card', 'webjs ui list'],
261
296
  },
@@ -516,6 +551,31 @@ async function main() {
516
551
  case 'db': {
517
552
  const sub = rest[0];
518
553
  const args = rest.slice(1);
554
+ // A verb the app's `webjs.db` block maps (#1468) runs that command
555
+ // through the shell, the way a `before` step does (node_modules/.bin on
556
+ // PATH, so a bare `prisma migrate deploy` resolves), with the extra args
557
+ // appended. This is what makes Drizzle a default rather than lock-in:
558
+ // `webjs db migrate` stays the one spelling the scaffolded start.before,
559
+ // the Dockerfile, and the deploy docs use, whatever ORM is behind it.
560
+ // Checked FIRST so a mapped `seed` overrides the seed-file runner too.
561
+ if (!sub) {
562
+ console.error('webjs db: missing subcommand.\n' + USAGE);
563
+ process.exit(1);
564
+ }
565
+ const { readDbCommands } = await import('../lib/app-tasks.js');
566
+ const dbCommands = readDbCommands(process.cwd());
567
+ // Own-property lookup: the map is a plain object, so a bare index would
568
+ // answer `webjs db constructor` with an inherited function.
569
+ const mapped = Object.hasOwn(dbCommands, sub) ? dbCommands[sub] : undefined;
570
+ if (mapped) {
571
+ const { runBeforeSteps, shellQuote } = await import('../lib/run-tasks.js');
572
+ // Each arg is quoted so it reaches the ORM as one word, unexpanded,
573
+ // the way the drizzle-kit default's real argv already does.
574
+ const full = [mapped, ...args.map(shellQuote)].join(' ');
575
+ console.log(`webjs db ${sub}: running \`${full}\` (from package.json webjs.db)`);
576
+ const r = await runBeforeSteps([full], process.cwd());
577
+ process.exit(r.ok ? 0 : r.code);
578
+ }
519
579
  // `webjs db seed` runs the app's own seed script directly (not a
520
580
  // drizzle-kit command); Drizzle has no codegen, so there is no
521
581
  // `generate`-the-client step, only schema-to-SQL `generate`.
@@ -535,8 +595,14 @@ async function main() {
535
595
  // schema sync), studio. All wrap drizzle-kit; the verbose name stays
536
596
  // hidden behind `webjs db`.
537
597
  const map = { generate: ['generate'], migrate: ['migrate'], push: ['push'], studio: ['studio'] };
538
- const kitArgs = map[sub];
539
- if (!kitArgs) { console.error('Unknown db subcommand.\n' + USAGE); process.exit(1); }
598
+ const kitArgs = Object.hasOwn(map, sub) ? map[sub] : undefined;
599
+ if (!kitArgs) {
600
+ console.error(
601
+ `Unknown db subcommand "${sub}". Map it in package.json to add it: ` +
602
+ `"webjs": { "db": { "${sub}": "<command>" } }\n` + USAGE,
603
+ );
604
+ process.exit(1);
605
+ }
540
606
  // Resolve the app's own drizzle-kit bin and spawn it with the CURRENT
541
607
  // runtime (process.execPath). This drops the hard `npx` dependency (#570):
542
608
  // `npx` is absent in a pure oven/bun image, which broke `webjs db migrate`
@@ -548,7 +614,9 @@ async function main() {
548
614
  } catch {
549
615
  console.error(
550
616
  '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 + '`.',
617
+ 'Install it with `npm install -D drizzle-kit`, then re-run `webjs db ' + sub + '`.\n' +
618
+ 'Using another ORM? Map the verb in package.json and the same command runs it:\n' +
619
+ ' "webjs": { "db": { "' + sub + '": "<your ORM\'s ' + sub + ' command>" } }',
552
620
  );
553
621
  process.exit(1);
554
622
  }
@@ -707,6 +775,146 @@ async function main() {
707
775
  console.log('\nwebjs test: done ✓');
708
776
  break;
709
777
  }
778
+ case 'ci': {
779
+ // Local CI (#1471): run the step list `webjs.ci` declares, the Rails
780
+ // `bin/ci` posture. The predicate is the CONFIG, not an `app/` dir (unlike
781
+ // `webjs check`), because a workspace root is a legitimate target: this
782
+ // monorepo declares its own list. Nothing declared is exit 1, not 0, since
783
+ // "ran zero steps" would read as green.
784
+ const cwd = process.cwd();
785
+ const json = rest.includes('--json');
786
+ const failFast = rest.includes('--fail-fast') || rest.includes('-f');
787
+ const signoff = rest.includes('--signoff');
788
+ const only = [];
789
+ let onlyMissing = false;
790
+ for (let i = 0; i < rest.length; i++) {
791
+ if (rest[i] !== '--only') continue;
792
+ const value = rest[i + 1];
793
+ // A bare `--only` (or one followed by another flag) must NOT fall
794
+ // through to the whole list: that inverts the flag. Refuse it below.
795
+ if (value === undefined || value.startsWith('--')) onlyMissing = true;
796
+ else only.push(rest[++i]);
797
+ }
798
+ const { readCiConfig, selectSteps, noCiConfigMessage } = await import('../lib/ci-config.js');
799
+ const { runCi, formatSummary, stepSummaryMarkdown, colorize } = await import('../lib/ci-runner.js');
800
+ // Under --json stdout carries exactly one document, so the human output
801
+ // moves to stderr; every step is captured so its output can ride the
802
+ // document (failed steps only) instead of the terminal.
803
+ const out = json ? (s) => { process.stderr.write(s); } : (s) => { process.stdout.write(s); };
804
+ const isTTY = !json && !!process.stdout.isTTY;
805
+ // Colour on a TTY unless NO_COLOR is set; the runner passes the same
806
+ // decision down as FORCE_COLOR for captured children.
807
+ const color = isTTY && !process.env.NO_COLOR;
808
+ const refuse = (message, code, extra) => {
809
+ if (json) console.log(JSON.stringify({ error: { code, message, cwd, ...extra } }));
810
+ else console.error(message);
811
+ process.exitCode = 1;
812
+ };
813
+ if (onlyMissing) {
814
+ refuse('webjs ci: --only needs a step or group title (webjs ci --only "Tests")', 'INVALID_ONLY', {});
815
+ break;
816
+ }
817
+
818
+ const cfg = readCiConfig(cwd);
819
+ if (!cfg.declared) {
820
+ const { workspaceApps } = await import('../lib/check-target.js');
821
+ const members = (await workspaceApps(cwd)).filter((app) => readCiConfig(join(cwd, app)).declared);
822
+ refuse(noCiConfigMessage(cwd, members), 'NO_CI_CONFIG', { apps: members });
823
+ break;
824
+ }
825
+ if (cfg.problems.length > 0) {
826
+ refuse(
827
+ `webjs ci: package.json has ${cfg.problems.length} problem(s) in the "webjs": { "ci" } block, so nothing ran:\n` +
828
+ cfg.problems.map((p) => ` - ${p}`).join('\n'),
829
+ 'INVALID_CI_CONFIG',
830
+ { problems: cfg.problems },
831
+ );
832
+ break;
833
+ }
834
+ const selected = selectSteps(cfg.steps, only);
835
+ if (selected.problems.length > 0) {
836
+ refuse(`webjs ci: ${selected.problems.join('; ')}`, 'UNKNOWN_ONLY', { problems: selected.problems });
837
+ break;
838
+ }
839
+
840
+ // `.env` first, like dev / start (#447), so a local `webjs db migrate`
841
+ // step reads DATABASE_URL from it. A real env var still wins (loadEnvFile
842
+ // never overrides), so a CI runner's explicit env is untouched.
843
+ loadAppEnv(cwd);
844
+ const title = 'Continuous Integration';
845
+ out(`${colorize(title, 'banner', color)}\n${colorize('Running the steps declared in package.json under webjs.ci', 'subtitle', color)}\n`);
846
+ const run = runCi(selected.steps, cwd, {
847
+ write: out,
848
+ isTTY,
849
+ color,
850
+ failFast,
851
+ captureAll: json,
852
+ actions: !!process.env.GITHUB_ACTIONS,
853
+ });
854
+ // The first signal winds the run down (children killed, results kept).
855
+ // A SECOND one exits outright, the way Rails' bin/ci lets a repeated
856
+ // interrupt through: a captured child that ignores SIGTERM (a hung
857
+ // browser under web-test-runner) would otherwise leave no way out.
858
+ let interrupting = false;
859
+ const onSignal = () => {
860
+ if (interrupting) process.exit(130);
861
+ interrupting = true;
862
+ run.interrupt();
863
+ };
864
+ process.on('SIGINT', onSignal);
865
+ process.on('SIGTERM', onSignal);
866
+ const result = await run.done;
867
+ process.off('SIGINT', onSignal);
868
+ process.off('SIGTERM', onSignal);
869
+ out(formatSummary(result, title, color));
870
+
871
+ if (process.env.GITHUB_STEP_SUMMARY) {
872
+ const { appendFileSync } = await import('node:fs');
873
+ try { appendFileSync(process.env.GITHUB_STEP_SUMMARY, stepSummaryMarkdown(result)); } catch {}
874
+ }
875
+
876
+ // The Rails signoff step, opt-in. A green run posts a green commit status
877
+ // (`gh signoff`, which branch protection can require); a red run says so
878
+ // and posts nothing. It goes through the same runner so it reads as one
879
+ // more step, and a missing `gh` is a failed step, never a silent skip.
880
+ let signoffOk = true;
881
+ if (signoff) {
882
+ if (result.ok) {
883
+ const so = runCi(
884
+ [{ kind: 'step', title: 'Signoff: All systems go. Ready for merge and deploy.', run: 'gh signoff', env: {} }],
885
+ cwd,
886
+ { write: out, isTTY, color, captureAll: json },
887
+ );
888
+ signoffOk = (await so.done).ok;
889
+ } else {
890
+ 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`);
891
+ }
892
+ }
893
+
894
+ if (json) {
895
+ console.log(JSON.stringify({
896
+ ok: result.ok && signoffOk,
897
+ seconds: result.seconds,
898
+ interrupted: result.interrupted,
899
+ steps: result.steps.map((s) => ({
900
+ title: s.title,
901
+ run: s.run,
902
+ group: s.group,
903
+ ok: s.ok,
904
+ code: s.code,
905
+ signal: s.signal,
906
+ interrupted: s.interrupted,
907
+ seconds: s.seconds,
908
+ ...(s.ok ? {} : { output: s.output ?? '' }),
909
+ })),
910
+ }));
911
+ }
912
+ // exitCode rather than a process.exit() call, because a run writes far
913
+ // more than `check` does and exit() truncates pending pipe writes when
914
+ // stdout is not a TTY.
915
+ process.exitCode = result.interrupted ? 130 : result.ok && signoffOk ? 0 : 1;
916
+ break;
917
+ }
710
918
  case 'check': {
711
919
  const { checkConventions, RULES } = await import('@webjsdev/server/check');
712
920
 
@@ -1301,6 +1509,8 @@ Full docs: https://webjs.dev/docs`);
1301
1509
  process.exit(1);
1302
1510
  }
1303
1511
  const noInstall = rest.includes('--no-install');
1512
+ // --skip-ci omits the GitHub workflow (#1471); the local ci list stays.
1513
+ const skipCi = rest.includes('--skip-ci');
1304
1514
  // --db picks the database dialect: sqlite (default) or postgres.
1305
1515
  const db = flag(rest, '--db', 'sqlite');
1306
1516
  // --runtime picks the target runtime: node (default) or bun. Orthogonal
@@ -1312,7 +1522,7 @@ Full docs: https://webjs.dev/docs`);
1312
1522
  process.exit(1);
1313
1523
  }
1314
1524
  const { scaffoldApp } = await import('../lib/create.js');
1315
- await scaffoldApp(name, process.cwd(), { template, db, runtime, install: !noInstall });
1525
+ await scaffoldApp(name, process.cwd(), { template, db, runtime, install: !noInstall, skipCi });
1316
1526
  break;
1317
1527
  }
1318
1528
  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 read = readFile || ((p) => readFileSync(p, 'utf8'));
42
- let pkg = {};
43
- try {
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
+ }
@@ -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'));