@webjsdev/cli 0.10.17 → 0.10.19

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/create.js CHANGED
@@ -85,11 +85,10 @@ const UI_REGISTRY_ROOT = resolveUiRegistryRoot();
85
85
 
86
86
  /**
87
87
  * Read a single @webjsdev/ui registry component, rewrite its relative import
88
- * of `../lib/utils.ts` so it resolves correctly when written to
89
- * `components/ui/<name>.ts` in the scaffolded app. The scaffold puts cn()
90
- * at `lib/utils/cn.ts` (folder-grouped with other browser-safe helpers).
91
- * From `components/ui/<x>.ts`, the equivalent path is two-up plus into the
92
- * utils/ folder: `../../lib/utils/cn.ts`.
88
+ * of `../lib/utils.ts` to the scaffolded app's aliased path so it resolves
89
+ * when written to `components/ui/<name>.ts`. The scaffold puts cn() at
90
+ * `lib/utils/cn.ts` (folder-grouped with other browser-safe helpers), so the
91
+ * alias form is `#lib/utils/cn.ts` (#555/#556).
93
92
  *
94
93
  * @param {string} name component name without `.ts` (e.g. 'button')
95
94
  * @returns {Promise<string>} source with import rewritten
@@ -97,9 +96,11 @@ const UI_REGISTRY_ROOT = resolveUiRegistryRoot();
97
96
  async function readUiComponent(name) {
98
97
  const src = join(UI_REGISTRY_ROOT, 'components', `${name}.ts`);
99
98
  const raw = await readFile(src, 'utf8');
99
+ // The registry component imports cn() via a relative `../lib/utils.ts`; rewrite
100
+ // it to the scaffolded app's aliased path (cn lives at lib/utils/cn.ts).
100
101
  return raw
101
- .replaceAll("'../lib/utils.ts'", "'../../lib/utils/cn.ts'")
102
- .replaceAll('"../lib/utils.ts"', '"../../lib/utils/cn.ts"');
102
+ .replaceAll("'../lib/utils.ts'", "'#lib/utils/cn.ts'")
103
+ .replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"');
103
104
  }
104
105
 
105
106
  /**
@@ -249,6 +250,15 @@ export async function scaffoldApp(name, cwd, opts = {}) {
249
250
  }
250
251
  const isApi = template === 'api';
251
252
  const isSaas = template === 'saas';
253
+
254
+ // Database dialect (#563): sqlite (default) or postgres. Drizzle is the ORM;
255
+ // the schema/queries/actions are identical across dialects, only db/columns
256
+ // + db/connection + the driver dep differ.
257
+ const dialect = opts.db || 'sqlite';
258
+ const VALID_DIALECTS = ['sqlite', 'postgres'];
259
+ if (!VALID_DIALECTS.includes(dialect)) {
260
+ throw new Error(`Unknown --db '${dialect}'. Only ${VALID_DIALECTS.join(' / ')} are supported.`);
261
+ }
252
262
  const appDir = join(cwd, name);
253
263
  if (existsSync(appDir)) {
254
264
  console.error(`Error: directory '${name}' already exists.`);
@@ -264,7 +274,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
264
274
  'modules',
265
275
  'lib',
266
276
  'public',
267
- 'prisma',
277
+ 'db',
268
278
  'test/unit',
269
279
  'test/e2e',
270
280
  ];
@@ -277,9 +287,22 @@ export async function scaffoldApp(name, cwd, opts = {}) {
277
287
  version: '0.1.0',
278
288
  type: 'module',
279
289
  private: true,
290
+ // Native package.json subpath aliases (#555): write app-internal imports as
291
+ // `#lib/...`, `#components/...`, `#db/...`, `#<any-dir>/...` instead of deep
292
+ // `../../../` relatives. ONE catch-all key, so a new top-level folder is
293
+ // aliased with no config change. Node 24+ and Bun both resolve `#*` natively
294
+ // (a `#/`-prefixed key is rejected by Bun, so this slash-free form is the
295
+ // cross-runtime-safe shape; no build step, no tsconfig paths). The webjs
296
+ // server expands the same map for the import graph + browser importmap. Opt
297
+ // out by using a plain relative import.
298
+ imports: {
299
+ '#*': './*',
300
+ },
280
301
  scripts: {
281
- predev: 'prisma generate',
282
- prestart: 'prisma migrate deploy',
302
+ // No `predev` / `prestart` hooks (#550): the `webjs` block below holds
303
+ // the start orchestration (`webjs db migrate`), run INSIDE `webjs start`,
304
+ // so `npm run start` (a thin alias) behaves identically. Drizzle has no
305
+ // codegen, so there is no dev `before` step.
283
306
  dev: 'webjs dev',
284
307
  start: 'webjs start',
285
308
  test: 'webjs test',
@@ -292,18 +315,24 @@ export async function scaffoldApp(name, cwd, opts = {}) {
292
315
  // vendor pins, @webjsdev versions, git hook). Local tool, NOT a CI gate
293
316
  // (its env-drift + network pin-freshness checks would make CI flaky).
294
317
  doctor: 'webjs doctor',
295
- 'db:migrate': 'prisma migrate dev',
296
- 'db:generate': 'prisma generate',
297
- 'db:studio': 'prisma studio',
318
+ 'db:generate': 'webjs db generate',
319
+ 'db:migrate': 'webjs db migrate',
320
+ 'db:push': 'webjs db push',
321
+ 'db:studio': 'webjs db studio',
322
+ 'db:seed': 'webjs db seed',
298
323
  },
299
324
  dependencies: {
300
- '@prisma/client': '^6.0.0',
325
+ // Drizzle ORM (no codegen, no engine binary). Pinned to the 1.0 line
326
+ // for relations v2. The SQLite/Postgres driver below is dialect-picked.
327
+ 'drizzle-orm': '^1.0.0-rc.3',
328
+ ...(dialect === 'postgres' ? { pg: '^8.13.0' } : { 'better-sqlite3': '^12.11.1' }),
301
329
  '@webjsdev/cli': 'latest',
302
330
  '@webjsdev/core': 'latest',
303
331
  '@webjsdev/server': 'latest',
304
332
  },
305
333
  devDependencies: {
306
- prisma: '^6.0.0',
334
+ 'drizzle-kit': '^1.0.0-rc.3',
335
+ ...(dialect === 'postgres' ? { '@types/pg': '^8.11.0' } : {}),
307
336
  // The TypeScript compiler, for `npm run typecheck` (webjs typecheck runs
308
337
  // tsc --noEmit). Not needed at runtime (Node strips types in place), only
309
338
  // to type-check the app.
@@ -330,6 +359,18 @@ export async function scaffoldApp(name, cwd, opts = {}) {
330
359
  // into components/ui/ (they import @webjsdev/core, not the kit), and the
331
360
  // CLI resolves @webjsdev/ui from its own install.
332
361
  },
362
+ // Start task orchestration (#550). `webjs start` reads `start.before` and
363
+ // runs it in-process, so `npm run start` (a thin alias above) behaves
364
+ // identically. Drizzle has no codegen, so there is no dev `before` step;
365
+ // production applies pending migrations via `webjs db migrate`. The scaffold
366
+ // uses the Tailwind browser runtime (no CSS build step), so there is no dev
367
+ // `parallel` watcher here; an app that adds the Tailwind CLI puts its
368
+ // `--watch` command under `webjs.dev.parallel`.
369
+ webjs: {
370
+ // Drizzle has no codegen, so there is no dev `before` step. Production
371
+ // applies pending migrations at boot via `webjs db migrate` (drizzle-kit).
372
+ start: { before: ['webjs db migrate'] },
373
+ },
333
374
  }, null, 2) + '\n');
334
375
 
335
376
  await writeFile(join(appDir, 'tsconfig.json'), JSON.stringify({
@@ -381,7 +422,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
381
422
  'middleware.ts',
382
423
  '.webjs/routes.d.ts',
383
424
  ],
384
- exclude: ['node_modules', '.webjs/vendor', 'prisma/migrations'],
425
+ exclude: ['node_modules', '.webjs/vendor', 'db/migrations'],
385
426
  }, null, 2) + '\n');
386
427
 
387
428
  // --- Templates (AGENTS.md, CONVENTIONS.md, CLAUDE.md, test files, Claude hooks) ---
@@ -398,8 +439,8 @@ export async function scaffoldApp(name, cwd, opts = {}) {
398
439
  // Environment variables
399
440
  '.env.example',
400
441
  // Project-level gitignore (node_modules, .webjs, .env, OS junk).
401
- // The Prisma dev.db rule is appended programmatically below so it
402
- // only appears in templates that actually use SQLite.
442
+ // The SQLite dev.db rule is appended programmatically below so it
443
+ // only appears for the sqlite dialect.
403
444
  '.gitignore',
404
445
  // Git hooks (blocks commits on main)
405
446
  '.hooks/pre-commit',
@@ -471,62 +512,172 @@ export async function scaffoldApp(name, cwd, opts = {}) {
471
512
  const preCommitPath = join(appDir, '.hooks', 'pre-commit');
472
513
  if (existsSync(preCommitPath)) await chmod(preCommitPath, 0o755);
473
514
 
474
- // --- Prisma schema + client singleton (all templates) ---
515
+ // --- Drizzle db layer (all templates), dialect-selected (#563) ---
516
+ //
517
+ // The schema, queries, and actions are identical across dialects; only
518
+ // db/columns.server.ts + db/connection.server.ts + drizzle.config.ts + the
519
+ // driver dep differ. Switching dialect (e.g. SQLite in dev, Postgres in
520
+ // prod) is a config + module swap, not a code rewrite. Pinned to drizzle-orm
521
+ // 1.0.0-rc.3 (relations v2). See research #562.
475
522
 
476
- await writeFile(join(appDir, 'prisma', 'schema.prisma'), `generator client {
477
- provider = "prisma-client-js"
478
- }
523
+ const columnsSqlite = `import { sqliteTableCreator, integer, text, real, blob, index as _index } from 'drizzle-orm/sqlite-core';
524
+ import type { SQLiteColumn } from 'drizzle-orm/sqlite-core';
525
+ import { getTableName, type Table } from 'drizzle-orm';
479
526
 
480
- datasource db {
481
- // Defaults to SQLite at ./prisma/dev.db. Switch to postgresql / mysql
482
- // by changing the provider + DATABASE_URL in .env.
483
- provider = "sqlite"
484
- url = env("DATABASE_URL")
485
- }
527
+ // Raw drizzle builders, re-exported so the schema reads like drizzle.
528
+ export { text, integer, real, blob };
529
+
530
+ // Casing factory: column keys map to snake_case SQL names.
531
+ export const table = sqliteTableCreator((name) => name, 'snake_case');
532
+
533
+ export const pk = () => integer().primaryKey({ autoIncrement: true });
534
+ export const uuidPk = () => text().primaryKey().$defaultFn(() => crypto.randomUUID());
535
+ export const uuid = () => text();
536
+ export const bool = () => integer({ mode: 'boolean' });
537
+ export const timestamp = () => integer({ mode: 'timestamp_ms' });
538
+ export const createdAt = () => timestamp().notNull().defaultNow();
539
+ export const updatedAt = () => timestamp().notNull().defaultNow().$onUpdate(() => new Date());
540
+
541
+ // Anonymous-style index helper (rc.3 requires a name; this derives a
542
+ // table-qualified one, matching drizzle-kit's own convention).
543
+ export const index = (...cols: SQLiteColumn[]) =>
544
+ _index(getTableName((cols[0] as unknown as { table: Table }).table) + '_' + cols.map((c) => c.name).join('_') + '_idx').on(...(cols as [SQLiteColumn, ...SQLiteColumn[]]));
545
+ `;
546
+
547
+ const columnsPg = `import { pgTableCreator, serial, uuid as pgUuid, integer, text, real, boolean, timestamp as pgTimestamp, index as _index } from 'drizzle-orm/pg-core';
548
+ import type { PgColumn } from 'drizzle-orm/pg-core';
549
+ import { getTableName, type Table } from 'drizzle-orm';
550
+
551
+ export { text, integer, real };
552
+
553
+ export const table = pgTableCreator((name) => name, 'snake_case');
554
+
555
+ export const pk = () => serial().primaryKey();
556
+ export const uuidPk = () => pgUuid().primaryKey().defaultRandom();
557
+ export const uuid = () => pgUuid();
558
+ export const bool = () => boolean();
559
+ export const timestamp = () => pgTimestamp({ withTimezone: true });
560
+ export const createdAt = () => timestamp().notNull().defaultNow();
561
+ export const updatedAt = () => timestamp().notNull().defaultNow().$onUpdate(() => new Date());
562
+
563
+ export const index = (...cols: PgColumn[]) =>
564
+ _index(getTableName((cols[0] as unknown as { table: Table }).table) + '_' + cols.map((c) => c.name).join('_') + '_idx').on(...(cols as [PgColumn, ...PgColumn[]]));
565
+ `;
566
+
567
+ await writeFile(join(appDir, 'db', 'columns.server.ts'), dialect === 'postgres' ? columnsPg : columnsSqlite);
568
+
569
+ // Example schema (dialect-agnostic). Replace the User model with your own.
570
+ await writeFile(join(appDir, 'db', 'schema.server.ts'), `import { defineRelations } from 'drizzle-orm';
571
+ import { table, pk, text, createdAt } from './columns.server.ts';
486
572
 
487
573
  // Example model. Feel free to delete or extend.
488
- model User {
489
- id Int @id @default(autoincrement())
490
- email String @unique
491
- name String?
492
- createdAt DateTime @default(now())
493
- }
574
+ export const users = table('users', {
575
+ id: pk(),
576
+ email: text().notNull().unique(),
577
+ name: text(),
578
+ createdAt: createdAt(),
579
+ });
580
+
581
+ // Relations live here (one defineRelations for the whole schema). Empty
582
+ // for now; add per-model relations as your schema grows.
583
+ export const relations = defineRelations({ users }, () => ({}));
584
+
585
+ // Derived types, never hand-written.
586
+ export type User = typeof users.$inferSelect;
494
587
  `);
495
588
 
496
- await writeFile(join(appDir, 'lib', 'prisma.server.ts'), `/**
497
- * Prisma client singleton. The \`globalThis\` trick keeps a single
498
- * instance across dev-server module reloads, so we don't open a new
499
- * DB connection on every file change.
500
- */
501
- import { PrismaClient } from '@prisma/client';
589
+ const connSqlite = `import { isAbsolute, resolve, dirname } from 'node:path';
590
+ import { fileURLToPath } from 'node:url';
591
+ import * as schema from './schema.server.ts';
592
+
593
+ // The only file that opens the driver. Runtime-neutral: native bun:sqlite on
594
+ // Bun, better-sqlite3 on Node. Cached on globalThis across dev reloads.
595
+ // A relative SQLite path resolves against the app root (the parent of db/), not
596
+ // process.cwd(), so the connection works under \`webjs dev\` AND when the app is
597
+ // embedded via createRequestHandler from a different working directory.
598
+ const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
599
+ const raw = process.env.DATABASE_URL?.replace(/^file:/, '') ?? 'db/dev.db';
600
+ const url = raw === ':memory:' || isAbsolute(raw) ? raw : resolve(appRoot, raw);
601
+ const g = globalThis as unknown as { __webjs_db?: unknown };
602
+
603
+ async function open() {
604
+ if ((globalThis as { Bun?: unknown }).Bun) {
605
+ const { Database } = await import('bun:sqlite');
606
+ const { drizzle } = await import('drizzle-orm/bun-sqlite');
607
+ return drizzle({ client: new Database(url), relations: schema.relations });
608
+ }
609
+ const { default: Database } = await import('better-sqlite3');
610
+ const { drizzle } = await import('drizzle-orm/better-sqlite3');
611
+ return drizzle({ client: new Database(url), relations: schema.relations });
612
+ }
613
+
614
+ export const db = (g.__webjs_db ??= await open()) as Awaited<ReturnType<typeof open>>;
615
+ `;
502
616
 
503
- const g = globalThis as unknown as { __prisma?: PrismaClient };
617
+ const connPg = `import { drizzle } from 'drizzle-orm/node-postgres';
618
+ import { Pool } from 'pg';
619
+ import * as schema from './schema.server.ts';
504
620
 
505
- export const prisma = g.__prisma ?? new PrismaClient();
506
- if (process.env.NODE_ENV !== 'production') g.__prisma = prisma;
621
+ // The only file that opens the driver. Cached on globalThis across dev reloads.
622
+ const g = globalThis as unknown as { __webjs_db?: unknown };
623
+ function open() {
624
+ return drizzle({ client: new Pool({ connectionString: process.env.DATABASE_URL }), relations: schema.relations });
625
+ }
626
+ export const db = (g.__webjs_db ??= open()) as ReturnType<typeof open>;
627
+ `;
628
+
629
+ await writeFile(join(appDir, 'db', 'connection.server.ts'), dialect === 'postgres' ? connPg : connSqlite);
630
+
631
+ // drizzle-kit config (root, must be this exact filename). DB url from env.
632
+ await writeFile(join(appDir, 'drizzle.config.ts'), dialect === 'postgres'
633
+ ? `import { defineConfig } from 'drizzle-kit';
634
+
635
+ export default defineConfig({
636
+ dialect: 'postgresql',
637
+ schema: './db/schema.server.ts',
638
+ out: './db/migrations',
639
+ dbCredentials: { url: process.env.DATABASE_URL! },
640
+ });
641
+ `
642
+ : `import { defineConfig } from 'drizzle-kit';
643
+
644
+ export default defineConfig({
645
+ dialect: 'sqlite',
646
+ schema: './db/schema.server.ts',
647
+ out: './db/migrations',
648
+ dbCredentials: { url: process.env.DATABASE_URL?.replace(/^file:/, '') ?? 'db/dev.db' },
649
+ });
507
650
  `);
508
651
 
509
- // Env vars: append DATABASE_URL to the .env.example the template
510
- // already copied (if present). The scaffold's root .env.example
511
- // lists auth secrets etc.; we just add the DB line idempotently.
652
+ // Env vars: append DATABASE_URL to the .env.example the template already
653
+ // copied (if present), idempotently.
654
+ const dbUrlLine = dialect === 'postgres'
655
+ ? 'DATABASE_URL=postgres://user:password@localhost:5432/' + name.replace(/[^a-z0-9_]/gi, '_')
656
+ : 'DATABASE_URL=file:./db/dev.db';
512
657
  const envExample = join(appDir, '.env.example');
513
658
  if (existsSync(envExample)) {
514
- const cur = await readFile(envExample, 'utf8');
515
- if (!cur.includes('DATABASE_URL')) {
516
- await writeFile(envExample, cur.replace(/\n?$/, '\n') + '\nDATABASE_URL=file:./prisma/dev.db\n');
659
+ let cur = await readFile(envExample, 'utf8');
660
+ // Replace any existing DATABASE_URL line so it is dialect-correct; else append.
661
+ if (/^DATABASE_URL=.*$/m.test(cur)) {
662
+ cur = cur.replace(/^DATABASE_URL=.*$/m, dbUrlLine);
663
+ } else {
664
+ cur = cur.replace(/\n?$/, '\n') + '\n' + dbUrlLine + '\n';
517
665
  }
666
+ await writeFile(envExample, cur);
518
667
  } else {
519
- await writeFile(envExample, 'DATABASE_URL=file:./prisma/dev.db\n');
668
+ await writeFile(envExample, dbUrlLine + '\n');
520
669
  }
521
670
 
522
- // .gitignore the generated SQLite file.
523
- const gitignore = join(appDir, '.gitignore');
524
- const gitignoreExtra = '\n# SQLite dev database\nprisma/dev.db\nprisma/dev.db-journal\n';
525
- if (existsSync(gitignore)) {
526
- const cur = await readFile(gitignore, 'utf8');
527
- if (!cur.includes('prisma/dev.db')) await writeFile(gitignore, cur + gitignoreExtra);
528
- } else {
529
- await writeFile(gitignore, 'node_modules\n.webjs\n' + gitignoreExtra);
671
+ // .gitignore the generated SQLite file (sqlite only; postgres has no local file).
672
+ if (dialect !== 'postgres') {
673
+ const gitignore = join(appDir, '.gitignore');
674
+ const gitignoreExtra = '\n# SQLite dev database\ndb/dev.db\ndb/dev.db-journal\ndb/dev.db-*\n';
675
+ if (existsSync(gitignore)) {
676
+ const cur = await readFile(gitignore, 'utf8');
677
+ if (!cur.includes('db/dev.db')) await writeFile(gitignore, cur + gitignoreExtra);
678
+ } else {
679
+ await writeFile(gitignore, 'node_modules\n.webjs\n' + gitignoreExtra);
680
+ }
530
681
  }
531
682
 
532
683
  // --- App files (template-specific) ---
@@ -585,8 +736,8 @@ export async function createUser(input: { name: string; email: string }) {
585
736
  * /api/users: thin route wrapper over typed server actions.
586
737
  * Business logic lives in modules/users/, not here.
587
738
  */
588
- import { listUsers } from '../../../modules/users/queries/list-users.server.ts';
589
- import { createUser } from '../../../modules/users/actions/create-user.server.ts';
739
+ import { listUsers } from '#modules/users/queries/list-users.server.ts';
740
+ import { createUser } from '#modules/users/actions/create-user.server.ts';
590
741
 
591
742
  export async function GET() {
592
743
  return Response.json(await listUsers());
@@ -603,8 +754,8 @@ export async function POST(req: Request) {
603
754
  await writeFile(join(appDir, 'test', 'unit', 'users.test.ts'), `import { test } from 'node:test';
604
755
  import assert from 'node:assert/strict';
605
756
 
606
- import { listUsers } from '../../modules/users/queries/list-users.server.ts';
607
- import { createUser } from '../../modules/users/actions/create-user.server.ts';
757
+ import { listUsers } from '#modules/users/queries/list-users.server.ts';
758
+ import { createUser } from '#modules/users/actions/create-user.server.ts';
608
759
 
609
760
  test('listUsers returns an array', async () => {
610
761
  const users = await listUsers();
@@ -694,16 +845,16 @@ export type ActionResult<T> =
694
845
  await writeFile(join(appDir, 'app', 'layout.ts'), `// webjs-scaffold-placeholder. This is the example app chrome (brand, nav, content-width container). Adapt it to your app, then delete this line. webjs check fails while the marker remains.
695
846
  import { html, cspNonce } from '@webjsdev/core';
696
847
  import '@webjsdev/core/client-router';
697
- import '../components/theme-toggle.ts';
848
+ import '#components/theme-toggle.ts';
698
849
  // Webjs UI components are tiered:
699
850
  // - Tier 1 (button, card, input, label, alert, badge, separator, etc.) are
700
851
  // class-helper FUNCTIONS, with no custom element to register. Each page
701
852
  // imports the specific helpers it needs (e.g.
702
- // \`import { buttonClass } from '../components/ui/button.ts'\`).
853
+ // \`import { buttonClass } from '#components/ui/button.ts'\`).
703
854
  // - Tier 2 (dialog, popover, tooltip, tabs, accordion, etc.) ARE custom
704
855
  // elements. Register them by side-effect-importing here once so they
705
856
  // work transitively across every page:
706
- // import '../components/ui/dialog.ts';
857
+ // import '#components/ui/dialog.ts';
707
858
  // The example app/page.ts below uses only Tier-1 helpers, so nothing
708
859
  // extra needs to be registered. Add Tier-2 imports as you 'webjs ui add'.
709
860
 
@@ -875,18 +1026,18 @@ ${SHADCN_THEME}
875
1026
 
876
1027
  await writeFile(join(appDir, 'app', 'page.ts'), `// webjs-scaffold-placeholder. This is the example homepage. Replace it with your app's real page, then delete this line. webjs check fails while the marker remains.
877
1028
  import { html } from '@webjsdev/core';
878
- import { rubric, displayH1, accentLink } from '../lib/utils/ui.ts';
879
- import { buttonClass } from '../components/ui/button.ts';
880
- import { badgeClass } from '../components/ui/badge.ts';
1029
+ import { rubric, displayH1, accentLink } from '#lib/utils/ui.ts';
1030
+ import { buttonClass } from '#components/ui/button.ts';
1031
+ import { badgeClass } from '#components/ui/badge.ts';
881
1032
  import {
882
1033
  cardClass,
883
1034
  cardHeaderClass,
884
1035
  cardTitleClass,
885
1036
  cardDescriptionClass,
886
1037
  cardContentClass,
887
- } from '../components/ui/card.ts';
888
- import { alertClass, alertTitleClass, alertDescriptionClass } from '../components/ui/alert.ts';
889
- import { separatorClass } from '../components/ui/separator.ts';
1038
+ } from '#components/ui/card.ts';
1039
+ import { alertClass, alertTitleClass, alertDescriptionClass } from '#components/ui/alert.ts';
1040
+ import { separatorClass } from '#components/ui/separator.ts';
890
1041
 
891
1042
  export const metadata = {
892
1043
  title: '${name}: built with webjs',
@@ -1019,7 +1170,7 @@ ThemeToggle.register('theme-toggle');
1019
1170
  `);
1020
1171
  } // end if (!isApi)
1021
1172
 
1022
- // --- SaaS template extras: auth, dashboard, prisma ---
1173
+ // --- SaaS template extras: auth, dashboard, drizzle User model ---
1023
1174
  if (isSaas) {
1024
1175
  const { writeSaasFiles } = await import('./saas-template.js');
1025
1176
  await writeSaasFiles(appDir);
@@ -1056,9 +1207,9 @@ ThemeToggle.register('theme-toggle');
1056
1207
  dialog,form,field,switch,checkbox}.ts
1057
1208
  components/theme-toggle.ts
1058
1209
  modules/auth/{actions,queries,types.ts}
1059
- lib/{auth,prisma,password}.server.ts
1210
+ lib/{auth,password}.server.ts
1060
1211
  lib/utils/cn.ts ← cn() helper for ui-* components
1061
- prisma/schema.prisma ← User model
1212
+ db/{schema,columns,connection}.server.ts ← Drizzle (User model)
1062
1213
  CONVENTIONS.md, AGENTS.md, CLAUDE.md
1063
1214
  `);
1064
1215
  } else {
@@ -1096,10 +1247,10 @@ For AI agents, read this before editing scaffolded files:
1096
1247
  no-scaffold-placeholder violations (app/page.ts, app/layout.ts).
1097
1248
  That is the signal to replace the example content. Delete each
1098
1249
  marker comment line as you do, and the check goes green.
1099
- • Use Prisma + SQLite for app data. It's already wired up. Define
1100
- real models in prisma/schema.prisma and run \`webjs db migrate\`.
1101
- NEVER store app data in JSON files, in-memory arrays, or
1102
- localStorage as a substitute for the database.
1250
+ • Use Drizzle + SQLite for app data. It's already wired up. Define
1251
+ real models in db/schema.server.ts, then run \`webjs db generate\`
1252
+ and \`webjs db migrate\`. NEVER store app data in JSON files,
1253
+ in-memory arrays, or localStorage as a substitute for the database.
1103
1254
  • Only three scaffolds exist: full-stack (default), api, saas. Don't
1104
1255
  invent template names. If you need a different kind of app, pick
1105
1256
  the closest scaffold and adapt it.
@@ -1129,11 +1280,12 @@ For AI agents, read this before editing scaffolded files:
1129
1280
  // to "dev server up" in one command. The full-stack and saas
1130
1281
  // templates ship with @webjsdev/ui already initialised; the api
1131
1282
  // template has no UI but may add one later. Saas needs a one-time
1132
- // prisma migrate before the first run (the example User model wants
1133
- // its table to exist).
1283
+ // generate + migrate before the first run (the example User model wants
1284
+ // its table to exist). Drizzle splits Prisma's `migrate dev` into
1285
+ // `db:generate` (schema to SQL) then `db:migrate` (apply).
1134
1286
  const installSegment = installed ? '' : `${pm} install && `;
1135
- const prismaSegment = isSaas ? `npx prisma migrate dev --name init && ` : '';
1136
- const runCommand = `cd ${name} && ${installSegment}${prismaSegment}${pm} run dev`;
1287
+ const dbSegment = isSaas ? `${pm} run db:generate && ${pm} run db:migrate && ` : '';
1288
+ const runCommand = `cd ${name} && ${installSegment}${dbSegment}${pm} run dev`;
1137
1289
  // Use `npx webjsdev ui ...` here, not `npx webjs ui ...`. The bare
1138
1290
  // `webjs` npm name is owned by an unrelated package; `npx webjs
1139
1291
  // <cmd>` would fetch THAT package instead of ours when run outside
@@ -0,0 +1,100 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { delimiter, dirname, join } from 'node:path';
3
+
4
+ /**
5
+ * Build a PATH the way `npm run` does: prepend every ANCESTOR
6
+ * `node_modules/.bin` (the app's, then up to the repo root for a hoisted
7
+ * monorepo) so a `before` / `parallel` command naming a LOCAL-only binary
8
+ * (`drizzle-kit`, `tailwindcss`) resolves under a bare `webjs dev` / `start`, exactly
9
+ * as it does under `npm run dev`. Without this a bare `webjs dev` exits 127 on
10
+ * the first such step and aborts the boot, defeating the whole #550 point.
11
+ *
12
+ * @param {string} cwd
13
+ * @param {NodeJS.ProcessEnv} [env]
14
+ */
15
+ function envWithLocalBin(cwd, env = process.env) {
16
+ const bins = [];
17
+ let dir = cwd;
18
+ // Walk up to the filesystem root, collecting each node_modules/.bin.
19
+ for (;;) {
20
+ bins.push(join(dir, 'node_modules', '.bin'));
21
+ const parent = dirname(dir);
22
+ if (parent === dir) break;
23
+ dir = parent;
24
+ }
25
+ return { ...env, PATH: [...bins, env.PATH || ''].join(delimiter) };
26
+ }
27
+
28
+ /**
29
+ * Run the configured `before` steps (#550) sequentially to completion. Returns
30
+ * the FIRST failure so the caller can abort the boot, or `{ ok: true }`. Pure of
31
+ * `process.exit` and `console` (the bin owns the exit code + logging via the
32
+ * `onStep` hook) so the orchestration is deterministically unit-testable, with
33
+ * `spawn` injectable for tests.
34
+ *
35
+ * @param {string[]} steps
36
+ * @param {string} cwd
37
+ * @param {{ spawn?: typeof nodeSpawn, onStep?: (step: string) => void }} [opts]
38
+ * @returns {Promise<{ ok: true } | { ok: false, step: string, code: number }>}
39
+ */
40
+ export async function runBeforeSteps(steps, cwd, opts = {}) {
41
+ const spawn = opts.spawn || nodeSpawn;
42
+ const env = envWithLocalBin(cwd);
43
+ for (const step of steps) {
44
+ if (opts.onStep) opts.onStep(step);
45
+ const code = await new Promise((res) => {
46
+ const c = spawn(step, { shell: true, stdio: 'inherit', cwd, env });
47
+ c.on('exit', (code) => res(code ?? 0));
48
+ c.on('error', () => res(1));
49
+ });
50
+ if (code !== 0) return { ok: false, step, code };
51
+ }
52
+ return { ok: true };
53
+ }
54
+
55
+ /**
56
+ * Spawn the configured dev `parallel` tasks (#550) as long-lived children and
57
+ * return a killer that tears them ALL down (idempotent), so a watcher cannot
58
+ * leak past the dev server. `spawn` is injectable for tests.
59
+ *
60
+ * @param {string[]} commands
61
+ * @param {string} cwd
62
+ * @param {{ spawn?: typeof nodeSpawn, onStart?: (cmd: string) => void }} [opts]
63
+ * @returns {() => void}
64
+ */
65
+ export function startParallelTasks(commands, cwd, opts = {}) {
66
+ const spawn = opts.spawn || nodeSpawn;
67
+ const env = envWithLocalBin(cwd);
68
+ const children = commands.map((cmd) => {
69
+ if (opts.onStart) opts.onStart(cmd);
70
+ // `detached: true` puts the child in its OWN process group, so the killer
71
+ // can take down the whole tree (the `sh -c` wrapper AND the watcher it
72
+ // spawns, e.g. tailwindcss) rather than just the shell, which would leak the
73
+ // watcher as an orphan.
74
+ return spawn(cmd, { shell: true, stdio: 'inherit', cwd, env, detached: true });
75
+ });
76
+ let killed = false;
77
+ return () => {
78
+ if (killed) return;
79
+ killed = true;
80
+ for (const c of children) killChildTree(c);
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Tear down a shell-spawned child's whole process GROUP. A `sh -c '<watcher>'`
86
+ * child run with `detached: true` is a group leader, so a NEGATIVE pid signals
87
+ * the group (the shell + the watcher). Falls back to a direct `kill()` when
88
+ * there is no numeric pid (a fake child in a test) or the group kill is
89
+ * unsupported (a non-POSIX runtime), so the killer never throws.
90
+ *
91
+ * @param {import('node:child_process').ChildProcess} child
92
+ */
93
+ function killChildTree(child) {
94
+ try {
95
+ if (typeof child.pid === 'number') process.kill(-child.pid, 'SIGTERM');
96
+ else child.kill();
97
+ } catch {
98
+ try { child.kill(); } catch {}
99
+ }
100
+ }