@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/README.md +3 -3
- package/bin/webjs.js +90 -22
- package/lib/app-tasks.js +62 -0
- package/lib/create.js +234 -82
- package/lib/run-tasks.js +100 -0
- package/lib/saas-template.js +65 -73
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +5 -5
- package/templates/.env.example +2 -2
- package/templates/.github/copilot-instructions.md +4 -4
- package/templates/.github/workflows/ci.yml +4 -7
- package/templates/AGENTS.md +88 -72
- package/templates/CONVENTIONS.md +52 -41
- package/templates/Dockerfile +10 -7
- package/templates/compose.yaml +3 -3
- package/templates/test/hello/hello.test.ts +1 -1
- package/lib/prisma-preflight.js +0 -168
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`
|
|
89
|
-
* `components/ui/<name>.ts
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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'", "'
|
|
102
|
-
.replaceAll('"../lib/utils.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
|
-
'
|
|
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:
|
|
282
|
-
|
|
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:
|
|
296
|
-
'db:
|
|
297
|
-
'db:
|
|
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
|
-
|
|
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
|
-
|
|
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', '
|
|
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
|
|
402
|
-
// only appears
|
|
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
|
-
// ---
|
|
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
|
-
|
|
477
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
489
|
-
id
|
|
490
|
-
email
|
|
491
|
-
name
|
|
492
|
-
createdAt
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
*
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
|
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
|
-
|
|
506
|
-
|
|
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
|
-
//
|
|
511
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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, '
|
|
668
|
+
await writeFile(envExample, dbUrlLine + '\n');
|
|
520
669
|
}
|
|
521
670
|
|
|
522
|
-
// .gitignore the generated SQLite file.
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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 '
|
|
589
|
-
import { createUser } from '
|
|
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 '
|
|
607
|
-
import { createUser } from '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
879
|
-
import { buttonClass } from '
|
|
880
|
-
import { badgeClass } from '
|
|
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 '
|
|
888
|
-
import { alertClass, alertTitleClass, alertDescriptionClass } from '
|
|
889
|
-
import { separatorClass } from '
|
|
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,
|
|
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,
|
|
1210
|
+
lib/{auth,password}.server.ts
|
|
1060
1211
|
lib/utils/cn.ts ← cn() helper for ui-* components
|
|
1061
|
-
|
|
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
|
|
1100
|
-
real models in
|
|
1101
|
-
NEVER store app data in JSON files,
|
|
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
|
-
//
|
|
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
|
|
1136
|
-
const runCommand = `cd ${name} && ${installSegment}${
|
|
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
|
package/lib/run-tasks.js
ADDED
|
@@ -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
|
+
}
|