@webjsdev/cli 0.10.16 → 0.10.18
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 +118 -52
- package/lib/app-tasks.js +62 -0
- package/lib/create.js +202 -62
- package/lib/dev-supervisor.js +61 -0
- package/lib/run-tasks.js +100 -0
- package/lib/saas-template.js +39 -49
- 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 +80 -64
- package/templates/CONVENTIONS.md +45 -35
- package/templates/Dockerfile +10 -7
- package/templates/compose.yaml +3 -3
- package/lib/prisma-preflight.js +0 -168
package/lib/create.js
CHANGED
|
@@ -249,6 +249,15 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
249
249
|
}
|
|
250
250
|
const isApi = template === 'api';
|
|
251
251
|
const isSaas = template === 'saas';
|
|
252
|
+
|
|
253
|
+
// Database dialect (#563): sqlite (default) or postgres. Drizzle is the ORM;
|
|
254
|
+
// the schema/queries/actions are identical across dialects, only db/columns
|
|
255
|
+
// + db/connection + the driver dep differ.
|
|
256
|
+
const dialect = opts.db || 'sqlite';
|
|
257
|
+
const VALID_DIALECTS = ['sqlite', 'postgres'];
|
|
258
|
+
if (!VALID_DIALECTS.includes(dialect)) {
|
|
259
|
+
throw new Error(`Unknown --db '${dialect}'. Only ${VALID_DIALECTS.join(' / ')} are supported.`);
|
|
260
|
+
}
|
|
252
261
|
const appDir = join(cwd, name);
|
|
253
262
|
if (existsSync(appDir)) {
|
|
254
263
|
console.error(`Error: directory '${name}' already exists.`);
|
|
@@ -264,7 +273,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
264
273
|
'modules',
|
|
265
274
|
'lib',
|
|
266
275
|
'public',
|
|
267
|
-
'
|
|
276
|
+
'db',
|
|
268
277
|
'test/unit',
|
|
269
278
|
'test/e2e',
|
|
270
279
|
];
|
|
@@ -278,8 +287,10 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
278
287
|
type: 'module',
|
|
279
288
|
private: true,
|
|
280
289
|
scripts: {
|
|
281
|
-
predev:
|
|
282
|
-
|
|
290
|
+
// No `predev` / `prestart` hooks (#550): the `webjs` block below holds
|
|
291
|
+
// the start orchestration (`webjs db migrate`), run INSIDE `webjs start`,
|
|
292
|
+
// so `npm run start` (a thin alias) behaves identically. Drizzle has no
|
|
293
|
+
// codegen, so there is no dev `before` step.
|
|
283
294
|
dev: 'webjs dev',
|
|
284
295
|
start: 'webjs start',
|
|
285
296
|
test: 'webjs test',
|
|
@@ -292,18 +303,24 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
292
303
|
// vendor pins, @webjsdev versions, git hook). Local tool, NOT a CI gate
|
|
293
304
|
// (its env-drift + network pin-freshness checks would make CI flaky).
|
|
294
305
|
doctor: 'webjs doctor',
|
|
295
|
-
'db:
|
|
296
|
-
'db:
|
|
297
|
-
'db:
|
|
306
|
+
'db:generate': 'webjs db generate',
|
|
307
|
+
'db:migrate': 'webjs db migrate',
|
|
308
|
+
'db:push': 'webjs db push',
|
|
309
|
+
'db:studio': 'webjs db studio',
|
|
310
|
+
'db:seed': 'webjs db seed',
|
|
298
311
|
},
|
|
299
312
|
dependencies: {
|
|
300
|
-
|
|
313
|
+
// Drizzle ORM (no codegen, no engine binary). Pinned to the 1.0 line
|
|
314
|
+
// for relations v2. The SQLite/Postgres driver below is dialect-picked.
|
|
315
|
+
'drizzle-orm': '^1.0.0-rc.3',
|
|
316
|
+
...(dialect === 'postgres' ? { pg: '^8.13.0' } : { 'better-sqlite3': '^12.11.1' }),
|
|
301
317
|
'@webjsdev/cli': 'latest',
|
|
302
318
|
'@webjsdev/core': 'latest',
|
|
303
319
|
'@webjsdev/server': 'latest',
|
|
304
320
|
},
|
|
305
321
|
devDependencies: {
|
|
306
|
-
|
|
322
|
+
'drizzle-kit': '^1.0.0-rc.3',
|
|
323
|
+
...(dialect === 'postgres' ? { '@types/pg': '^8.11.0' } : {}),
|
|
307
324
|
// The TypeScript compiler, for `npm run typecheck` (webjs typecheck runs
|
|
308
325
|
// tsc --noEmit). Not needed at runtime (Node strips types in place), only
|
|
309
326
|
// to type-check the app.
|
|
@@ -330,6 +347,18 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
330
347
|
// into components/ui/ (they import @webjsdev/core, not the kit), and the
|
|
331
348
|
// CLI resolves @webjsdev/ui from its own install.
|
|
332
349
|
},
|
|
350
|
+
// Start task orchestration (#550). `webjs start` reads `start.before` and
|
|
351
|
+
// runs it in-process, so `npm run start` (a thin alias above) behaves
|
|
352
|
+
// identically. Drizzle has no codegen, so there is no dev `before` step;
|
|
353
|
+
// production applies pending migrations via `webjs db migrate`. The scaffold
|
|
354
|
+
// uses the Tailwind browser runtime (no CSS build step), so there is no dev
|
|
355
|
+
// `parallel` watcher here; an app that adds the Tailwind CLI puts its
|
|
356
|
+
// `--watch` command under `webjs.dev.parallel`.
|
|
357
|
+
webjs: {
|
|
358
|
+
// Drizzle has no codegen, so there is no dev `before` step. Production
|
|
359
|
+
// applies pending migrations at boot via `webjs db migrate` (drizzle-kit).
|
|
360
|
+
start: { before: ['webjs db migrate'] },
|
|
361
|
+
},
|
|
333
362
|
}, null, 2) + '\n');
|
|
334
363
|
|
|
335
364
|
await writeFile(join(appDir, 'tsconfig.json'), JSON.stringify({
|
|
@@ -381,7 +410,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
381
410
|
'middleware.ts',
|
|
382
411
|
'.webjs/routes.d.ts',
|
|
383
412
|
],
|
|
384
|
-
exclude: ['node_modules', '.webjs/vendor', '
|
|
413
|
+
exclude: ['node_modules', '.webjs/vendor', 'db/migrations'],
|
|
385
414
|
}, null, 2) + '\n');
|
|
386
415
|
|
|
387
416
|
// --- Templates (AGENTS.md, CONVENTIONS.md, CLAUDE.md, test files, Claude hooks) ---
|
|
@@ -398,8 +427,8 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
398
427
|
// Environment variables
|
|
399
428
|
'.env.example',
|
|
400
429
|
// Project-level gitignore (node_modules, .webjs, .env, OS junk).
|
|
401
|
-
// The
|
|
402
|
-
// only appears
|
|
430
|
+
// The SQLite dev.db rule is appended programmatically below so it
|
|
431
|
+
// only appears for the sqlite dialect.
|
|
403
432
|
'.gitignore',
|
|
404
433
|
// Git hooks (blocks commits on main)
|
|
405
434
|
'.hooks/pre-commit',
|
|
@@ -471,62 +500,172 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
471
500
|
const preCommitPath = join(appDir, '.hooks', 'pre-commit');
|
|
472
501
|
if (existsSync(preCommitPath)) await chmod(preCommitPath, 0o755);
|
|
473
502
|
|
|
474
|
-
// ---
|
|
503
|
+
// --- Drizzle db layer (all templates), dialect-selected (#563) ---
|
|
504
|
+
//
|
|
505
|
+
// The schema, queries, and actions are identical across dialects; only
|
|
506
|
+
// db/columns.server.ts + db/connection.server.ts + drizzle.config.ts + the
|
|
507
|
+
// driver dep differ. Switching dialect (e.g. SQLite in dev, Postgres in
|
|
508
|
+
// prod) is a config + module swap, not a code rewrite. Pinned to drizzle-orm
|
|
509
|
+
// 1.0.0-rc.3 (relations v2). See research #562.
|
|
475
510
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}
|
|
511
|
+
const columnsSqlite = `import { sqliteTableCreator, integer, text, real, blob, index as _index } from 'drizzle-orm/sqlite-core';
|
|
512
|
+
import type { SQLiteColumn } from 'drizzle-orm/sqlite-core';
|
|
513
|
+
import { getTableName, type Table } from 'drizzle-orm';
|
|
479
514
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
515
|
+
// Raw drizzle builders, re-exported so the schema reads like drizzle.
|
|
516
|
+
export { text, integer, real, blob };
|
|
517
|
+
|
|
518
|
+
// Casing factory: column keys map to snake_case SQL names.
|
|
519
|
+
export const table = sqliteTableCreator((name) => name, 'snake_case');
|
|
520
|
+
|
|
521
|
+
export const pk = () => integer().primaryKey({ autoIncrement: true });
|
|
522
|
+
export const uuidPk = () => text().primaryKey().$defaultFn(() => crypto.randomUUID());
|
|
523
|
+
export const uuid = () => text();
|
|
524
|
+
export const bool = () => integer({ mode: 'boolean' });
|
|
525
|
+
export const timestamp = () => integer({ mode: 'timestamp_ms' });
|
|
526
|
+
export const createdAt = () => timestamp().notNull().defaultNow();
|
|
527
|
+
export const updatedAt = () => timestamp().notNull().defaultNow().$onUpdate(() => new Date());
|
|
528
|
+
|
|
529
|
+
// Anonymous-style index helper (rc.3 requires a name; this derives a
|
|
530
|
+
// table-qualified one, matching drizzle-kit's own convention).
|
|
531
|
+
export const index = (...cols: SQLiteColumn[]) =>
|
|
532
|
+
_index(getTableName((cols[0] as unknown as { table: Table }).table) + '_' + cols.map((c) => c.name).join('_') + '_idx').on(...(cols as [SQLiteColumn, ...SQLiteColumn[]]));
|
|
533
|
+
`;
|
|
534
|
+
|
|
535
|
+
const columnsPg = `import { pgTableCreator, serial, uuid as pgUuid, integer, text, real, boolean, timestamp as pgTimestamp, index as _index } from 'drizzle-orm/pg-core';
|
|
536
|
+
import type { PgColumn } from 'drizzle-orm/pg-core';
|
|
537
|
+
import { getTableName, type Table } from 'drizzle-orm';
|
|
538
|
+
|
|
539
|
+
export { text, integer, real };
|
|
540
|
+
|
|
541
|
+
export const table = pgTableCreator((name) => name, 'snake_case');
|
|
542
|
+
|
|
543
|
+
export const pk = () => serial().primaryKey();
|
|
544
|
+
export const uuidPk = () => pgUuid().primaryKey().defaultRandom();
|
|
545
|
+
export const uuid = () => pgUuid();
|
|
546
|
+
export const bool = () => boolean();
|
|
547
|
+
export const timestamp = () => pgTimestamp({ withTimezone: true });
|
|
548
|
+
export const createdAt = () => timestamp().notNull().defaultNow();
|
|
549
|
+
export const updatedAt = () => timestamp().notNull().defaultNow().$onUpdate(() => new Date());
|
|
550
|
+
|
|
551
|
+
export const index = (...cols: PgColumn[]) =>
|
|
552
|
+
_index(getTableName((cols[0] as unknown as { table: Table }).table) + '_' + cols.map((c) => c.name).join('_') + '_idx').on(...(cols as [PgColumn, ...PgColumn[]]));
|
|
553
|
+
`;
|
|
554
|
+
|
|
555
|
+
await writeFile(join(appDir, 'db', 'columns.server.ts'), dialect === 'postgres' ? columnsPg : columnsSqlite);
|
|
556
|
+
|
|
557
|
+
// Example schema (dialect-agnostic). Replace the User model with your own.
|
|
558
|
+
await writeFile(join(appDir, 'db', 'schema.server.ts'), `import { defineRelations } from 'drizzle-orm';
|
|
559
|
+
import { table, pk, text, createdAt } from './columns.server.ts';
|
|
486
560
|
|
|
487
561
|
// Example model. Feel free to delete or extend.
|
|
488
|
-
|
|
489
|
-
id
|
|
490
|
-
email
|
|
491
|
-
name
|
|
492
|
-
createdAt
|
|
493
|
-
}
|
|
562
|
+
export const users = table('users', {
|
|
563
|
+
id: pk(),
|
|
564
|
+
email: text().notNull().unique(),
|
|
565
|
+
name: text(),
|
|
566
|
+
createdAt: createdAt(),
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// Relations live here (one defineRelations for the whole schema). Empty
|
|
570
|
+
// for now; add per-model relations as your schema grows.
|
|
571
|
+
export const relations = defineRelations({ users }, () => ({}));
|
|
572
|
+
|
|
573
|
+
// Derived types, never hand-written.
|
|
574
|
+
export type User = typeof users.$inferSelect;
|
|
494
575
|
`);
|
|
495
576
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
*
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
577
|
+
const connSqlite = `import { isAbsolute, resolve, dirname } from 'node:path';
|
|
578
|
+
import { fileURLToPath } from 'node:url';
|
|
579
|
+
import * as schema from './schema.server.ts';
|
|
580
|
+
|
|
581
|
+
// The only file that opens the driver. Runtime-neutral: native bun:sqlite on
|
|
582
|
+
// Bun, better-sqlite3 on Node. Cached on globalThis across dev reloads.
|
|
583
|
+
// A relative SQLite path resolves against the app root (the parent of db/), not
|
|
584
|
+
// process.cwd(), so the connection works under \`webjs dev\` AND when the app is
|
|
585
|
+
// embedded via createRequestHandler from a different working directory.
|
|
586
|
+
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
587
|
+
const raw = process.env.DATABASE_URL?.replace(/^file:/, '') ?? 'db/dev.db';
|
|
588
|
+
const url = raw === ':memory:' || isAbsolute(raw) ? raw : resolve(appRoot, raw);
|
|
589
|
+
const g = globalThis as unknown as { __webjs_db?: unknown };
|
|
590
|
+
|
|
591
|
+
async function open() {
|
|
592
|
+
if ((globalThis as { Bun?: unknown }).Bun) {
|
|
593
|
+
const { Database } = await import('bun:sqlite');
|
|
594
|
+
const { drizzle } = await import('drizzle-orm/bun-sqlite');
|
|
595
|
+
return drizzle({ client: new Database(url), relations: schema.relations });
|
|
596
|
+
}
|
|
597
|
+
const { default: Database } = await import('better-sqlite3');
|
|
598
|
+
const { drizzle } = await import('drizzle-orm/better-sqlite3');
|
|
599
|
+
return drizzle({ client: new Database(url), relations: schema.relations });
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export const db = (g.__webjs_db ??= await open()) as Awaited<ReturnType<typeof open>>;
|
|
603
|
+
`;
|
|
502
604
|
|
|
503
|
-
const
|
|
605
|
+
const connPg = `import { drizzle } from 'drizzle-orm/node-postgres';
|
|
606
|
+
import { Pool } from 'pg';
|
|
607
|
+
import * as schema from './schema.server.ts';
|
|
504
608
|
|
|
505
|
-
|
|
506
|
-
|
|
609
|
+
// The only file that opens the driver. Cached on globalThis across dev reloads.
|
|
610
|
+
const g = globalThis as unknown as { __webjs_db?: unknown };
|
|
611
|
+
function open() {
|
|
612
|
+
return drizzle({ client: new Pool({ connectionString: process.env.DATABASE_URL }), relations: schema.relations });
|
|
613
|
+
}
|
|
614
|
+
export const db = (g.__webjs_db ??= open()) as ReturnType<typeof open>;
|
|
615
|
+
`;
|
|
616
|
+
|
|
617
|
+
await writeFile(join(appDir, 'db', 'connection.server.ts'), dialect === 'postgres' ? connPg : connSqlite);
|
|
618
|
+
|
|
619
|
+
// drizzle-kit config (root, must be this exact filename). DB url from env.
|
|
620
|
+
await writeFile(join(appDir, 'drizzle.config.ts'), dialect === 'postgres'
|
|
621
|
+
? `import { defineConfig } from 'drizzle-kit';
|
|
622
|
+
|
|
623
|
+
export default defineConfig({
|
|
624
|
+
dialect: 'postgresql',
|
|
625
|
+
schema: './db/schema.server.ts',
|
|
626
|
+
out: './db/migrations',
|
|
627
|
+
dbCredentials: { url: process.env.DATABASE_URL! },
|
|
628
|
+
});
|
|
629
|
+
`
|
|
630
|
+
: `import { defineConfig } from 'drizzle-kit';
|
|
631
|
+
|
|
632
|
+
export default defineConfig({
|
|
633
|
+
dialect: 'sqlite',
|
|
634
|
+
schema: './db/schema.server.ts',
|
|
635
|
+
out: './db/migrations',
|
|
636
|
+
dbCredentials: { url: process.env.DATABASE_URL?.replace(/^file:/, '') ?? 'db/dev.db' },
|
|
637
|
+
});
|
|
507
638
|
`);
|
|
508
639
|
|
|
509
|
-
// Env vars: append DATABASE_URL to the .env.example the template
|
|
510
|
-
//
|
|
511
|
-
|
|
640
|
+
// Env vars: append DATABASE_URL to the .env.example the template already
|
|
641
|
+
// copied (if present), idempotently.
|
|
642
|
+
const dbUrlLine = dialect === 'postgres'
|
|
643
|
+
? 'DATABASE_URL=postgres://user:password@localhost:5432/' + name.replace(/[^a-z0-9_]/gi, '_')
|
|
644
|
+
: 'DATABASE_URL=file:./db/dev.db';
|
|
512
645
|
const envExample = join(appDir, '.env.example');
|
|
513
646
|
if (existsSync(envExample)) {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
647
|
+
let cur = await readFile(envExample, 'utf8');
|
|
648
|
+
// Replace any existing DATABASE_URL line so it is dialect-correct; else append.
|
|
649
|
+
if (/^DATABASE_URL=.*$/m.test(cur)) {
|
|
650
|
+
cur = cur.replace(/^DATABASE_URL=.*$/m, dbUrlLine);
|
|
651
|
+
} else {
|
|
652
|
+
cur = cur.replace(/\n?$/, '\n') + '\n' + dbUrlLine + '\n';
|
|
517
653
|
}
|
|
654
|
+
await writeFile(envExample, cur);
|
|
518
655
|
} else {
|
|
519
|
-
await writeFile(envExample, '
|
|
656
|
+
await writeFile(envExample, dbUrlLine + '\n');
|
|
520
657
|
}
|
|
521
658
|
|
|
522
|
-
// .gitignore the generated SQLite file.
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
659
|
+
// .gitignore the generated SQLite file (sqlite only; postgres has no local file).
|
|
660
|
+
if (dialect !== 'postgres') {
|
|
661
|
+
const gitignore = join(appDir, '.gitignore');
|
|
662
|
+
const gitignoreExtra = '\n# SQLite dev database\ndb/dev.db\ndb/dev.db-journal\ndb/dev.db-*\n';
|
|
663
|
+
if (existsSync(gitignore)) {
|
|
664
|
+
const cur = await readFile(gitignore, 'utf8');
|
|
665
|
+
if (!cur.includes('db/dev.db')) await writeFile(gitignore, cur + gitignoreExtra);
|
|
666
|
+
} else {
|
|
667
|
+
await writeFile(gitignore, 'node_modules\n.webjs\n' + gitignoreExtra);
|
|
668
|
+
}
|
|
530
669
|
}
|
|
531
670
|
|
|
532
671
|
// --- App files (template-specific) ---
|
|
@@ -1019,7 +1158,7 @@ ThemeToggle.register('theme-toggle');
|
|
|
1019
1158
|
`);
|
|
1020
1159
|
} // end if (!isApi)
|
|
1021
1160
|
|
|
1022
|
-
// --- SaaS template extras: auth, dashboard,
|
|
1161
|
+
// --- SaaS template extras: auth, dashboard, drizzle User model ---
|
|
1023
1162
|
if (isSaas) {
|
|
1024
1163
|
const { writeSaasFiles } = await import('./saas-template.js');
|
|
1025
1164
|
await writeSaasFiles(appDir);
|
|
@@ -1056,9 +1195,9 @@ ThemeToggle.register('theme-toggle');
|
|
|
1056
1195
|
dialog,form,field,switch,checkbox}.ts
|
|
1057
1196
|
components/theme-toggle.ts
|
|
1058
1197
|
modules/auth/{actions,queries,types.ts}
|
|
1059
|
-
lib/{auth,
|
|
1198
|
+
lib/{auth,password}.server.ts
|
|
1060
1199
|
lib/utils/cn.ts ← cn() helper for ui-* components
|
|
1061
|
-
|
|
1200
|
+
db/{schema,columns,connection}.server.ts ← Drizzle (User model)
|
|
1062
1201
|
CONVENTIONS.md, AGENTS.md, CLAUDE.md
|
|
1063
1202
|
`);
|
|
1064
1203
|
} else {
|
|
@@ -1096,10 +1235,10 @@ For AI agents, read this before editing scaffolded files:
|
|
|
1096
1235
|
no-scaffold-placeholder violations (app/page.ts, app/layout.ts).
|
|
1097
1236
|
That is the signal to replace the example content. Delete each
|
|
1098
1237
|
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.
|
|
1238
|
+
• Use Drizzle + SQLite for app data. It's already wired up. Define
|
|
1239
|
+
real models in db/schema.server.ts, then run \`webjs db generate\`
|
|
1240
|
+
and \`webjs db migrate\`. NEVER store app data in JSON files,
|
|
1241
|
+
in-memory arrays, or localStorage as a substitute for the database.
|
|
1103
1242
|
• Only three scaffolds exist: full-stack (default), api, saas. Don't
|
|
1104
1243
|
invent template names. If you need a different kind of app, pick
|
|
1105
1244
|
the closest scaffold and adapt it.
|
|
@@ -1129,11 +1268,12 @@ For AI agents, read this before editing scaffolded files:
|
|
|
1129
1268
|
// to "dev server up" in one command. The full-stack and saas
|
|
1130
1269
|
// templates ship with @webjsdev/ui already initialised; the api
|
|
1131
1270
|
// template has no UI but may add one later. Saas needs a one-time
|
|
1132
|
-
//
|
|
1133
|
-
// its table to exist).
|
|
1271
|
+
// generate + migrate before the first run (the example User model wants
|
|
1272
|
+
// its table to exist). Drizzle splits Prisma's `migrate dev` into
|
|
1273
|
+
// `db:generate` (schema to SQL) then `db:migrate` (apply).
|
|
1134
1274
|
const installSegment = installed ? '' : `${pm} install && `;
|
|
1135
|
-
const
|
|
1136
|
-
const runCommand = `cd ${name} && ${installSegment}${
|
|
1275
|
+
const dbSegment = isSaas ? `${pm} run db:generate && ${pm} run db:migrate && ` : '';
|
|
1276
|
+
const runCommand = `cd ${name} && ${installSegment}${dbSegment}${pm} run dev`;
|
|
1137
1277
|
// Use `npx webjsdev ui ...` here, not `npx webjs ui ...`. The bare
|
|
1138
1278
|
// `webjs` npm name is owned by an unrelated package; `npx webjs
|
|
1139
1279
|
// <cmd>` would fetch THAT package instead of ours when run outside
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-server reload supervisor planning for `webjs dev` (issue #514).
|
|
3
|
+
*
|
|
4
|
+
* `webjs dev` re-execs itself under the host runtime's hot-reload supervisor so
|
|
5
|
+
* an edit to a transitively-imported module (an action, query, component, util)
|
|
6
|
+
* takes effect without a manual restart. Both runtimes cache ES modules by
|
|
7
|
+
* resolved URL with no public invalidation API, so the dev re-import in
|
|
8
|
+
* `@webjsdev/server`'s `dev.js` relies on the runtime's own file-watching cache
|
|
9
|
+
* invalidation:
|
|
10
|
+
*
|
|
11
|
+
* - **Node** has no in-place module-cache eviction, so it re-execs under
|
|
12
|
+
* `node --watch`, which RESTARTS the process on a file change (a fresh ESM
|
|
13
|
+
* cache each time). The dev re-import additionally appends a `?t=` cache-bust
|
|
14
|
+
* query that Node honours between restarts.
|
|
15
|
+
* - **Bun** keys its module cache by path and IGNORES that `?t=` query, so the
|
|
16
|
+
* `node --watch` model does not transfer: without help a re-imported module
|
|
17
|
+
* stays STALE on Bun (the #514 bug). Bun's `--hot` invalidates loaded modules
|
|
18
|
+
* on a file change WITHOUT restarting the process, which is exactly what the
|
|
19
|
+
* dev re-import needs; `Bun.serve` is reused across hot reloads, so the
|
|
20
|
+
* listener is not duplicated. `--hot` auto-watches every loaded file, so the
|
|
21
|
+
* node `--watch-path` flags do not apply (and are not Bun flags).
|
|
22
|
+
*
|
|
23
|
+
* This pure planner returns the spawn decision so the bin stays a thin shell and
|
|
24
|
+
* the branch logic is unit-testable without spawning a process.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Plan how `webjs dev` runs its server.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} opts
|
|
31
|
+
* @param {boolean} opts.isBun Whether the host runtime is Bun (`process.versions.bun`).
|
|
32
|
+
* @param {string[]} opts.argv `process.argv.slice(1)` (the script path followed by its args), forwarded to the child verbatim.
|
|
33
|
+
* @param {boolean} opts.noHot Whether `--no-hot` was passed (opt out of the supervisor entirely).
|
|
34
|
+
* @param {(path: string) => boolean} opts.exists Existence check for the Node `--watch-path` targets (relative to cwd). Unused on Bun.
|
|
35
|
+
* @returns {{ mode: 'inline' } | { mode: 'spawn', args: string[] }}
|
|
36
|
+
* `inline` runs the server in this process (no reload watcher); `spawn`
|
|
37
|
+
* re-execs `process.execPath` with `args` and `__WEBJS_DEV_CHILD=1`.
|
|
38
|
+
*/
|
|
39
|
+
export function planDevSupervisor({ isBun, argv, noHot, exists }) {
|
|
40
|
+
// `--no-hot` opts out of the reload supervisor on either runtime: run the dev
|
|
41
|
+
// server in THIS process with no watcher. Degraded dev (a deep-import edit
|
|
42
|
+
// needs a manual restart) but useful under an external process manager or a
|
|
43
|
+
// debugger that wants a single, un-re-exec'd process.
|
|
44
|
+
if (noHot) return { mode: 'inline' };
|
|
45
|
+
|
|
46
|
+
if (isBun) return { mode: 'spawn', args: ['--hot', ...argv] };
|
|
47
|
+
|
|
48
|
+
// Node: re-exec under `node --watch`, watching the project dirs/files that
|
|
49
|
+
// exist. `--watch-preserve-output` keeps prior logs across a restart.
|
|
50
|
+
const watchPaths = [];
|
|
51
|
+
for (const dir of ['app', 'components', 'modules', 'lib', 'actions']) {
|
|
52
|
+
if (exists(dir)) watchPaths.push('--watch-path', dir);
|
|
53
|
+
}
|
|
54
|
+
for (const f of ['middleware.ts', 'middleware.js']) {
|
|
55
|
+
if (exists(f)) watchPaths.push('--watch-path', f);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
mode: 'spawn',
|
|
59
|
+
args: ['--watch', '--watch-preserve-output', ...watchPaths, ...argv],
|
|
60
|
+
};
|
|
61
|
+
}
|
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
|
+
}
|