create-basalt 1.11.0 → 1.13.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` /
@@ -267,7 +273,11 @@ newest release **on the app's current major** (a newer major is listed as
267
273
  `minimumReleaseAge`, read from the project's `pnpm-workspace.yaml` when set) is
268
274
  left for later so the install cannot refuse it. When a framework release
269
275
  declares a peer range the app would not meet (say `zod ^5` while zod is held on
270
- 4), the plan warns before anything is written.
276
+ 4), the plan warns before anything is written. When a framework release
277
+ requires an `@basaltkit/*` peer the app does not list (`@basaltkit/testing` 3
278
+ takes `@basaltkit/mailer`), the plan adds it at its latest version (in
279
+ `dependencies` when a runtime dependency needs it, else `devDependencies`), or
280
+ warns when it cannot; optional peers are never added.
271
281
 
272
282
  Both `package.json` and `web/package.json` are updated, **in place**: only the
273
283
  version strings change — order, indentation and range style (`^`, `~`, exact)
@@ -288,6 +298,13 @@ file". Files are patched **only** when they are byte-for-byte a known template
288
298
  (or the manifest records them untouched); a customised one is left alone and the
289
299
  exact snippet to paste is printed.
290
300
 
301
+ An app from before the production path is **offered** what it lacks:
302
+ `tsconfig.build.json`, a `build` script, the `Dockerfile` (pnpm apps) and
303
+ `.dockerignore`, and with Prisma `@prisma/client-runtime-utils`. Existing files
304
+ are never touched and an existing `start` script is never rewritten — the two
305
+ script lines to paste are printed instead. A Prisma client still generated under
306
+ `src/generated` gets the move instructions and no Dockerfile until it is moved.
307
+
291
308
  ### Adding features later
292
309
 
293
310
  ```bash
@@ -330,9 +347,15 @@ default — `DATABASE_URL` with `--prisma`) that is set neither in the environme
330
347
  nor in `.env` — an error, with the fix (`cp .env.example .env`, set it, start
331
348
  PostgreSQL); a missing `.env` next to a `.env.example` (warning); whether the Prisma client is generated and
332
349
  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`.
350
+ `prisma migrate status`); the `db:seed` script of create-basalt 1.8–1.11
351
+ (`tsx prisma/seed.ts`, which loads no `.env` — a warning with the exact
352
+ replacement, `"db:seed": "prisma db seed"`; `update` prints the same line and
353
+ never rewrites the script); `.mcp.json` when `@basaltkit/ai-mcp` is installed; dev
354
+ tooling declared as a runtime dependency; an outdated `bin/basalt.ts` or
355
+ `src/dev.ts`; and, statically, the production path (a `start` running tsx while
356
+ tsx is a devDependency, no `build` script, `dist/src/server.js` older than
357
+ `src/`, a Prisma client generated under `src/`, a missing
358
+ `@prisma/client-runtime-utils`). It never builds or starts the app.
336
359
 
337
360
  `info` prints create-basalt, Node, OS, package manager, the app's features and
338
361
  the declared/installed versions of the framework and key tools — paste it into
@@ -447,7 +470,7 @@ Detects the manager that invoked the command from `npm_config_user_agent` (or th
447
470
  | --- | --- |
448
471
  | `runProjectCommand(argv, deps)` | Runs `update`/`add`/`doctor`/`info` and returns the exit code; every side effect (output, prompts, package-manager runs, registry fetch, codemods) is injected through `deps` |
449
472
  | `loadProject(dir, pm?)` | Reads an app: package.json (+ `web/`), manifest, detected features and package manager. Throws `NotABasaltAppError` |
450
- | `planUpdate(ctx, { major?, only?, tooling?, registry? })` | The update plan — entries, the full before/after of every file, tooling, warnings, framework majors. Writes nothing |
473
+ | `planUpdate(ctx, { major?, only?, tooling?, registry? })` | The update plan — entries, the full before/after of every file, tooling, warnings, framework majors, required peers it adds (`addedPeers`). Writes nothing |
451
474
  | `planAdd(ctx, feature, { force?, resolveLatest?, registry? })` | The add plan — created/merged/skipped files and manual steps. Writes nothing |
452
475
  | `runDoctor(ctx, options)` | Doctor findings (`{ level: 'ok' \| 'info' \| 'warn' \| 'error', area, message }`) |
453
476
  | `MANIFEST_PATH`, `readManifest(dir)`, `hashContent(text)` | The scaffold manifest |
@@ -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),
@@ -114,6 +114,8 @@ export interface LatestInfo {
114
114
  latest?: string;
115
115
  /** `peerDependencies` declared by that latest version. */
116
116
  peers: Record<string, string>;
117
+ /** The peers its `peerDependenciesMeta` marks optional (absent: none). */
118
+ optionalPeers?: string[];
117
119
  /**
118
120
  * Release age of `latest` against the window: `mature` (provably older, or
119
121
  * not probed), `recent` (inside the window) or `unknown` (could not prove it).
@@ -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',
@@ -152,6 +153,15 @@ function peersOf(body) {
152
153
  }
153
154
  return peers;
154
155
  }
156
+ /** The peers `peerDependenciesMeta` marks `optional: true`. */
157
+ function optionalPeersOf(body) {
158
+ const raw = body?.peerDependenciesMeta;
159
+ if (!raw || typeof raw !== 'object')
160
+ return [];
161
+ return Object.entries(raw)
162
+ .filter(([, meta]) => typeof meta === 'object' && meta !== null && meta.optional === true)
163
+ .map(([name]) => name);
164
+ }
155
165
  async function lookupLatest(fetchImpl, registry, name, signal) {
156
166
  try {
157
167
  const response = await fetchImpl(latestUrl(registry, name), {
@@ -164,7 +174,9 @@ async function lookupLatest(fetchImpl, registry, name, signal) {
164
174
  return 'miss';
165
175
  const body = (await response.json());
166
176
  const version = body?.version;
167
- return typeof version === 'string' && STABLE_SEMVER.test(version) ? { version, peers: peersOf(body) } : 'miss';
177
+ return typeof version === 'string' && STABLE_SEMVER.test(version)
178
+ ? { version, peers: peersOf(body), optionalPeers: optionalPeersOf(body) }
179
+ : 'miss';
168
180
  }
169
181
  catch {
170
182
  // Network error, per-request timeout, or malformed JSON.
@@ -222,7 +234,7 @@ export async function lookupLatestVersions(names, shouldProbe, options = {}) {
222
234
  const age = windowMs === 0 || !shouldProbe(name, found.version)
223
235
  ? 'mature'
224
236
  : await probeAge(fetchImpl, registry, name, windowMs, now, AbortSignal.any([AbortSignal.timeout(timeoutMs), overall.signal]));
225
- results.set(name, { latest: found.version, peers: found.peers, age });
237
+ results.set(name, { latest: found.version, peers: found.peers, optionalPeers: found.optionalPeers, age });
226
238
  }
227
239
  };
228
240
  const pool = Math.max(1, Math.min(options.concurrency ?? CONCURRENCY, names.length));
@@ -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
+ }
@@ -76,6 +76,17 @@ export interface UpdatePlan {
76
76
  to: string;
77
77
  changelog: string;
78
78
  }[];
79
+ /**
80
+ * Required `@basaltkit/*` peers of the framework releases the app ends up on
81
+ * that its package.json did not list (testing 3 takes mailer): added at their
82
+ * latest version, in `dependencies` when a runtime dependency needs them.
83
+ */
84
+ addedPeers: {
85
+ name: string;
86
+ range: string;
87
+ section: 'dependencies' | 'devDependencies';
88
+ requiredBy: string[];
89
+ }[];
79
90
  registry: string;
80
91
  }
81
92
  export interface UpdateOptions {
@@ -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),
@@ -258,6 +277,50 @@ export async function planUpdate(ctx, options = {}) {
258
277
  }
259
278
  }
260
279
  }
280
+ // A required @basaltkit peer the app never declared: pnpm and npm may
281
+ // auto-install it, but yarn PnP, `auto-install-peers=false` or
282
+ // `--legacy-peer-deps` do not, and `doctor` checks installed versions, not
283
+ // the manifest. Add it at its latest version; when that is impossible
284
+ // (registry, release-age window, out of range), say so.
285
+ const declared = new Set(entries.filter((entry) => entry.file === 'package.json').map((entry) => entry.name));
286
+ const missing = new Map();
287
+ for (const entry of entries) {
288
+ if (!entry.framework || (entry.status !== 'update' && entry.status !== 'current') || entry.file !== 'package.json')
289
+ continue;
290
+ const info = results.get(entry.name);
291
+ for (const [peer, wanted] of Object.entries(info?.peers ?? {})) {
292
+ if (!isFramework(peer) || declared.has(peer) || info?.optionalPeers?.includes(peer))
293
+ continue;
294
+ const prior = missing.get(peer);
295
+ const section = entry.section === 'dependencies' || prior?.section === 'dependencies' ? 'dependencies' : 'devDependencies';
296
+ const requiredBy = [...(prior?.requiredBy ?? []), `${entry.name}@${info?.latest}`];
297
+ missing.set(peer, { wanted: prior?.wanted ?? wanted, requiredBy: [...new Set(requiredBy)], section });
298
+ }
299
+ }
300
+ const addedPeers = [];
301
+ if (missing.size > 0) {
302
+ const peerLookup = await lookupLatestVersions([...missing.keys()].sort(), (name) => !exempt(name), {
303
+ ...options.registry,
304
+ minimumReleaseAge: window,
305
+ });
306
+ const additions = { dependencies: {}, devDependencies: {} };
307
+ for (const [peer, need] of missing) {
308
+ const found = peerLookup.results.get(peer);
309
+ if (found?.latest !== undefined && found.age === 'mature' && satisfies(found.latest, need.wanted) === true) {
310
+ const range = `^${found.latest}`;
311
+ additions[need.section][peer] = range;
312
+ addedPeers.push({ name: peer, range, section: need.section, requiredBy: need.requiredBy });
313
+ }
314
+ else {
315
+ warnings.push(`${need.requiredBy.join(', ')} requires ${peer} ${need.wanted}, which package.json does not list — add it (${ctx.pm} add ${need.section === 'devDependencies' ? '-D ' : ''}${peer})`);
316
+ }
317
+ }
318
+ if (addedPeers.length > 0) {
319
+ const merged = mergePackageJson(files['package.json']?.after ?? ctx.packageJsonText, additions);
320
+ if (merged.changes.length > 0)
321
+ files['package.json'] = { before: ctx.packageJsonText, after: merged.text };
322
+ }
323
+ }
261
324
  const failed = entries.filter((entry) => entry.status === 'failed').map((entry) => entry.name);
262
325
  if (failed.length > 0)
263
326
  warnings.push(`The registry did not answer for ${[...new Set(failed)].join(', ')} — left unchanged.`);
@@ -270,7 +333,7 @@ export async function planUpdate(ctx, options = {}) {
270
333
  changelog: changelogUrl(entry.name),
271
334
  }))
272
335
  .filter((major, i, all) => all.findIndex((other) => other.name === major.name) === i);
273
- return { entries, updates, files, tooling: toolingNotes, manual, warnings, majors, registry };
336
+ return { entries, updates, files, tooling: toolingNotes, manual, warnings, majors, addedPeers, registry };
274
337
  }
275
338
  /** The plan as terminal lines: the update table, then notes, tooling, warnings and majors. */
276
339
  export function renderUpdatePlan(plan, colors) {
@@ -309,6 +372,12 @@ export function renderUpdatePlan(plan, colors) {
309
372
  lines.push(` ${major.name} ${major.from} → ${major.to}: ${major.changelog}`);
310
373
  lines.push(` Upgrade notes: ${DOCS_UPGRADING_URL}`);
311
374
  }
375
+ if (plan.addedPeers.length > 0) {
376
+ lines.push('', colors.bold('Required peers the app did not list (added):'));
377
+ for (const peer of plan.addedPeers) {
378
+ lines.push(` ${colors.cyan(peer.name)} ${peer.range} (${peer.section}) — required by ${peer.requiredBy.join(', ')}`);
379
+ }
380
+ }
312
381
  if (plan.tooling.length > 0) {
313
382
  lines.push('', colors.bold('Project tooling:'));
314
383
  for (const note of plan.tooling)
@@ -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";