create-basalt 1.11.0 → 1.12.0

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
@@ -138,9 +138,12 @@ Always:
138
138
 
139
139
  ```
140
140
  my-app/
141
- ├── package.json # scripts: dev, start, test, typecheck, basalt (+ dev:web with --ui); create-basalt as a devDependency
141
+ ├── package.json # scripts: dev, build, start (node dist/src/server.js), start:dev (tsx), test, typecheck, basalt (+ dev:web with --ui); create-basalt as a devDependency
142
142
  ├── .basalt/project.json # scaffold manifest: create-basalt version, options, a hash per generated file — commit it
143
143
  ├── tsconfig.json # strict TypeScript, ESM
144
+ ├── tsconfig.build.json # what `pnpm build` compiles: src/ → dist/ (rootDir ".", so dist/src/server.js)
145
+ ├── Dockerfile # multi-stage: build with the dev toolchain, run dist/src/server.js on plain node as USER node
146
+ ├── .dockerignore # keeps .env, keys, node_modules and dist out of the build context
144
147
  ├── .env.example # MY_APP_PORT, MY_APP_HOST, MY_APP_LOG_LEVEL, NODE_ENV (+ MY_APP_APP_SECRET with auth) + the precedence warning — committed
145
148
  ├── .env # the same values for local development (+ a generated MY_APP_APP_SECRET with auth) — git-ignored, loaded by dev/basalt
146
149
  ├── .gitignore
@@ -150,12 +153,12 @@ my-app/
150
153
  │ ├── env.ts # environment variables validated with Zod (@basaltkit/env), app-prefixed
151
154
  │ ├── app.ts # buildApp() with the chosen plugins
152
155
  │ ├── routes.ts # GET / (friendly index) and GET /health
153
- │ ├── server.ts # startup + clean shutdown on SIGINT/SIGTERM (`pnpm start` — loads no .env)
156
+ │ ├── server.ts # startup + clean shutdown on SIGINT/SIGTERM (`pnpm start` runs it compiled — loads no .env)
154
157
  │ └── dev.ts # `pnpm dev`: loads .env, defaults NODE_ENV=development, starts server.ts
155
158
  └── tests/app.test.ts # smoke test adapted to the options
156
159
  ```
157
160
 
158
- With `--prisma`, adds `prisma/schema.prisma`, `prisma.config.ts`, `src/db.ts`, `prisma/seed.ts` (with tenancy) and the `db:generate` / `db:migrate` / `db:deploy` / `db:seed` scripts. The `basalt` script is `create-basalt --project` with `--no-cli` (so `pnpm basalt update|add|doctor|info` works in every app); with `--cli` it is `tsx bin/basalt.ts`, and `bin/basalt.ts` forwards those four commands to create-basalt before booting the app. With `--ui`, adds the `web/` folder (Vite 8 + React 19 + Tailwind CSS 4 via `@tailwindcss/vite` + shadcn — Tailwind is configured in `web/src/index.css`, no `tailwind.config.js`/PostCSS — with `web/src/api.ts` built on top of `@basaltkit/sdk`; with auth on it includes a login/register screen).
161
+ With `--prisma`, adds `prisma/schema.prisma`, `prisma.config.ts`, `src/db.ts`, `prisma/seed.ts` (with tenancy) and the `db:generate` / `db:migrate` / `db:deploy` / `db:seed` scripts. The client is generated into `generated/prisma` (outside `src/`, so the build never copies it) and imported as `#db/client.js` through a package.json `imports` alias; `@prisma/client-runtime-utils` is a direct dependency (the generated runtime requires it by name), and `pnpm-workspace.yaml` approves the `prisma` / `@prisma/engines` build scripts. The `basalt` script is `create-basalt --project` with `--no-cli` (so `pnpm basalt update|add|doctor|info` works in every app); with `--cli` it is `tsx bin/basalt.ts`, and `bin/basalt.ts` forwards those four commands to create-basalt before booting the app. With `--ui`, adds the `web/` folder (Vite 8 + React 19 + Tailwind CSS 4 via `@tailwindcss/vite` + shadcn — Tailwind is configured in `web/src/index.css`, no `tailwind.config.js`/PostCSS — with `web/src/api.ts` built on top of `@basaltkit/sdk`; with auth on it includes a login/register screen).
159
162
 
160
163
  ### With `--ui`: running the API and frontend
161
164
 
@@ -188,10 +191,13 @@ the next restart.
188
191
  pnpm create basalt my-app --prisma
189
192
  cd my-app && pnpm install # postinstall runs `prisma generate`
190
193
  # .env already points MY_APP_DATABASE_URL at postgres://…@localhost:5432/my_app — start PostgreSQL or edit it
191
- pnpm db:migrate # prisma migrate dev — creates the tables and seeds `demo`
194
+ pnpm db:migrate # prisma migrate dev — creates the tables
195
+ pnpm db:seed # prisma db seed — the `demo` tenant
192
196
  pnpm dev
193
197
  ```
194
198
 
199
+ After a schema change: `pnpm db:migrate --name <change>` writes and applies the migration (without `--name` Prisma prompts for one), then `pnpm db:generate` — Prisma 7's `migrate dev` does not regenerate the client.
200
+
195
201
  **Migrations, never `prisma db push`.** The generated app boots with
196
202
  `assertMigrated: true`, which refuses to start unless the database it reached has
197
203
  the `_prisma_migrations` table — written by `prisma migrate dev` /
@@ -288,6 +294,13 @@ file". Files are patched **only** when they are byte-for-byte a known template
288
294
  (or the manifest records them untouched); a customised one is left alone and the
289
295
  exact snippet to paste is printed.
290
296
 
297
+ An app from before the production path is **offered** what it lacks:
298
+ `tsconfig.build.json`, a `build` script, the `Dockerfile` (pnpm apps) and
299
+ `.dockerignore`, and with Prisma `@prisma/client-runtime-utils`. Existing files
300
+ are never touched and an existing `start` script is never rewritten — the two
301
+ script lines to paste are printed instead. A Prisma client still generated under
302
+ `src/generated` gets the move instructions and no Dockerfile until it is moved.
303
+
291
304
  ### Adding features later
292
305
 
293
306
  ```bash
@@ -330,9 +343,15 @@ default — `DATABASE_URL` with `--prisma`) that is set neither in the environme
330
343
  nor in `.env` — an error, with the fix (`cp .env.example .env`, set it, start
331
344
  PostgreSQL); a missing `.env` next to a `.env.example` (warning); whether the Prisma client is generated and
332
345
  migrations exist (whether they are *applied* needs a database —
333
- `prisma migrate status`); `.mcp.json` when `@basaltkit/ai-mcp` is installed; dev
334
- tooling declared as a runtime dependency; and an outdated `bin/basalt.ts` or
335
- `src/dev.ts`.
346
+ `prisma migrate status`); the `db:seed` script of create-basalt 1.8–1.11
347
+ (`tsx prisma/seed.ts`, which loads no `.env` — a warning with the exact
348
+ replacement, `"db:seed": "prisma db seed"`; `update` prints the same line and
349
+ never rewrites the script); `.mcp.json` when `@basaltkit/ai-mcp` is installed; dev
350
+ tooling declared as a runtime dependency; an outdated `bin/basalt.ts` or
351
+ `src/dev.ts`; and, statically, the production path (a `start` running tsx while
352
+ tsx is a devDependency, no `build` script, `dist/src/server.js` older than
353
+ `src/`, a Prisma client generated under `src/`, a missing
354
+ `@prisma/client-runtime-utils`). It never builds or starts the app.
336
355
 
337
356
  `info` prints create-basalt, Node, OS, package manager, the app's features and
338
357
  the declared/installed versions of the framework and key tools — paste it into
@@ -92,7 +92,11 @@ if (existsSync(envFile)) process.loadEnvFile(envFile)
92
92
  * migrated. Anything else is rethrown untouched.
93
93
  */
94
94
  async function explainBootFailure(error: unknown): Promise<never> {
95
- const failure = (error ?? {}) as { code?: unknown; report?: unknown; message?: unknown; cause?: unknown; errors?: unknown }
95
+ const failure = (error ?? {}) as {
96
+ code?: unknown; report?: unknown; message?: unknown; cause?: unknown; errors?: unknown; details?: unknown
97
+ }
98
+ // @basaltkit/prisma's describeDbError diagnosis (assertMigrated attaches it): the cause and its one-line fix.
99
+ const diagnosis = (failure.details as { diagnosis?: { code?: unknown; cause?: unknown; fix?: unknown } } | undefined)?.diagnosis
96
100
  const message = String(failure.message ?? '')
97
101
  const codes = [failure, failure.cause, ...(Array.isArray(failure.errors) ? failure.errors : [])].map((item) =>
98
102
  String((item as { code?: unknown } | null | undefined)?.code ?? ''),
@@ -116,6 +120,16 @@ async function explainBootFailure(error: unknown): Promise<never> {
116
120
  (existsSync(join(root, 'prisma')) ? ' — for DATABASE_URL, start PostgreSQL and point the URL at it' : '') +
117
121
  '. \`pnpm basalt doctor\` checks the environment; ' + debugHint,
118
122
  ].join('\\n')
123
+ } else if (diagnosis && (diagnosis.code === 'DB_PERMISSION_DENIED' || diagnosis.code === 'DB_NOT_EMPTY_BASELINE')) {
124
+ const database = await databaseInUse()
125
+ explanation = [
126
+ diagnosis.code === 'DB_PERMISSION_DENIED'
127
+ ? 'The app cannot start — the database refused the app database role (permission denied):'
128
+ : 'The app cannot start — the database has tables but no migration history:',
129
+ ' ' + String(diagnosis.cause),
130
+ 'Database: ' + database.target + ' (from ' + database.name + '; credentials never shown).',
131
+ 'Fix: ' + String(diagnosis.fix) + ' ' + debugHint,
132
+ ].join('\\n')
119
133
  } else if (unreachable || failure.code === 'PRISMA_NOT_MIGRATED') {
120
134
  const database = await databaseInUse()
121
135
  const where = 'Database: ' + database.target + ' (from ' + database.name + '; credentials never shown).'
package/dist/cli.js CHANGED
@@ -154,7 +154,10 @@ try {
154
154
  // the app asserts at boot that it reached the MIGRATED database.
155
155
  // .env is already written with a local URL (pnpm dev / pnpm basalt load it).
156
156
  steps.push(`# start PostgreSQL, or edit ${envPrefix(result.options.name)}_DATABASE_URL in .env`);
157
- steps.push(`${pm} run db:migrate${flags.tenancy ? ' # creates the tables and seeds the demo tenant' : ' # creates the tables'}`);
157
+ steps.push(`${pm} run db:migrate # creates the tables`);
158
+ // Prisma 7's `migrate dev` no longer seeds: the demo tenant is its own step.
159
+ if (flags.tenancy)
160
+ steps.push(`${pm} run db:seed # the demo tenant the resolvers expect`);
158
161
  }
159
162
  steps.push(`${pm} run dev${flags.ui ? ' # API on :3000' : ''}`);
160
163
  // `web` is a pnpm workspace member (pnpm-workspace.yaml); `dev:web` runs its
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { mkdir, readdir, writeFile } from 'node:fs/promises';
3
3
  import { dirname, join, resolve } from 'node:path';
4
- import { appTest, appTs, dbTs, devEnvFile, devTs, dockerignore, envExample, envTs, gitignore, mcpJson, basaltBin, packageJson, pnpmWorkspaceYaml, prismaConfigTs, prismaSchema, prismaSeedTs, readme, routesTs, serverTs, tsconfigJson, } from './templates.js';
4
+ import { appTest, appTs, dbTs, devEnvFile, devTs, dockerfile, dockerignore, envExample, envTs, gitignore, mcpJson, basaltBin, packageJson, pnpmWorkspaceYaml, prismaConfigTs, prismaSchema, prismaSeedTs, readme, routesTs, serverTs, tsconfigBuildJson, tsconfigJson, } from './templates.js';
5
5
  import { uiFiles } from './templates-ui.js';
6
6
  import { INSECURE_SECRET } from './project/doctor.js';
7
7
  import { createManifest, MANIFEST_PATH, serializeManifest } from './project/manifest.js';
@@ -43,8 +43,10 @@ export function scaffoldFiles(options) {
43
43
  return {
44
44
  'package.json': packageJson(options),
45
45
  'tsconfig.json': tsconfigJson(options),
46
+ 'tsconfig.build.json': tsconfigBuildJson(),
46
47
  '.env.example': envExample(options),
47
48
  '.gitignore': gitignore(options),
49
+ Dockerfile: dockerfile(),
48
50
  '.dockerignore': dockerignore(),
49
51
  'README.md': readme(options),
50
52
  'pnpm-workspace.yaml': pnpmWorkspaceYaml(options),
@@ -33,6 +33,7 @@
33
33
  export const THIRD_PARTY_VERSIONS = {
34
34
  '@prisma/adapter-pg': '^7.10.0',
35
35
  '@prisma/client': '^7.10.0',
36
+ '@prisma/client-runtime-utils': '^7.10.0',
36
37
  '@tailwindcss/vite': '^4.3.3',
37
38
  '@types/node': '^26.6.2',
38
39
  '@types/pg': '^8.15.6',
@@ -9,7 +9,7 @@ import type { Colors } from './term.js';
9
9
  export type Level = 'ok' | 'info' | 'warn' | 'error';
10
10
  export interface DoctorFinding {
11
11
  level: Level;
12
- /** Short area label: node, pm, deps, env, prisma, mcp, tooling. */
12
+ /** Short area label: node, pm, deps, env, prisma, mcp, tooling, build. */
13
13
  area: string;
14
14
  message: string;
15
15
  }
@@ -1,13 +1,16 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { readdir, readFile } from 'node:fs/promises';
2
+ import { readdir, readFile, stat } from 'node:fs/promises';
3
3
  import { arch, platform } from 'node:os';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { binStatus } from '../bin-template.js';
6
6
  import { devEntryStatus } from '../dev-entry.js';
7
7
  import { alwaysLatest, lookupLatestVersions } from '../latest-versions.js';
8
+ import { PRODUCTION_ENTRY } from '../stubs.js';
8
9
  import { envPrefix } from '../templates.js';
9
10
  import { allDependencies } from './context.js';
10
11
  import { ownVersion } from './manifest.js';
12
+ import { LEGACY_PRISMA_OUTPUT, RUNS_TSX } from './production.js';
13
+ import { legacySeedScriptFix } from './seed-script.js';
11
14
  import { compareVersions, parseSimpleRange, parseVersion, satisfies } from './semver.js';
12
15
  /** Mirrors the placeholder check of `secret()` in @basaltkit/env (src/secret.ts). */
13
16
  export const INSECURE_SECRET = /change.?me|changeme|placeholder|example|secret|password|default|test|xxxx+|0000+/i;
@@ -344,6 +347,9 @@ export async function runDoctor(ctx, options = {}) {
344
347
  push('warn', 'prisma', `No migrations yet — run \`${ctx.pm === 'npm' ? 'npm run' : ctx.pm} db:migrate\` (the app boots with assertMigrated).`);
345
348
  else
346
349
  push('info', 'prisma', `${count} migration(s) on disk; whether they are applied needs the database: \`prisma migrate status\`.`);
350
+ const seedFix = legacySeedScriptFix(ctx.packageJson, await readText(join(dir, 'prisma.config.ts')));
351
+ if (seedFix !== undefined)
352
+ push('warn', 'prisma', seedFix);
347
353
  }
348
354
  // --- MCP -------------------------------------------------------------------------
349
355
  if (deps['@basaltkit/ai-mcp'] !== undefined) {
@@ -389,8 +395,54 @@ export async function runDoctor(ctx, options = {}) {
389
395
  push('info', 'tooling', 'src/dev.ts is customised and does not load .env — see `create-basalt update` for the snippet.');
390
396
  if (!ctx.manifest)
391
397
  push('info', 'tooling', 'No .basalt/project.json (scaffolded before create-basalt 1.10) — features are inferred from package.json.');
398
+ // --- Production path (BK-026) — static: nothing is built or started ----------
399
+ const scripts = ctx.packageJson.scripts ?? {};
400
+ const run = (script) => `${ctx.pm === 'npm' ? 'npm run' : ctx.pm} ${script}`;
401
+ const start = scripts['start'];
402
+ const tsxIsDevOnly = ctx.packageJson.dependencies?.['tsx'] === undefined && ctx.packageJson.devDependencies?.['tsx'] !== undefined;
403
+ if (start !== undefined && RUNS_TSX.test(start) && tsxIsDevOnly) {
404
+ push('warn', 'build', `\`start\` runs tsx ("${start}"), but tsx is only a devDependency — a production install (--prod) has no tsx. Build and run the compiled server: \`npx create-basalt@latest update\` prints the scripts.`);
405
+ }
406
+ if (scripts['build'] === undefined) {
407
+ push('warn', 'build', `No \`build\` script — there is no tested way to run the app on plain node. \`npx create-basalt@latest update\` adds tsconfig.build.json and \`build\`.`);
408
+ }
409
+ else {
410
+ const built = await mtimeOf(join(dir, PRODUCTION_ENTRY));
411
+ const source = await newestMtime(join(dir, 'src'));
412
+ if (built !== undefined && source !== undefined && source > built) {
413
+ push('info', 'build', `${PRODUCTION_ENTRY} is older than src/ — run \`${run('build')}\` before \`${run('start')}\`.`);
414
+ }
415
+ }
416
+ if (schema !== undefined) {
417
+ if (LEGACY_PRISMA_OUTPUT.test(schema)) {
418
+ push('warn', 'build', 'The Prisma client is generated under src/ — `tsc` does not copy its .js files, so the built app cannot load it. `npx create-basalt@latest update` prints how to move it to ./generated.');
419
+ }
420
+ if (deps['@prisma/client'] !== undefined && deps['@prisma/client-runtime-utils'] === undefined) {
421
+ push('warn', 'build', 'The generated Prisma client requires @prisma/client-runtime-utils by name — declare it as a dependency (same range as @prisma/client), or plain `node` cannot load the client under pnpm.');
422
+ }
423
+ }
392
424
  return findings;
393
425
  }
426
+ async function mtimeOf(path) {
427
+ try {
428
+ return (await stat(path)).mtimeMs;
429
+ }
430
+ catch {
431
+ return undefined;
432
+ }
433
+ }
434
+ /** Newest modification time of the .ts files under `dir` (recursive), or undefined. */
435
+ async function newestMtime(dir) {
436
+ let newest;
437
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
438
+ for (const entry of entries) {
439
+ const path = join(dir, entry.name);
440
+ const time = entry.isDirectory() ? await newestMtime(path) : entry.name.endsWith('.ts') ? await mtimeOf(path) : undefined;
441
+ if (time !== undefined && (newest === undefined || time > newest))
442
+ newest = time;
443
+ }
444
+ return newest;
445
+ }
394
446
  export function renderFindings(findings, colors) {
395
447
  const icon = {
396
448
  ok: colors.green('✓'),
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The production path (BK-026) for an app scaffolded before create-basalt
3
+ * shipped one: `create-basalt update` OFFERS what is missing — it is part of
4
+ * the printed plan, applied only on confirmation — and never rewrites what the
5
+ * app already has. In particular an existing `start` script is never changed:
6
+ * switching it from tsx to the compiled server changes how the app is
7
+ * deployed, so that is printed as a manual step.
8
+ */
9
+ export interface ProductionPathPlan {
10
+ /** New files: path → content (only files that do not exist yet). */
11
+ files: Record<string, string>;
12
+ /** package.json after the additions (the input text when nothing changes). */
13
+ packageJsonText: string;
14
+ /** One line per change, for the plan's "Project tooling" section. */
15
+ notes: string[];
16
+ /** Steps printed for the user, never applied. */
17
+ manual: string[];
18
+ }
19
+ /** A Prisma generator that still writes the client under src/ — tsc does not copy its .js files to dist/. */
20
+ export declare const LEGACY_PRISMA_OUTPUT: RegExp;
21
+ /** `start` (or any script) that runs TypeScript through tsx. */
22
+ export declare const RUNS_TSX: RegExp;
23
+ export declare const MANUAL_START_SNIPPET = "In package.json, run the compiled server in production (build first: `pnpm build`):\n \"start\": \"node --enable-source-maps dist/src/server.js\",\n \"start:dev\": \"tsx src/server.ts\"";
24
+ export declare const MANUAL_PRISMA_LAYOUT_SNIPPET: string;
25
+ /**
26
+ * What an existing app lacks of the production path: tsconfig.build.json, a
27
+ * `build` script, the Dockerfile and .dockerignore, and — for a Prisma app —
28
+ * a direct `@prisma/client-runtime-utils` dependency. Pure apart from reading
29
+ * the app's files. The Dockerfile is offered to pnpm apps only, and not
30
+ * while the Prisma client is still generated under src/.
31
+ */
32
+ export declare function planProductionPath(dir: string, packageJsonText: string, pm?: string): Promise<ProductionPathPlan>;
@@ -0,0 +1,74 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { DOCKERFILE, DOCKERIGNORE, PRODUCTION_ENTRY } from '../stubs.js';
4
+ import { BUILD_SCRIPT, PRISMA_IMPORTS, START_DEV_SCRIPT, START_SCRIPT, tsconfigBuildJson } from '../templates.js';
5
+ import { mergePackageJson } from './package-json.js';
6
+ /** A Prisma generator that still writes the client under src/ — tsc does not copy its .js files to dist/. */
7
+ export const LEGACY_PRISMA_OUTPUT = /generator\s+\w+\s*\{[^}]*output\s*=\s*"\.\.\/src\/[^"]*"/;
8
+ /** `start` (or any script) that runs TypeScript through tsx. */
9
+ export const RUNS_TSX = /(^|[\s;&|])(?:npx\s+|pnpm\s+(?:exec\s+)?)?tsx(\s|$)|--import[= ]tsx\b/;
10
+ async function readOptional(path) {
11
+ try {
12
+ return await readFile(path, 'utf8');
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ const exists = async (path) => (await readOptional(path)) !== undefined;
19
+ export const MANUAL_START_SNIPPET = `In package.json, run the compiled server in production (build first: \`pnpm build\`):
20
+ "start": "${START_SCRIPT}",
21
+ "start:dev": "${START_DEV_SCRIPT}"`;
22
+ export const MANUAL_PRISMA_LAYOUT_SNIPPET = `The Prisma client is generated under src/, where \`tsc\` does not copy its .js files — \`node ${PRODUCTION_ENTRY}\` cannot load it. Move it out of src/:
23
+ prisma/schema.prisma output = "../generated/prisma"
24
+ package.json "imports": ${JSON.stringify(PRISMA_IMPORTS)}
25
+ src/db.ts import { PrismaClient } from '#db/client.js'
26
+ .gitignore generated/ (instead of src/generated/)
27
+ then \`pnpm db:generate\` and delete src/generated/. The Dockerfile is offered once this is done.`;
28
+ /**
29
+ * What an existing app lacks of the production path: tsconfig.build.json, a
30
+ * `build` script, the Dockerfile and .dockerignore, and — for a Prisma app —
31
+ * a direct `@prisma/client-runtime-utils` dependency. Pure apart from reading
32
+ * the app's files. The Dockerfile is offered to pnpm apps only, and not
33
+ * while the Prisma client is still generated under src/.
34
+ */
35
+ export async function planProductionPath(dir, packageJsonText, pm = 'pnpm') {
36
+ const pkg = JSON.parse(packageJsonText);
37
+ const files = {};
38
+ const notes = [];
39
+ const manual = [];
40
+ if (!(await exists(join(dir, 'tsconfig.build.json')))) {
41
+ files['tsconfig.build.json'] = tsconfigBuildJson();
42
+ notes.push('tsconfig.build.json: what `build` compiles (src/ → dist/, rootDir `.`)');
43
+ }
44
+ const schema = await readOptional(join(dir, 'prisma', 'schema.prisma'));
45
+ const prismaClient = pkg.dependencies?.['@prisma/client'];
46
+ const merged = mergePackageJson(packageJsonText, {
47
+ scripts: { build: BUILD_SCRIPT },
48
+ ...(schema !== undefined && prismaClient !== undefined
49
+ ? { dependencies: { '@prisma/client-runtime-utils': prismaClient } }
50
+ : {}),
51
+ });
52
+ for (const change of merged.changes) {
53
+ if (change.startsWith('scripts:'))
54
+ notes.push(`package.json: a "build" script (${BUILD_SCRIPT})`);
55
+ else
56
+ notes.push(`package.json: @prisma/client-runtime-utils ${prismaClient} — the generated client requires it by name; plain node cannot reach it as a transitive dependency`);
57
+ }
58
+ const start = pkg.scripts?.['start'];
59
+ if (start !== undefined && RUNS_TSX.test(start))
60
+ manual.push(MANUAL_START_SNIPPET);
61
+ const legacyPrisma = schema !== undefined && LEGACY_PRISMA_OUTPUT.test(schema);
62
+ if (legacyPrisma)
63
+ manual.push(MANUAL_PRISMA_LAYOUT_SNIPPET);
64
+ // The Dockerfile installs with pnpm (frozen pnpm-lock.yaml): offered to pnpm apps only.
65
+ else if (pm === 'pnpm' && !(await exists(join(dir, 'Dockerfile')))) {
66
+ files['Dockerfile'] = DOCKERFILE;
67
+ notes.push(`Dockerfile: build stage + plain-node runtime running ${PRODUCTION_ENTRY} (same as \`basalt publish dockerfile\`)`);
68
+ }
69
+ if (!(await exists(join(dir, '.dockerignore')))) {
70
+ files['.dockerignore'] = DOCKERIGNORE;
71
+ notes.push('.dockerignore: keeps .env, keys and node_modules out of the image');
72
+ }
73
+ return { files, packageJsonText: merged.text, notes, manual };
74
+ }
@@ -0,0 +1,18 @@
1
+ import type { PackageJson } from './package-json.js';
2
+ /**
3
+ * The `db:seed` script create-basalt 1.8.0–1.11.0 generated for `--prisma`
4
+ * apps: `tsx prisma/seed.ts` loads no .env, so on a fresh app it fails in
5
+ * src/env.ts (`ENV_INVALID`: database URL / app secret missing) unless the
6
+ * variables are exported. `prisma db seed` goes through prisma.config.ts,
7
+ * which loads .env and runs the `migrations.seed` command it declares.
8
+ *
9
+ * Only the exact old template is matched: a customised script is the app's.
10
+ * Nothing here rewrites package.json — `doctor` reports it and `update`
11
+ * prints the replacement.
12
+ */
13
+ export declare const LEGACY_DB_SEED: RegExp;
14
+ /**
15
+ * The fix for an old `db:seed` script, as printed lines; undefined when the
16
+ * app does not have the old script (or no prisma.config.ts to load .env).
17
+ */
18
+ export declare function legacySeedScriptFix(pkg: PackageJson, prismaConfig: string | undefined): string | undefined;
@@ -0,0 +1,32 @@
1
+ import { DB_SEED_SCRIPT } from '../templates.js';
2
+ /**
3
+ * The `db:seed` script create-basalt 1.8.0–1.11.0 generated for `--prisma`
4
+ * apps: `tsx prisma/seed.ts` loads no .env, so on a fresh app it fails in
5
+ * src/env.ts (`ENV_INVALID`: database URL / app secret missing) unless the
6
+ * variables are exported. `prisma db seed` goes through prisma.config.ts,
7
+ * which loads .env and runs the `migrations.seed` command it declares.
8
+ *
9
+ * Only the exact old template is matched: a customised script is the app's.
10
+ * Nothing here rewrites package.json — `doctor` reports it and `update`
11
+ * prints the replacement.
12
+ */
13
+ export const LEGACY_DB_SEED = /^\s*tsx\s+(?:\.\/)?prisma\/seed\.ts\s*$/;
14
+ /** Whether prisma.config.ts declares a seed command (`migrations: { seed: … }`). */
15
+ const DECLARES_SEED = /migrations\s*:\s*\{[^}]*\bseed\s*:/;
16
+ /**
17
+ * The fix for an old `db:seed` script, as printed lines; undefined when the
18
+ * app does not have the old script (or no prisma.config.ts to load .env).
19
+ */
20
+ export function legacySeedScriptFix(pkg, prismaConfig) {
21
+ const script = pkg.scripts?.['db:seed'];
22
+ if (script === undefined || prismaConfig === undefined || !LEGACY_DB_SEED.test(script))
23
+ return undefined;
24
+ const lines = [
25
+ `\`db:seed\` runs "${script}", which does not load .env — it fails with ENV_INVALID unless the variables are exported. Run it through Prisma (prisma.config.ts loads .env):`,
26
+ ` package.json "db:seed": "${DB_SEED_SCRIPT}"`,
27
+ ];
28
+ if (!DECLARES_SEED.test(prismaConfig)) {
29
+ lines.push(` prisma.config.ts migrations: { seed: '${script.trim()}' }, (inside defineConfig)`);
30
+ }
31
+ return lines.join('\n');
32
+ }
@@ -4,6 +4,8 @@ import { binStatus, hasProjectCommands, manualBinSnippet, patchBasaltBin } from
4
4
  import { devEntryStatus, MANUAL_DEV_SNIPPET, patchDevEntry, patchEnvExample } from '../dev-entry.js';
5
5
  import { alwaysLatest, lookupLatestVersions, minimumReleaseAgeMinutes, } from '../latest-versions.js';
6
6
  import { BASALT_PROJECT_SCRIPT } from '../templates.js';
7
+ import { planProductionPath } from './production.js';
8
+ import { legacySeedScriptFix } from './seed-script.js';
7
9
  import { extendManifest, MANIFEST_PATH, serializeManifest } from './manifest.js';
8
10
  import { DEPENDENCY_SECTIONS, mergePackageJson, setDependencyRange, } from './package-json.js';
9
11
  import { table } from './term.js';
@@ -228,6 +230,23 @@ export async function planUpdate(ctx, options = {}) {
228
230
  patched['.env.example'] = exampleAfter;
229
231
  toolingNotes.push('.env.example: header now says dev/CLI load .env automatically and `start` does not');
230
232
  }
233
+ // The production path (BK-026): tsconfig.build.json, a `build` script, the
234
+ // Dockerfile — offered when missing, never overwriting what the app has.
235
+ const production = await planProductionPath(ctx.dir, files['package.json']?.after ?? ctx.packageJsonText, ctx.pm);
236
+ if (production.packageJsonText !== (files['package.json']?.after ?? ctx.packageJsonText)) {
237
+ files['package.json'] = { before: ctx.packageJsonText, after: production.packageJsonText };
238
+ }
239
+ for (const [path, content] of Object.entries(production.files)) {
240
+ files[path] = { before: undefined, after: content };
241
+ patched[path] = content;
242
+ }
243
+ toolingNotes.push(...production.notes);
244
+ manual.push(...production.manual);
245
+ // db:seed from create-basalt 1.8–1.11 does not load .env: printed, never
246
+ // rewritten — package.json scripts are the app's.
247
+ const seedFix = legacySeedScriptFix(ctx.packageJson, await readOptional(join(ctx.dir, 'prisma.config.ts')));
248
+ if (seedFix !== undefined)
249
+ manual.push(seedFix);
231
250
  if (ctx.manifest && Object.keys(patched).length > 0) {
232
251
  files[MANIFEST_PATH] = {
233
252
  before: serializeManifest(ctx.manifest),
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The production stubs `basalt publish` copies into an app — the SINGLE source
3
+ * of truth. create-basalt ships the same files in every new scaffold and offers
4
+ * them from `create-basalt update`: its build copies this file verbatim to
5
+ * `packages/create-app/src/stubs.ts` (scripts/sync-stubs.mjs), and a test there
6
+ * fails if the copy drifts. Keep this module free of imports for that reason.
7
+ *
8
+ * The layout these stubs assume is the create-basalt scaffold's (BK-026):
9
+ * `pnpm build` runs `tsc -p tsconfig.build.json` (rootDir `.`, outDir `dist`),
10
+ * so `src/server.ts` compiles to {@link PRODUCTION_ENTRY}; `pnpm start` runs it
11
+ * with plain `node` — no tsx, no TypeScript at runtime. A Prisma client, when
12
+ * there is one, is generated OUTSIDE `src/` (`generated/prisma`) and imported
13
+ * through the package.json `imports` alias `#db/*`, so it resolves the same
14
+ * from `src/` (tsx) and from `dist/src/` (node).
15
+ */
16
+ /** The compiled server entry the Dockerfile runs and the scaffold's `start` script points at. */
17
+ export declare const PRODUCTION_ENTRY = "dist/src/server.js";
18
+ /** Multi-stage production image: build with the dev toolchain, run on plain node as a non-root user. */
19
+ export declare const DOCKERFILE = "# syntax=docker/dockerfile:1\n# Production image for a Basalt app (the create-basalt layout): `pnpm build`\n# compiles src/ to dist/ and the image runs dist/src/server.js on plain\n# node \u2014 no tsx, no TypeScript at runtime.\n#\n# docker build -t my-app .\n# docker run -p 3000:3000 -e APP_SECRET=\u2026 -e DATABASE_URL=\u2026 my-app\n#\n# Configuration comes from the environment (nothing loads .env here, and\n# .dockerignore keeps it out of the image). Database migrations are NOT run\n# by the image: apply them before rolling out (`pnpm db:deploy` in CI).\n#\n# Installs with pnpm from pnpm-lock.yaml. An app on npm/yarn/bun replaces the\n# pnpm install / exec / run / prune lines with its own package manager's.\nARG NODE_VERSION=22\nARG PNPM_VERSION=11\n\nFROM node:${NODE_VERSION}-slim AS build\nARG PNPM_VERSION\n# Non-interactive pnpm: without it `pnpm prune` in a workspace (a --ui app's\n# web/) aborts with ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY.\nENV CI=true\nWORKDIR /app\nRUN npm install -g pnpm@${PNPM_VERSION}\nCOPY . .\n# Full install: the build needs the devDependencies (typescript, prisma).\nRUN pnpm install --frozen-lockfile\n# The Prisma client (when the app has a schema) is generated into ./generated.\nRUN if [ -f prisma/schema.prisma ]; then pnpm exec prisma generate; fi && mkdir -p generated\nRUN pnpm run build\n# Drop the devDependencies (tsx, typescript, prisma CLI, generators, the AI\n# bridge): none of them may ship. --ignore-scripts keeps the app's postinstall\n# (`prisma generate`, a dev tool) from running again.\nRUN pnpm prune --prod --ignore-scripts\n\nFROM node:${NODE_VERSION}-slim AS run\nENV NODE_ENV=production\nENV PORT=3000\n# Path the HEALTHCHECK probes: the scaffold's /health route by default; set\n# /readyz when the app registers healthPlugin().\nENV HEALTHCHECK_PATH=/health\nWORKDIR /app\nCOPY --from=build --chown=node:node /app/package.json ./package.json\nCOPY --from=build --chown=node:node /app/node_modules ./node_modules\nCOPY --from=build --chown=node:node /app/generated ./generated\nCOPY --from=build --chown=node:node /app/dist ./dist\nUSER node\nEXPOSE 3000\nHEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \\\n CMD node -e \"fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + process.env.HEALTHCHECK_PATH).then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))\"\nCMD [\"node\", \"--enable-source-maps\", \"dist/src/server.js\"]\n";
20
+ /**
21
+ * Published with the Dockerfile: its \`COPY . .\` would otherwise bake \`.env\`,
22
+ * private keys and VCS metadata into an image layer. Every secret rule is
23
+ * \`**\/\`-prefixed: .dockerignore patterns are anchored at the context root, so
24
+ * a bare \`.env\` or \`*.pem\` would still let \`prisma/.env\` or
25
+ * \`certs/server.key\` through. \`dist\` is rebuilt inside the image.
26
+ */
27
+ export declare const DOCKERIGNORE = "**/.env\n**/.env.*\n!**/.env.example\n**/.npmrc\n**/.git\n**/node_modules\n**/coverage\n**/*.log\n**/*.pem\n**/*.key\n**/*.p12\n**/*.pfx\n**/.DS_Store\ndist\n";
28
+ /** GitHub Actions CI: install, typecheck, build (the production path), test. */
29
+ export declare const CI_WORKFLOW = "name: ci\non:\n push: { branches: [main] }\n pull_request:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n # Drop `with: version` if package.json declares a packageManager.\n - uses: pnpm/action-setup@v4\n with: { version: 11 }\n - uses: actions/setup-node@v4\n with: { node-version: 22, cache: pnpm }\n - run: pnpm install --frozen-lockfile\n - run: pnpm run typecheck\n - run: pnpm run build\n - run: pnpm run test\n";
30
+ export declare const EDITORCONFIG = "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\nindent_size = 2\ntrim_trailing_whitespace = true\n";
package/dist/stubs.js ADDED
@@ -0,0 +1,123 @@
1
+ // GENERATED by scripts/sync-stubs.mjs from packages/cli/src/stubs.ts (runs before every build) — do not edit.
2
+ /**
3
+ * The production stubs `basalt publish` copies into an app — the SINGLE source
4
+ * of truth. create-basalt ships the same files in every new scaffold and offers
5
+ * them from `create-basalt update`: its build copies this file verbatim to
6
+ * `packages/create-app/src/stubs.ts` (scripts/sync-stubs.mjs), and a test there
7
+ * fails if the copy drifts. Keep this module free of imports for that reason.
8
+ *
9
+ * The layout these stubs assume is the create-basalt scaffold's (BK-026):
10
+ * `pnpm build` runs `tsc -p tsconfig.build.json` (rootDir `.`, outDir `dist`),
11
+ * so `src/server.ts` compiles to {@link PRODUCTION_ENTRY}; `pnpm start` runs it
12
+ * with plain `node` — no tsx, no TypeScript at runtime. A Prisma client, when
13
+ * there is one, is generated OUTSIDE `src/` (`generated/prisma`) and imported
14
+ * through the package.json `imports` alias `#db/*`, so it resolves the same
15
+ * from `src/` (tsx) and from `dist/src/` (node).
16
+ */
17
+ /** The compiled server entry the Dockerfile runs and the scaffold's `start` script points at. */
18
+ export const PRODUCTION_ENTRY = 'dist/src/server.js';
19
+ /** Multi-stage production image: build with the dev toolchain, run on plain node as a non-root user. */
20
+ export const DOCKERFILE = `# syntax=docker/dockerfile:1
21
+ # Production image for a Basalt app (the create-basalt layout): \`pnpm build\`
22
+ # compiles src/ to dist/ and the image runs ${PRODUCTION_ENTRY} on plain
23
+ # node — no tsx, no TypeScript at runtime.
24
+ #
25
+ # docker build -t my-app .
26
+ # docker run -p 3000:3000 -e APP_SECRET=… -e DATABASE_URL=… my-app
27
+ #
28
+ # Configuration comes from the environment (nothing loads .env here, and
29
+ # .dockerignore keeps it out of the image). Database migrations are NOT run
30
+ # by the image: apply them before rolling out (\`pnpm db:deploy\` in CI).
31
+ #
32
+ # Installs with pnpm from pnpm-lock.yaml. An app on npm/yarn/bun replaces the
33
+ # pnpm install / exec / run / prune lines with its own package manager's.
34
+ ARG NODE_VERSION=22
35
+ ARG PNPM_VERSION=11
36
+
37
+ FROM node:\${NODE_VERSION}-slim AS build
38
+ ARG PNPM_VERSION
39
+ # Non-interactive pnpm: without it \`pnpm prune\` in a workspace (a --ui app's
40
+ # web/) aborts with ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY.
41
+ ENV CI=true
42
+ WORKDIR /app
43
+ RUN npm install -g pnpm@\${PNPM_VERSION}
44
+ COPY . .
45
+ # Full install: the build needs the devDependencies (typescript, prisma).
46
+ RUN pnpm install --frozen-lockfile
47
+ # The Prisma client (when the app has a schema) is generated into ./generated.
48
+ RUN if [ -f prisma/schema.prisma ]; then pnpm exec prisma generate; fi && mkdir -p generated
49
+ RUN pnpm run build
50
+ # Drop the devDependencies (tsx, typescript, prisma CLI, generators, the AI
51
+ # bridge): none of them may ship. --ignore-scripts keeps the app's postinstall
52
+ # (\`prisma generate\`, a dev tool) from running again.
53
+ RUN pnpm prune --prod --ignore-scripts
54
+
55
+ FROM node:\${NODE_VERSION}-slim AS run
56
+ ENV NODE_ENV=production
57
+ ENV PORT=3000
58
+ # Path the HEALTHCHECK probes: the scaffold's /health route by default; set
59
+ # /readyz when the app registers healthPlugin().
60
+ ENV HEALTHCHECK_PATH=/health
61
+ WORKDIR /app
62
+ COPY --from=build --chown=node:node /app/package.json ./package.json
63
+ COPY --from=build --chown=node:node /app/node_modules ./node_modules
64
+ COPY --from=build --chown=node:node /app/generated ./generated
65
+ COPY --from=build --chown=node:node /app/dist ./dist
66
+ USER node
67
+ EXPOSE 3000
68
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \\
69
+ CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + process.env.HEALTHCHECK_PATH).then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"
70
+ CMD ["node", "--enable-source-maps", "${PRODUCTION_ENTRY}"]
71
+ `;
72
+ /**
73
+ * Published with the Dockerfile: its \`COPY . .\` would otherwise bake \`.env\`,
74
+ * private keys and VCS metadata into an image layer. Every secret rule is
75
+ * \`**\/\`-prefixed: .dockerignore patterns are anchored at the context root, so
76
+ * a bare \`.env\` or \`*.pem\` would still let \`prisma/.env\` or
77
+ * \`certs/server.key\` through. \`dist\` is rebuilt inside the image.
78
+ */
79
+ export const DOCKERIGNORE = `**/.env
80
+ **/.env.*
81
+ !**/.env.example
82
+ **/.npmrc
83
+ **/.git
84
+ **/node_modules
85
+ **/coverage
86
+ **/*.log
87
+ **/*.pem
88
+ **/*.key
89
+ **/*.p12
90
+ **/*.pfx
91
+ **/.DS_Store
92
+ dist
93
+ `;
94
+ /** GitHub Actions CI: install, typecheck, build (the production path), test. */
95
+ export const CI_WORKFLOW = `name: ci
96
+ on:
97
+ push: { branches: [main] }
98
+ pull_request:
99
+ jobs:
100
+ build:
101
+ runs-on: ubuntu-latest
102
+ steps:
103
+ - uses: actions/checkout@v4
104
+ # Drop \`with: version\` if package.json declares a packageManager.
105
+ - uses: pnpm/action-setup@v4
106
+ with: { version: 11 }
107
+ - uses: actions/setup-node@v4
108
+ with: { node-version: 22, cache: pnpm }
109
+ - run: pnpm install --frozen-lockfile
110
+ - run: pnpm run typecheck
111
+ - run: pnpm run build
112
+ - run: pnpm run test
113
+ `;
114
+ export const EDITORCONFIG = `root = true
115
+
116
+ [*]
117
+ charset = utf-8
118
+ end_of_line = lf
119
+ insert_final_newline = true
120
+ indent_style = space
121
+ indent_size = 2
122
+ trim_trailing_whitespace = true
123
+ `;
@@ -33,6 +33,24 @@ export declare const versionOf: (pkg: string) => string;
33
33
  * unknown package so every emitted range lives in that single table.
34
34
  */
35
35
  export declare const thirdPartyVersionOf: (pkg: string) => string;
36
+ /**
37
+ * The production path (BK-026): `build` compiles src/ with tsconfig.build.json
38
+ * (rootDir `.`, outDir `dist`) and `start` runs the result on plain node — the
39
+ * same entry the Dockerfile's CMD runs. `start:dev` keeps the old tsx start.
40
+ */
41
+ export declare const BUILD_SCRIPT = "tsc -p tsconfig.build.json";
42
+ /** The `db:seed` script of a `--prisma` scaffold with tenancy (prisma.config.ts loads .env, then runs `migrations.seed`). */
43
+ export declare const DB_SEED_SCRIPT = "prisma db seed";
44
+ export declare const START_SCRIPT = "node --enable-source-maps dist/src/server.js";
45
+ export declare const START_DEV_SCRIPT = "tsx src/server.ts";
46
+ /** The `start` script every create-basalt release before the production path wrote. */
47
+ export declare const LEGACY_START_SCRIPT = "tsx src/server.ts";
48
+ /**
49
+ * package.json `imports` alias for the generated Prisma client. It lives in
50
+ * ./generated/prisma — outside src/, so tsc never has to copy its .js files —
51
+ * and `#db/*` resolves to it from src/ (tsx, vitest) and dist/src/ (node) alike.
52
+ */
53
+ export declare const PRISMA_IMPORTS: Readonly<Record<string, string>>;
36
54
  /** The root `basalt` script of an app without `--cli`: create-basalt's project commands. */
37
55
  export declare const BASALT_PROJECT_SCRIPT = "create-basalt --project";
38
56
  /** The root `dev:web` script of a `--ui` app (web/ is a pnpm workspace member). */
@@ -41,6 +59,15 @@ export declare const webDevScript: (options: Pick<ProjectOptions, 'name'>) => st
41
59
  export declare const enforcesMembership: (options: ProjectOptions) => boolean;
42
60
  export declare function packageJson(options: ProjectOptions): string;
43
61
  export declare function tsconfigJson(options: ProjectOptions): string;
62
+ /**
63
+ * `tsconfig.build.json` — what `pnpm build` compiles: src/ only (tests and
64
+ * bin/ stay dev-only), emitted to dist/ with rootDir `.`, so src/server.ts
65
+ * becomes dist/src/server.js. Extends tsconfig.json, so the compiler options
66
+ * (and `pnpm typecheck`) never diverge.
67
+ */
68
+ export declare function tsconfigBuildJson(): string;
69
+ /** The production Dockerfile — the same file `basalt publish dockerfile` writes. */
70
+ export declare function dockerfile(): string;
44
71
  export declare function envTs(options: ProjectOptions): string;
45
72
  /**
46
73
  * An app-specific env-var prefix derived from the project name
@@ -101,8 +128,9 @@ export declare function prismaConfigTs(options: ProjectOptions): string;
101
128
  export declare function dbTs(options: ProjectOptions): string;
102
129
  /**
103
130
  * `prisma/seed.ts` — creates the `demo` tenant the scaffolded resolvers expect
104
- * (`x-tenant-id: demo`, `demo.localhost`). Runs from `pnpm db:seed` and after
105
- * `prisma migrate dev` (prisma.config.ts declares it).
131
+ * (`x-tenant-id: demo`, `demo.localhost`). Runs from `pnpm db:seed`
132
+ * (`prisma db seed`, the command prisma.config.ts declares) — Prisma 7's
133
+ * `migrate dev` does not run it.
106
134
  */
107
135
  export declare function prismaSeedTs(options: ProjectOptions): string;
108
136
  export declare function appTest(options: ProjectOptions): string;
@@ -129,7 +157,9 @@ export declare function mcpJson(_options: ProjectOptions): string;
129
157
  export declare function dockerignore(): string;
130
158
  export declare function gitignore(options: ProjectOptions): string;
131
159
  /**
132
- * pnpm settings: esbuild's build script is required by tsx;
160
+ * pnpm settings: esbuild's build script is required by tsx; with --prisma,
161
+ * the Prisma CLI's build scripts (prisma, @prisma/engines) are approved too — pnpm 11 fails
162
+ * the install on an unapproved build;
133
163
  * msgpackr-extract (optional native accelerator via BullMQ) is declined —
134
164
  * msgpackr falls back to pure JS. With --ui, `web` is a workspace member so
135
165
  * its own dependencies resolve.
package/dist/templates.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { THIRD_PARTY_VERSIONS } from './latest-versions.js';
2
+ import { DOCKERFILE, DOCKERIGNORE, PRODUCTION_ENTRY } from './stubs.js';
2
3
  import { SCAFFOLD_VERSIONS } from './versions.js';
3
4
  export { basaltBin } from './bin-template.js';
4
5
  export { SCAFFOLD_VERSIONS } from './versions.js';
@@ -29,6 +30,24 @@ export const thirdPartyVersionOf = (pkg) => {
29
30
  }
30
31
  return range;
31
32
  };
33
+ /**
34
+ * The production path (BK-026): `build` compiles src/ with tsconfig.build.json
35
+ * (rootDir `.`, outDir `dist`) and `start` runs the result on plain node — the
36
+ * same entry the Dockerfile's CMD runs. `start:dev` keeps the old tsx start.
37
+ */
38
+ export const BUILD_SCRIPT = 'tsc -p tsconfig.build.json';
39
+ /** The `db:seed` script of a `--prisma` scaffold with tenancy (prisma.config.ts loads .env, then runs `migrations.seed`). */
40
+ export const DB_SEED_SCRIPT = 'prisma db seed';
41
+ export const START_SCRIPT = `node --enable-source-maps ${PRODUCTION_ENTRY}`;
42
+ export const START_DEV_SCRIPT = 'tsx src/server.ts';
43
+ /** The `start` script every create-basalt release before the production path wrote. */
44
+ export const LEGACY_START_SCRIPT = 'tsx src/server.ts';
45
+ /**
46
+ * package.json `imports` alias for the generated Prisma client. It lives in
47
+ * ./generated/prisma — outside src/, so tsc never has to copy its .js files —
48
+ * and `#db/*` resolves to it from src/ (tsx, vitest) and dist/src/ (node) alike.
49
+ */
50
+ export const PRISMA_IMPORTS = { '#db/*': './generated/prisma/*' };
32
51
  /** The root `basalt` script of an app without `--cli`: create-basalt's project commands. */
33
52
  export const BASALT_PROJECT_SCRIPT = 'create-basalt --project';
34
53
  /** The root `dev:web` script of a `--ui` app (web/ is a pnpm workspace member). */
@@ -75,7 +94,11 @@ export function packageJson(options) {
75
94
  if (options.prisma) {
76
95
  // Prisma 7 talks to PostgreSQL through a driver adapter, so `pg` is a real
77
96
  // runtime dependency (see src/db.ts); the `prisma` CLI is dev-only.
78
- for (const pkg of ['@prisma/adapter-pg', '@prisma/client', 'pg']) {
97
+ // @prisma/client-runtime-utils is required BY NAME from the generated
98
+ // client's runtime/ (./generated/prisma/runtime/client.js). Under pnpm it is
99
+ // only a transitive dependency of @prisma/client, out of reach of plain
100
+ // `node` — so the app declares it directly (BK-026).
101
+ for (const pkg of ['@prisma/adapter-pg', '@prisma/client', '@prisma/client-runtime-utils', 'pg']) {
79
102
  dependencies[pkg] = thirdPartyVersionOf(pkg);
80
103
  }
81
104
  }
@@ -108,11 +131,15 @@ export function packageJson(options) {
108
131
  version: '0.1.0',
109
132
  private: true,
110
133
  type: 'module',
134
+ ...(options.prisma ? { imports: PRISMA_IMPORTS } : {}),
111
135
  scripts: {
112
136
  // dev opts into NODE_ENV=development (src/dev.ts); start does not, so an
113
137
  // unset NODE_ENV counts as production and secrets are required.
114
138
  dev: 'tsx watch src/dev.ts',
115
- start: 'tsx src/server.ts',
139
+ // Production: compile once, run on plain node (no tsx at runtime).
140
+ build: BUILD_SCRIPT,
141
+ start: START_SCRIPT,
142
+ 'start:dev': START_DEV_SCRIPT,
116
143
  test: 'vitest run',
117
144
  typecheck: 'tsc --noEmit',
118
145
  // With --cli, bin/basalt.ts serves the generators AND hands the project
@@ -132,7 +159,11 @@ export function packageJson(options) {
132
159
  // is deliberately no `db:push` script.
133
160
  'db:migrate': 'prisma migrate dev',
134
161
  'db:deploy': 'prisma migrate deploy',
135
- ...(options.tenancy ? { 'db:seed': 'tsx prisma/seed.ts' } : {}),
162
+ // Through Prisma, not `tsx prisma/seed.ts`: prisma.config.ts loads
163
+ // .env (the seed imports src/env.ts, which needs the database URL),
164
+ // then runs the `migrations.seed` command it declares. Prisma 7's
165
+ // `migrate dev` no longer seeds on its own.
166
+ ...(options.tenancy ? { 'db:seed': DB_SEED_SCRIPT } : {}),
136
167
  }
137
168
  : {}),
138
169
  },
@@ -153,12 +184,34 @@ export function tsconfigJson(options) {
153
184
  noEmit: true,
154
185
  types: ['node'],
155
186
  },
156
- // The generated Prisma client lives under src/generated (git-ignored, and
157
- // re-created by `prisma generate`); the seed script under prisma/ is
158
- // type-checked too.
187
+ // The generated Prisma client lives in ./generated (git-ignored,
188
+ // re-created by `prisma generate`, reached through the `#db/*` import
189
+ // alias); the seed script under prisma/ is type-checked too.
159
190
  include: ['src', 'tests', ...(options.prisma && options.tenancy ? ['prisma/seed.ts'] : [])],
160
191
  }, null, 2)}\n`;
161
192
  }
193
+ /**
194
+ * `tsconfig.build.json` — what `pnpm build` compiles: src/ only (tests and
195
+ * bin/ stay dev-only), emitted to dist/ with rootDir `.`, so src/server.ts
196
+ * becomes dist/src/server.js. Extends tsconfig.json, so the compiler options
197
+ * (and `pnpm typecheck`) never diverge.
198
+ */
199
+ export function tsconfigBuildJson() {
200
+ return `${JSON.stringify({
201
+ extends: './tsconfig.json',
202
+ compilerOptions: {
203
+ noEmit: false,
204
+ rootDir: '.',
205
+ outDir: 'dist',
206
+ sourceMap: true,
207
+ },
208
+ include: ['src'],
209
+ }, null, 2)}\n`;
210
+ }
211
+ /** The production Dockerfile — the same file `basalt publish dockerfile` writes. */
212
+ export function dockerfile() {
213
+ return DOCKERFILE;
214
+ }
162
215
  export function envTs(options) {
163
216
  const prefix = envPrefix(options.name);
164
217
  return `import { defineEnv${options.auth ? ', secret' : ''} } from '@basaltkit/env'
@@ -534,7 +587,15 @@ import { env } from './env.js'
534
587
  const app = await buildApp({
535
588
  logLevel: env.LOG_LEVEL,
536
589
  pretty: env.NODE_ENV === 'development',
537
- }).boot()
590
+ })
591
+ .boot()
592
+ .catch((error: unknown) => {
593
+ // A database failure diagnosed by @basaltkit/prisma (permission denied, not
594
+ // migrated, unreachable…) carries its one-line fix: print it above the stack.
595
+ const diagnosis = (error as { details?: { diagnosis?: { cause?: string; fix?: string } } } | null)?.details?.diagnosis
596
+ if (diagnosis) console.error('Cannot start: ' + diagnosis.cause + '\\nFix: ' + diagnosis.fix)
597
+ throw error
598
+ })
538
599
 
539
600
  const server = app.container.get(FASTIFY)
540
601
  await server.listen({ port: env.PORT, host: env.HOST })
@@ -594,9 +655,17 @@ const PRISMA_MODELS = {
594
655
  }
595
656
 
596
657
  model TenantDomain {
597
- domain String @id
598
- tenantId String
599
- tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
658
+ domain String @id
659
+ tenantId String
660
+ // Custom-domain verification (PrismaDomainStore). A row with a NULL token
661
+ // mirrors \`tenant.domains\` and is written by PrismaTenantSource; a row with a
662
+ // token is a CustomDomains claim, which the source never deletes, and which
663
+ // only resolves a request once \`verified\` is true.
664
+ verificationToken String?
665
+ verified Boolean @default(true)
666
+ createdAt DateTime @default(now())
667
+ verifiedAt DateTime?
668
+ tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
600
669
 
601
670
  @@index([tenantId])
602
671
  @@map("tenant_domains")
@@ -611,9 +680,11 @@ model TenantDomain {
611
680
  }
612
681
 
613
682
  model AuthSession {
614
- id String @id
615
- userId String
616
- expiresAt DateTime
683
+ id String @id
684
+ userId String
685
+ expiresAt DateTime
686
+ /// Last use, for the idle timeout (trackSessionActivity: true).
687
+ lastSeenAt DateTime?
617
688
 
618
689
  @@index([userId])
619
690
  @@map("auth_sessions")
@@ -792,7 +863,9 @@ export function prismaSchema(options) {
792
863
 
793
864
  generator client {
794
865
  provider = "prisma-client-js"
795
- output = "../src/generated/prisma"
866
+ // Outside src/ on purpose: src/db.ts imports it as \`#db/client.js\` (the
867
+ // package.json "imports" alias), which resolves from src/ and dist/src/ alike.
868
+ output = "../generated/prisma"
796
869
  }
797
870
 
798
871
  datasource db {
@@ -840,7 +913,7 @@ try {
840
913
  export default defineConfig({
841
914
  schema: 'prisma/schema.prisma',${options.tenancy
842
915
  ? `
843
- // Runs after \`prisma migrate dev\` / \`prisma migrate reset\`.
916
+ // Run by \`prisma db seed\` (\`pnpm db:seed\`), with the .env loaded above.
844
917
  migrations: { seed: 'tsx prisma/seed.ts' },`
845
918
  : ''}
846
919
  datasource: {
@@ -860,7 +933,9 @@ export default defineConfig({
860
933
  */
861
934
  export function dbTs(options) {
862
935
  return `import { PrismaPg } from '@prisma/adapter-pg'${options.tenancy ? `\nimport { tenancyExtension } from '@basaltkit/prisma'` : ''}
863
- import { PrismaClient } from './generated/prisma/client.js'
936
+ // The generated client (./generated/prisma), through the package.json
937
+ // "imports" alias — the same specifier works under tsx and from dist/.
938
+ import { PrismaClient } from '#db/client.js'
864
939
  import { env } from './env.js'
865
940
 
866
941
  /**
@@ -896,8 +971,9 @@ export const db = prisma
896
971
  }
897
972
  /**
898
973
  * `prisma/seed.ts` — creates the `demo` tenant the scaffolded resolvers expect
899
- * (`x-tenant-id: demo`, `demo.localhost`). Runs from `pnpm db:seed` and after
900
- * `prisma migrate dev` (prisma.config.ts declares it).
974
+ * (`x-tenant-id: demo`, `demo.localhost`). Runs from `pnpm db:seed`
975
+ * (`prisma db seed`, the command prisma.config.ts declares) — Prisma 7's
976
+ * `migrate dev` does not run it.
901
977
  */
902
978
  export function prismaSeedTs(options) {
903
979
  return `import { prismaTenantSource } from '@basaltkit/tenancy-prisma'
@@ -984,7 +1060,8 @@ Included: ${features.join(' · ')}.
984
1060
  pnpm install${options.prisma
985
1061
  ? `
986
1062
  # start PostgreSQL — .env points ${envPrefix(options.name)}_DATABASE_URL at localhost:5432 — then
987
- pnpm db:migrate # create the tables${options.tenancy ? ` (runs the seed too)` : ''}`
1063
+ pnpm db:migrate # create the tables${options.tenancy ? `
1064
+ pnpm db:seed # the 'demo' tenant the resolvers expect` : ''}`
988
1065
  : ''}
989
1066
  pnpm dev # API on http://localhost:3000
990
1067
  pnpm test
@@ -998,11 +1075,11 @@ the models this app needs — the reference models of the \`@basaltkit/*-prisma\
998
1075
  packages it uses, plus your own \`Project\` at the end.
999
1076
 
1000
1077
  \`\`\`bash
1001
- pnpm db:migrate # prisma migrate dev — change the schema, write a migration${options.tenancy
1078
+ pnpm db:migrate --name <change> # prisma migrate dev — writes AND applies a migration (asks for a name without --name)${options.tenancy
1002
1079
  ? `
1003
- pnpm db:seed # the 'demo' tenant the resolvers expect (also run by db:migrate)`
1080
+ pnpm db:seed # prisma db seed — the 'demo' tenant the resolvers expect`
1004
1081
  : ''}
1005
- pnpm db:generate # prisma generate — refresh src/generated/prisma (also runs on install)
1082
+ pnpm db:generate # prisma generate — refresh generated/prisma after a schema change (also runs on install; migrate dev does not)
1006
1083
  pnpm db:deploy # prisma migrate deploy — apply pending migrations in production
1007
1084
  \`\`\`
1008
1085
 
@@ -1044,7 +1121,7 @@ scaffold with \`--cli\` — \`pnpm basalt prisma:sync\` does the merge for you.)
1044
1121
  \`src/env.ts\` validates \`process.env\`. For development, ${options.cli ? '`pnpm dev` and `pnpm basalt` load' : '`pnpm dev` loads'} \`.env\`
1045
1122
  automatically — create-basalt wrote one for you: git-ignored, with local
1046
1123
  values${options.auth ? ' and a generated secret' : ''}. \`.env.example\` is its committed template.
1047
- \`pnpm start\` (production, \`src/server.ts\`) loads **nothing**: there the
1124
+ \`pnpm start\` (production, \`${PRODUCTION_ENTRY}\`) loads **nothing**: there the
1048
1125
  variables come from the real environment (or \`node --env-file=.env\`).
1049
1126
 
1050
1127
  The variables are **app-prefixed**: \`${envPrefix(options.name)}_PORT\`,
@@ -1068,6 +1145,24 @@ To drop the fallback entirely (prefixed names only), write
1068
1145
  \`prefix: { value: '${envPrefix(options.name)}', fallback: false }\`. When in doubt about what
1069
1146
  your shell exports: \`env | grep DATABASE_URL\`.
1070
1147
 
1148
+ ## Build & ship
1149
+
1150
+ \`\`\`bash
1151
+ pnpm build # tsc -p tsconfig.build.json → dist/ (src only)
1152
+ pnpm start # node --enable-source-maps ${PRODUCTION_ENTRY} — no tsx at runtime
1153
+ pnpm start:dev # the server from source with tsx, without building
1154
+ docker build -t ${options.name} . # the Dockerfile: build stage + plain-node runtime
1155
+ \`\`\`
1156
+
1157
+ \`pnpm start\` runs the compiled server, so build first. The \`Dockerfile\` (also
1158
+ what \`basalt publish dockerfile\` writes) installs, ${options.prisma ? 'generates the Prisma client, ' : ''}builds, drops the
1159
+ devDependencies and runs ${PRODUCTION_ENTRY} as the \`node\` user, with a
1160
+ \`HEALTHCHECK\` on \`/health\`. Pass the configuration as environment variables${options.prisma ? ', and apply migrations (\`pnpm db:deploy\`) before rolling out — the image does not run them' : ''}.${options.prisma
1161
+ ? ` The Prisma client is generated into \`generated/prisma\` (outside \`src/\`) and
1162
+ imported as \`#db/client.js\` through the package.json \`imports\` alias, so the same
1163
+ import works under tsx and from \`dist/\`.`
1164
+ : ''}
1165
+
1071
1166
  ## Keeping it up to date
1072
1167
 
1073
1168
  \`\`\`bash
@@ -1177,20 +1272,7 @@ export function mcpJson(_options) {
1177
1272
  * `certs/server.key` through.
1178
1273
  */
1179
1274
  export function dockerignore() {
1180
- return `**/.env
1181
- **/.env.*
1182
- !**/.env.example
1183
- **/.npmrc
1184
- **/.git
1185
- **/node_modules
1186
- **/coverage
1187
- **/*.log
1188
- **/*.pem
1189
- **/*.key
1190
- **/*.p12
1191
- **/*.pfx
1192
- **/.DS_Store
1193
- `;
1275
+ return DOCKERIGNORE;
1194
1276
  }
1195
1277
  export function gitignore(options) {
1196
1278
  return `node_modules/
@@ -1201,12 +1283,14 @@ dist/
1201
1283
  *.log
1202
1284
  ${options.prisma
1203
1285
  ? `# Generated by \`prisma generate\` (runs on install) — never edit or commit it.
1204
- src/generated/
1286
+ generated/
1205
1287
  `
1206
1288
  : ''}`;
1207
1289
  }
1208
1290
  /**
1209
- * pnpm settings: esbuild's build script is required by tsx;
1291
+ * pnpm settings: esbuild's build script is required by tsx; with --prisma,
1292
+ * the Prisma CLI's build scripts (prisma, @prisma/engines) are approved too — pnpm 11 fails
1293
+ * the install on an unapproved build;
1210
1294
  * msgpackr-extract (optional native accelerator via BullMQ) is declined —
1211
1295
  * msgpackr falls back to pure JS. With --ui, `web` is a workspace member so
1212
1296
  * its own dependencies resolve.
@@ -1224,7 +1308,13 @@ export function pnpmWorkspaceYaml(options) {
1224
1308
  `
1225
1309
  : ''}allowBuilds:
1226
1310
  esbuild: true
1227
- msgpackr-extract: false
1311
+ msgpackr-extract: false${options.prisma
1312
+ ? `
1313
+ # The Prisma CLI's install scripts fetch its engines; pnpm 11 refuses to finish
1314
+ # an install (ERR_PNPM_IGNORED_BUILDS) while a dependency's build is unapproved.
1315
+ '@prisma/engines': true
1316
+ prisma: true`
1317
+ : ''}
1228
1318
  # @basaltkit/* releases in lockstep, often within hours — exclude the scope (and
1229
1319
  # create-basalt, which serves \`pnpm basalt update\`) from pnpm's minimumReleaseAge
1230
1320
  # policy so an update is never blocked on a fresh release.
package/dist/versions.js CHANGED
@@ -4,26 +4,26 @@
4
4
  export const SCAFFOLD_VERSIONS = {
5
5
  '@basaltkit/admin': '^2.0.0',
6
6
  '@basaltkit/admin-shadcn': '^1.1.0',
7
- '@basaltkit/ai-mcp': '^0.3.0',
8
- '@basaltkit/auth': '^4.1.0',
9
- '@basaltkit/auth-prisma': '^2.0.0',
10
- '@basaltkit/cli': '^1.3.0',
7
+ '@basaltkit/ai-mcp': '^0.4.0',
8
+ '@basaltkit/auth': '^4.2.0',
9
+ '@basaltkit/auth-prisma': '^2.1.0',
10
+ '@basaltkit/cli': '^1.4.0',
11
11
  '@basaltkit/config': '^1.1.0',
12
- '@basaltkit/core': '^1.5.0',
12
+ '@basaltkit/core': '^1.6.0',
13
13
  '@basaltkit/env': '^3.1.0',
14
14
  '@basaltkit/events': '^1.3.0',
15
- '@basaltkit/fastify': '^2.5.0',
16
- '@basaltkit/generator': '^1.5.0',
15
+ '@basaltkit/fastify': '^2.6.0',
16
+ '@basaltkit/generator': '^1.6.0',
17
17
  '@basaltkit/logger': '^1.3.0',
18
- '@basaltkit/mcp': '^5.0.0',
19
- '@basaltkit/prisma': '^3.0.0',
18
+ '@basaltkit/mcp': '^5.1.0',
19
+ '@basaltkit/prisma': '^3.1.0',
20
20
  '@basaltkit/sdk': '^2.1.0',
21
21
  '@basaltkit/subscriptions': '^5.0.0',
22
22
  '@basaltkit/subscriptions-prisma': '^3.0.0',
23
- '@basaltkit/teams': '^4.0.0',
23
+ '@basaltkit/teams': '^4.1.0',
24
24
  '@basaltkit/teams-prisma': '^1.1.0',
25
- '@basaltkit/tenancy': '^3.1.0',
26
- '@basaltkit/tenancy-prisma': '^2.0.0',
25
+ '@basaltkit/tenancy': '^3.2.0',
26
+ '@basaltkit/tenancy-prisma': '^2.1.0',
27
27
  '@basaltkit/testing': '^2.0.0',
28
- 'create-basalt': '^1.11.0',
28
+ 'create-basalt': '^1.12.0',
29
29
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-basalt",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "engines": {
5
5
  "node": ">=22.5.0"
6
6
  },
@@ -23,33 +23,35 @@
23
23
  "devDependencies": {
24
24
  "@prisma/adapter-pg": "^7.10.0",
25
25
  "@prisma/client": "^7.10.0",
26
+ "@prisma/client-runtime-utils": "^7.10.0",
26
27
  "@types/node": "^26.6.1",
27
28
  "@types/pg": "^8.23.1",
28
29
  "pg": "^8.23.0",
29
30
  "prisma": "^7.10.0",
31
+ "tsx": "^4.23.13",
30
32
  "typescript": "^7.0.2",
31
33
  "vitest": "^5.0.1",
32
34
  "zod": "^4.6.5",
33
- "@basaltkit/auth": "^4.1.0",
34
- "@basaltkit/auth-prisma": "^2.0.0",
35
- "@basaltkit/config": "^1.1.2",
36
- "@basaltkit/cli": "^1.3.0",
37
- "@basaltkit/core": "^1.5.0",
38
- "@basaltkit/env": "^3.1.1",
35
+ "@basaltkit/auth": "^4.2.0",
36
+ "@basaltkit/auth-prisma": "^2.1.0",
37
+ "@basaltkit/cli": "^1.4.0",
38
+ "@basaltkit/core": "^1.6.0",
39
39
  "@basaltkit/events": "^1.3.0",
40
- "@basaltkit/fastify": "^2.5.0",
41
- "@basaltkit/generator": "^1.5.0",
40
+ "@basaltkit/fastify": "^2.6.0",
41
+ "@basaltkit/config": "^1.1.2",
42
+ "@basaltkit/generator": "^1.6.0",
42
43
  "@basaltkit/logger": "^1.3.0",
43
- "@basaltkit/mcp": "^5.0.0",
44
- "@basaltkit/prisma": "^3.0.0",
44
+ "@basaltkit/env": "^3.1.1",
45
+ "@basaltkit/mcp": "^5.1.0",
46
+ "@basaltkit/prisma": "^3.1.0",
45
47
  "@basaltkit/subscriptions": "^5.0.0",
46
48
  "@basaltkit/subscriptions-prisma": "^3.0.0",
47
- "@basaltkit/teams": "^4.0.0",
49
+ "@basaltkit/teams": "^4.1.0",
50
+ "@basaltkit/tenancy": "^3.2.0",
48
51
  "@basaltkit/teams-prisma": "^1.1.0",
49
- "@basaltkit/tenancy": "^3.1.0",
52
+ "@basaltkit/tenancy-prisma": "^2.1.0",
50
53
  "@basaltkit/tsconfig": "^0.24.0",
51
- "@basaltkit/testing": "^2.0.3",
52
- "@basaltkit/tenancy-prisma": "^2.0.0"
54
+ "@basaltkit/testing": "^2.0.3"
53
55
  },
54
56
  "publishConfig": {
55
57
  "access": "public"
@@ -71,8 +73,9 @@
71
73
  "boilerplate"
72
74
  ],
73
75
  "scripts": {
74
- "build": "node scripts/sync-versions.mjs && tsc -p tsconfig.build.json",
76
+ "build": "node scripts/sync-versions.mjs && node scripts/sync-stubs.mjs && tsc -p tsconfig.build.json",
75
77
  "sync-versions": "node scripts/sync-versions.mjs",
78
+ "sync-stubs": "node scripts/sync-stubs.mjs",
76
79
  "test": "vitest run",
77
80
  "typecheck": "tsc --noEmit"
78
81
  }