create-basalt 1.14.1 → 1.15.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
@@ -505,7 +505,7 @@ Outside an interactive terminal (no TTY) the questions are skipped. Always pass
505
505
  Automatic installation failed (network, Node version, etc.). Go into the folder and run `pnpm install` (or the indicated manager) to see the real error.
506
506
 
507
507
  **I started the app and `GET /auth/login` gives a secret error.**
508
- With auth on, `src/env.ts` requires `APP_SECRET` — read as `MY_SAAS_APP_SECRET`, with at least 32 characters (`secret()` supplies a development default only when `NODE_ENV` is explicitly `development`/`test`). For development the scaffold's `.env` carries a generated one; in production export your own (`openssl rand -base64 48`) — `pnpm start` does not read `.env`.
508
+ With auth on, `src/env.ts` requires `APP_SECRET` — read as `MY_SAAS_APP_SECRET`, with at least 32 characters (`secret()` supplies a development default only when `NODE_ENV` is explicitly `development`/`test`). For development the scaffold's `.env` carries a generated one; in production export your own (`openssl rand -base64 48`) or put it in the server's `.env` — `pnpm start` loads `.env` (an exported variable wins) and, with `NODE_ENV` unset, runs as production.
509
509
 
510
510
  **`The app cannot start — the database did not answer`.**
511
511
  PostgreSQL is not running, or `MY_SAAS_DATABASE_URL` points at the wrong host/port (the message shows which, without credentials). Start it (`docker compose up -d`, or your local service) or fix the URL in `.env`; on a fresh database, `pnpm db:migrate`. `pnpm dev` still prints the raw error (the explainer lives in `bin/basalt.ts` only, so `src/dev.ts` stays a few lines); `pnpm basalt routes` gives the explained version.
@@ -2,6 +2,8 @@
2
2
  export declare const PROJECT_COMMANDS_MARKER = "['update', 'add', 'doctor', 'info'].includes(process.argv[2]";
3
3
  /** Marker proving a bin/basalt.ts already has the dev prelude (.env loading, `upgrade`, env errors). */
4
4
  export declare const DEV_PRELUDE_MARKER = "process.loadEnvFile(envFile)";
5
+ /** Marker proving a bin/basalt.ts already runs the generators without booting the app. */
6
+ export declare const GENERATORS_PRELUDE_MARKER = "process.argv[2]?.startsWith('make:')";
5
7
  export declare function basaltBin(): string;
6
8
  /**
7
9
  * Hashes of every bin/basalt.ts an earlier create-basalt release generated
@@ -11,13 +13,16 @@ export declare function basaltBin(): string;
11
13
  export declare const LEGACY_BIN_HASHES: ReadonlySet<string>;
12
14
  export declare const hasProjectCommands: (content: string) => boolean;
13
15
  export declare const hasDevPrelude: (content: string) => boolean;
16
+ export declare const hasGeneratorsPrelude: (content: string) => boolean;
14
17
  /**
15
18
  * Upgrades a template-generated bin/basalt.ts to the current template, step by
16
19
  * step: (1) without the project commands, inserts their prelude after the
17
20
  * shebang and turns the remaining static imports into dynamic ones (so they no
18
21
  * longer run before it); (2) without the dev prelude, inserts it right after
19
- * the project commands and routes the app import through `explainBootFailure`.
20
- * Deterministic for the known templates — for the latest ones (1.9, 1.10) the
22
+ * the project commands and routes the app import through `explainBootFailure`;
23
+ * (3) without the generators prelude, inserts it right before the dev tools
24
+ * are imported for the booted CLI.
25
+ * Deterministic for the known templates — for the latest ones (1.9 to 1.14) the
21
26
  * result is exactly {@link basaltBin}.
22
27
  */
23
28
  export declare function patchBasaltBin(content: string): string;
@@ -174,10 +174,35 @@ async function databaseInUse(): Promise<{ name: string; target: string }> {
174
174
  }
175
175
  }
176
176
  `;
177
+ /**
178
+ * The GENERATORS PRELUDE, after the dev prelude and still before anything of
179
+ * the app is imported: `make:*` only writes source files, so — like `upgrade` —
180
+ * it runs without booting the app. Booting it would run every boot check, and
181
+ * a generator would refuse to work until the database is up and migrated (or
182
+ * every environment variable is set), which is often exactly when the
183
+ * resource is being written. Any other command, and a `make:*` the generator
184
+ * does not know, falls through to the booted CLI below.
185
+ */
186
+ const GENERATORS_PRELUDE = `// The generators (make:*) only write source files: like \`upgrade\`, they run
187
+ // without booting the app, so they work before the database is up or migrated.
188
+ if (process.argv[2]?.startsWith('make:')) {
189
+ const { consoleIo, parseArgv } = await import('@basaltkit/cli')
190
+ const { generatorCommands } = await import('@basaltkit/generator')
191
+ const { command, args, flags } = parseArgv(process.argv.slice(2))
192
+ const make = generatorCommands().find((candidate) => candidate.name === command)
193
+ if (make) {
194
+ // The generators read only io/args/flags — there is no app to hand them.
195
+ const context = { io: consoleIo(), args, flags } as unknown as Parameters<typeof make.handle>[0]
196
+ process.exit((await make.handle(context)) ?? 0)
197
+ }
198
+ }
199
+ `;
177
200
  /** Marker proving a bin/basalt.ts already delegates the project commands. */
178
201
  export const PROJECT_COMMANDS_MARKER = "['update', 'add', 'doctor', 'info'].includes(process.argv[2]";
179
202
  /** Marker proving a bin/basalt.ts already has the dev prelude (.env loading, `upgrade`, env errors). */
180
203
  export const DEV_PRELUDE_MARKER = 'process.loadEnvFile(envFile)';
204
+ /** Marker proving a bin/basalt.ts already runs the generators without booting the app. */
205
+ export const GENERATORS_PRELUDE_MARKER = "process.argv[2]?.startsWith('make:')";
181
206
  const APP_IMPORT = "await import('../src/app.js')";
182
207
  const APP_IMPORT_EXPLAINED = `${APP_IMPORT}.catch(explainBootFailure)`;
183
208
  const RUN_CLI = 'process.exit(await runCli({ app })';
@@ -185,6 +210,7 @@ export function basaltBin() {
185
210
  return `#!/usr/bin/env node
186
211
  ${PRELUDE}
187
212
  ${DEV_PRELUDE}
213
+ ${GENERATORS_PRELUDE}
188
214
  // The dev tools are imported dynamically, after the project commands above.
189
215
  const { runCli } = await import('@basaltkit/cli')
190
216
  const { generatorCommands } = await import('@basaltkit/generator')
@@ -228,16 +254,24 @@ export const LEGACY_BIN_HASHES = new Set([
228
254
  // 2026-10-01 (create-basalt 1.10): project commands (update/add/doctor/info)
229
255
  // delegated before boot — but no .env loading and `upgrade` booted the app.
230
256
  'sha256-9091e137a5de39929d0e70b2fd973386b9cc1c2f996e130989a22c795b9d0893',
257
+ // 2026-10-01 (create-basalt 1.11): the dev prelude — but make:* booted the
258
+ // app, so a generator needed a migrated database.
259
+ 'sha256-bd0b6c9c40089a18c2c2f1a70953044d6a74a329d38a72903b494b1f2265a114',
260
+ // 2026-10-08: database diagnoses in explainBootFailure — make:* still booted.
261
+ 'sha256-abea8cbfdb41a302cabad7929617f7fc2fe822345aa8db6ff777e923eb0fc96b',
231
262
  ]);
232
263
  export const hasProjectCommands = (content) => content.includes(PROJECT_COMMANDS_MARKER);
233
264
  export const hasDevPrelude = (content) => content.includes(DEV_PRELUDE_MARKER);
265
+ export const hasGeneratorsPrelude = (content) => content.includes(GENERATORS_PRELUDE_MARKER);
234
266
  /**
235
267
  * Upgrades a template-generated bin/basalt.ts to the current template, step by
236
268
  * step: (1) without the project commands, inserts their prelude after the
237
269
  * shebang and turns the remaining static imports into dynamic ones (so they no
238
270
  * longer run before it); (2) without the dev prelude, inserts it right after
239
- * the project commands and routes the app import through `explainBootFailure`.
240
- * Deterministic for the known templates — for the latest ones (1.9, 1.10) the
271
+ * the project commands and routes the app import through `explainBootFailure`;
272
+ * (3) without the generators prelude, inserts it right before the dev tools
273
+ * are imported for the booted CLI.
274
+ * Deterministic for the known templates — for the latest ones (1.9 to 1.14) the
241
275
  * result is exactly {@link basaltBin}.
242
276
  */
243
277
  export function patchBasaltBin(content) {
@@ -246,6 +280,8 @@ export function patchBasaltBin(content) {
246
280
  text = addProjectCommands(text);
247
281
  if (!hasDevPrelude(text))
248
282
  text = addDevPrelude(text);
283
+ if (!hasGeneratorsPrelude(text))
284
+ text = addGeneratorsPrelude(text);
249
285
  return text;
250
286
  }
251
287
  function addProjectCommands(content) {
@@ -273,11 +309,19 @@ function addDevPrelude(content) {
273
309
  .replace(`${RUN_CLI})\n`, `${RUN_CLI}.catch(explainBootFailure))\n`);
274
310
  return `${content.slice(0, end)}${DEV_PRELUDE}\n${rest}`;
275
311
  }
312
+ /** Inserts the generators prelude right before the dev tools are imported for the booted CLI. */
313
+ function addGeneratorsPrelude(content) {
314
+ const anchor = /^(?:\/\/ The dev tools are imported dynamically[^\n]*\n)?const \{ runCli \} = await import\('@basaltkit\/cli'\)$/m.exec(content);
315
+ // Without the template's dynamic CLI import, the file is not ours to rearrange.
316
+ if (!anchor || !hasDevPrelude(content))
317
+ return content;
318
+ return `${content.slice(0, anchor.index)}${GENERATORS_PRELUDE}\n${content.slice(anchor.index)}`;
319
+ }
276
320
  /** Where a bin/basalt.ts stands: already current, an untouched old template, or user code. */
277
321
  export function binStatus(content, manifestHash) {
278
322
  if (content === undefined)
279
323
  return 'absent';
280
- if (hasProjectCommands(content) && hasDevPrelude(content))
324
+ if (hasProjectCommands(content) && hasDevPrelude(content) && hasGeneratorsPrelude(content))
281
325
  return 'current';
282
326
  const hash = hashContent(content);
283
327
  return LEGACY_BIN_HASHES.has(hash) || hash === manifestHash ? 'patchable' : 'modified';
@@ -300,6 +344,13 @@ and explains env errors — and change the app import to
300
344
  \`const { buildApp } = ${APP_IMPORT_EXPLAINED}\`:
301
345
 
302
346
  ${DEV_PRELUDE}`);
347
+ }
348
+ if (!hasGeneratorsPrelude(content)) {
349
+ steps.push(`${steps.length > 0 ? 'Then paste' : 'Paste'} this after the dev prelude (before the dev tools are imported for the
350
+ booted CLI) — it runs the make:* generators without booting the app, so they
351
+ work before the database is up or migrated:
352
+
353
+ ${GENERATORS_PRELUDE}`);
303
354
  }
304
355
  return steps.join('\n');
305
356
  }
@@ -16,8 +16,9 @@ export declare const patchDevEntry: () => string;
16
16
  /** What to paste by hand into a customised src/dev.ts. */
17
17
  export declare const MANUAL_DEV_SNIPPET = "Paste this at the top of src/dev.ts, BEFORE the server is imported (keep that\nimport dynamic \u2014 `await import('./server.js')` \u2014 so the variables are set first):\n\nimport { existsSync } from 'node:fs'\nimport { fileURLToPath } from 'node:url'\n\n// Development loads .env (when present) like `node --env-file=.env`: a variable\n// already exported in your shell still wins, the file only fills in what is missing.\nconst envFile = fileURLToPath(new URL('../.env', import.meta.url))\nif (existsSync(envFile)) process.loadEnvFile(envFile)\n";
18
18
  /**
19
- * `.env.example` with the old "nothing loads this file" lead swapped for the
20
- * current one; undefined when the file does not start with the old lead (a
19
+ * `.env.example` with an earlier lead ("nothing loads this file", or "`pnpm
20
+ * start` does NOT") swapped for the current one; undefined when the file does
21
+ * not start with one of them (a
21
22
  * newer or hand-written file is left alone — it is only comments).
22
23
  */
23
24
  export declare function patchEnvExample(content: string): string | undefined;
package/dist/dev-entry.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { hashContent } from './project/manifest.js';
2
- import { devTs, ENV_EXAMPLE_LEAD, LEGACY_ENV_EXAMPLE_LEAD } from './templates.js';
2
+ import { devTs, ENV_EXAMPLE_LEAD, LEGACY_ENV_EXAMPLE_LEAD, PREVIOUS_ENV_EXAMPLE_LEAD } from './templates.js';
3
3
  /**
4
4
  * `src/dev.ts` (the `pnpm dev` entrypoint) and the `.env.example` header across
5
5
  * create-basalt releases. Up to 1.10, nothing loaded `.env`: `pnpm dev` booted
@@ -37,13 +37,15 @@ const envFile = fileURLToPath(new URL('../.env', import.meta.url))
37
37
  if (existsSync(envFile)) process.loadEnvFile(envFile)
38
38
  `;
39
39
  /**
40
- * `.env.example` with the old "nothing loads this file" lead swapped for the
41
- * current one; undefined when the file does not start with the old lead (a
40
+ * `.env.example` with an earlier lead ("nothing loads this file", or "`pnpm
41
+ * start` does NOT") swapped for the current one; undefined when the file does
42
+ * not start with one of them (a
42
43
  * newer or hand-written file is left alone — it is only comments).
43
44
  */
44
45
  export function patchEnvExample(content) {
45
46
  const normalized = content.replace(/\r\n/g, '\n');
46
- if (!normalized.startsWith(`${LEGACY_ENV_EXAMPLE_LEAD}\n`))
47
+ const lead = [LEGACY_ENV_EXAMPLE_LEAD, PREVIOUS_ENV_EXAMPLE_LEAD].find((old) => normalized.startsWith(`${old}\n`));
48
+ if (lead === undefined)
47
49
  return undefined;
48
- return `${ENV_EXAMPLE_LEAD}${normalized.slice(LEGACY_ENV_EXAMPLE_LEAD.length)}`;
50
+ return `${ENV_EXAMPLE_LEAD}${normalized.slice(lead.length)}`;
49
51
  }
@@ -6,7 +6,7 @@ import { binStatus } from '../bin-template.js';
6
6
  import { devEntryStatus } from '../dev-entry.js';
7
7
  import { alwaysLatest, lookupLatestVersions } from '../latest-versions.js';
8
8
  import { PRODUCTION_ENTRY } from '../stubs.js';
9
- import { envPrefix } from '../templates.js';
9
+ import { envPrefix, PREVIOUS_START_SCRIPT, START_SCRIPT } from '../templates.js';
10
10
  import { allDependencies } from './context.js';
11
11
  import { ownVersion } from './manifest.js';
12
12
  import { LEGACY_PRISMA_OUTPUT, RUNS_TSX } from './production.js';
@@ -382,10 +382,10 @@ export async function runDoctor(ctx, options = {}) {
382
382
  const bin = await readText(join(dir, 'bin', 'basalt.ts'));
383
383
  const status = binStatus(bin, ctx.manifest?.files['bin/basalt.ts']);
384
384
  if (status === 'patchable') {
385
- push('warn', 'tooling', 'bin/basalt.ts is an older template (no .env loading / pre-boot project commands) — `npx create-basalt@latest update` patches it.');
385
+ push('warn', 'tooling', 'bin/basalt.ts is an older template (it lacks some of: .env loading, pre-boot project commands, make:* without booting the app) — `npx create-basalt@latest update` patches it.');
386
386
  }
387
387
  if (status === 'modified') {
388
- push('info', 'tooling', 'bin/basalt.ts is customised and lacks the current preludes (project commands, .env loading, pre-boot `upgrade`) — see `create-basalt update` for the snippet.');
388
+ push('info', 'tooling', 'bin/basalt.ts is customised and lacks the current preludes (project commands, .env loading, pre-boot `upgrade` and make:*) — see `create-basalt update` for the snippet.');
389
389
  }
390
390
  const dev = await readText(join(dir, 'src', 'dev.ts'));
391
391
  const devStatus = devEntryStatus(dev, ctx.manifest?.files['src/dev.ts']);
@@ -403,6 +403,12 @@ export async function runDoctor(ctx, options = {}) {
403
403
  if (start !== undefined && RUNS_TSX.test(start) && tsxIsDevOnly) {
404
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
405
  }
406
+ if (start === PREVIOUS_START_SCRIPT) {
407
+ push('info', 'build', `\`start\` loads no .env ("${start}") — fine where the platform exports the environment; \`npx create-basalt@latest update\` switches it to \`${START_SCRIPT}\`.`);
408
+ }
409
+ if (start !== undefined && /--env-file/.test(start) && /^\s*NODE_ENV\s*=\s*["']?development["']?\s*$/m.test((await readText(join(dir, '.env'))) ?? '')) {
410
+ push('warn', 'build', '.env sets NODE_ENV=development and `start` loads .env: a production start boots in development, with the dev secret defaults. Remove the line — `dev` and `basalt` set it themselves.');
411
+ }
406
412
  if (scripts['build'] === undefined) {
407
413
  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
414
  }
@@ -2,9 +2,12 @@
2
2
  * The production path (BK-026) for an app scaffolded before create-basalt
3
3
  * shipped one: `create-basalt update` OFFERS what is missing — it is part of
4
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:
5
+ * app already has. In particular a customised `start` script is never changed:
6
6
  * switching it from tsx to the compiled server changes how the app is
7
- * deployed, so that is printed as a manual step.
7
+ * deployed, so that is printed as a manual step. Only the exact `start` an
8
+ * earlier create-basalt wrote ({@link PREVIOUS_START_SCRIPT}) is offered the
9
+ * `.env` loading — under @basaltkit/deploy's pm2 runtime before 0.10 it booted
10
+ * without its secrets.
8
11
  */
9
12
  export interface ProductionPathPlan {
10
13
  /** New files: path → content (only files that do not exist yet). */
@@ -20,7 +23,8 @@ export interface ProductionPathPlan {
20
23
  export declare const LEGACY_PRISMA_OUTPUT: RegExp;
21
24
  /** `start` (or any script) that runs TypeScript through tsx. */
22
25
  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\"";
26
+ export declare const MANUAL_START_SNIPPET = "In package.json, run the compiled server in production (build first: `pnpm build`):\n \"start\": \"node --env-file-if-exists=.env --enable-source-maps dist/src/server.js\",\n \"start:dev\": \"tsx src/server.ts\"";
27
+ export declare const MANUAL_NODE_ENV_SNIPPET = "Remove NODE_ENV=development from .env: `pnpm dev` and `pnpm basalt` already set it, and\n`pnpm start` now loads .env \u2014 with that line it would boot in development, accepting the dev secret\ndefaults instead of requiring the real ones.";
24
28
  export declare const MANUAL_PRISMA_LAYOUT_SNIPPET: string;
25
29
  /**
26
30
  * What an existing app lacks of the production path: tsconfig.build.json, a
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { DOCKERFILE, DOCKERIGNORE, PRODUCTION_ENTRY } from '../stubs.js';
4
- import { BUILD_SCRIPT, PRISMA_IMPORTS, START_DEV_SCRIPT, START_SCRIPT, tsconfigBuildJson } from '../templates.js';
4
+ import { BUILD_SCRIPT, ENGINES_NODE, PREVIOUS_START_SCRIPT, PRISMA_IMPORTS, START_DEV_SCRIPT, START_SCRIPT, tsconfigBuildJson } from '../templates.js';
5
5
  import { mergePackageJson } from './package-json.js';
6
6
  /** A Prisma generator that still writes the client under src/ — tsc does not copy its .js files to dist/. */
7
7
  export const LEGACY_PRISMA_OUTPUT = /generator\s+\w+\s*\{[^}]*output\s*=\s*"\.\.\/src\/[^"]*"/;
@@ -19,6 +19,9 @@ const exists = async (path) => (await readOptional(path)) !== undefined;
19
19
  export const MANUAL_START_SNIPPET = `In package.json, run the compiled server in production (build first: \`pnpm build\`):
20
20
  "start": "${START_SCRIPT}",
21
21
  "start:dev": "${START_DEV_SCRIPT}"`;
22
+ export const MANUAL_NODE_ENV_SNIPPET = `Remove NODE_ENV=development from .env: \`pnpm dev\` and \`pnpm basalt\` already set it, and
23
+ \`pnpm start\` now loads .env — with that line it would boot in development, accepting the dev secret
24
+ defaults instead of requiring the real ones.`;
22
25
  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
26
  prisma/schema.prisma output = "../generated/prisma"
24
27
  package.json "imports": ${JSON.stringify(PRISMA_IMPORTS)}
@@ -45,12 +48,15 @@ export async function planProductionPath(dir, packageJsonText, pm = 'pnpm') {
45
48
  const prismaClient = pkg.dependencies?.['@prisma/client'];
46
49
  const merged = mergePackageJson(packageJsonText, {
47
50
  scripts: { build: BUILD_SCRIPT },
51
+ replaceScripts: pkg.scripts?.['start'] === PREVIOUS_START_SCRIPT ? { start: { from: PREVIOUS_START_SCRIPT, to: START_SCRIPT } } : {},
48
52
  ...(schema !== undefined && prismaClient !== undefined
49
53
  ? { dependencies: { '@prisma/client-runtime-utils': prismaClient } }
50
54
  : {}),
51
55
  });
52
56
  for (const change of merged.changes) {
53
- if (change.startsWith('scripts:'))
57
+ if (change.startsWith('scripts: start:'))
58
+ notes.push(`package.json: "start" loads .env when it exists (${START_SCRIPT}); needs Node ${ENGINES_NODE}`);
59
+ else if (change.startsWith('scripts:'))
54
60
  notes.push(`package.json: a "build" script (${BUILD_SCRIPT})`);
55
61
  else
56
62
  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`);
@@ -58,6 +64,12 @@ export async function planProductionPath(dir, packageJsonText, pm = 'pnpm') {
58
64
  const start = pkg.scripts?.['start'];
59
65
  if (start !== undefined && RUNS_TSX.test(start))
60
66
  manual.push(MANUAL_START_SNIPPET);
67
+ // `start` now loads .env: a NODE_ENV=development left there (create-basalt ≤ 1.14
68
+ // wrote one) would boot `pnpm start` in development, with the dev secret defaults.
69
+ const dotEnv = await readOptional(join(dir, '.env'));
70
+ if (dotEnv !== undefined && /^\s*NODE_ENV\s*=\s*["']?development["']?\s*$/m.test(dotEnv)) {
71
+ manual.push(MANUAL_NODE_ENV_SNIPPET);
72
+ }
61
73
  const legacyPrisma = schema !== undefined && LEGACY_PRISMA_OUTPUT.test(schema);
62
74
  if (legacyPrisma)
63
75
  manual.push(MANUAL_PRISMA_LAYOUT_SNIPPET);
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { binStatus, hasProjectCommands, manualBinSnippet, patchBasaltBin } from '../bin-template.js';
3
+ import { binStatus, hasDevPrelude, hasProjectCommands, manualBinSnippet, patchBasaltBin } from '../bin-template.js';
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';
@@ -207,7 +207,7 @@ export async function planUpdate(ctx, options = {}) {
207
207
  const after = patchBasaltBin(bin);
208
208
  files['bin/basalt.ts'] = { before: bin, after };
209
209
  patched['bin/basalt.ts'] = after;
210
- toolingNotes.push(`bin/basalt.ts: unmodified template from an earlier release — ${hasProjectCommands(bin) ? '' : 'gains update/add/doctor/info; '}loads .env for development, runs \`upgrade\` without booting the app, explains env errors`);
210
+ toolingNotes.push(`bin/basalt.ts: unmodified template from an earlier release — ${hasProjectCommands(bin) ? '' : 'gains update/add/doctor/info; '}${hasDevPrelude(bin) ? '' : 'loads .env for development, runs \`upgrade\` without booting the app, explains env errors; '}runs make:* without booting the app (no database needed to generate code)`);
211
211
  }
212
212
  else if (status === 'modified' && bin !== undefined) {
213
213
  manual.push(`bin/basalt.ts was customised, so it was not patched. To bring it up to date:\n${manualBinSnippet(bin)}`);
@@ -228,7 +228,7 @@ export async function planUpdate(ctx, options = {}) {
228
228
  if (example !== undefined && exampleAfter !== undefined) {
229
229
  files['.env.example'] = { before: example, after: exampleAfter };
230
230
  patched['.env.example'] = exampleAfter;
231
- toolingNotes.push('.env.example: header now says dev/CLI load .env automatically and `start` does not');
231
+ toolingNotes.push('.env.example: header now says `dev`, `basalt` and `start` load .env (an exported variable wins)');
232
232
  }
233
233
  // The production path (BK-026): tsconfig.build.json, a `build` script, the
234
234
  // Dockerfile — offered when missing, never overwriting what the app has.
@@ -41,7 +41,16 @@ export declare const thirdPartyVersionOf: (pkg: string) => string;
41
41
  export declare const BUILD_SCRIPT = "tsc -p tsconfig.build.json";
42
42
  /** The `db:seed` script of a `--prisma` scaffold with tenancy (prisma.config.ts loads .env, then runs `migrations.seed`). */
43
43
  export declare const DB_SEED_SCRIPT = "prisma db seed";
44
- export declare const START_SCRIPT = "node --enable-source-maps dist/src/server.js";
44
+ /**
45
+ * `start` loads `.env` when it exists (`--env-file-if-exists`): an exported
46
+ * variable still wins, and the file sets no NODE_ENV, so an unset NODE_ENV
47
+ * stays production — secrets required (fail-closed).
48
+ */
49
+ export declare const START_SCRIPT = "node --env-file-if-exists=.env --enable-source-maps dist/src/server.js";
50
+ /** The `start` create-basalt wrote before it loaded `.env`: `update` replaces it when untouched. */
51
+ export declare const PREVIOUS_START_SCRIPT = "node --enable-source-maps dist/src/server.js";
52
+ /** `--env-file-if-exists` (the `start` script) needs Node 22.9. */
53
+ export declare const ENGINES_NODE = ">=22.9.0";
45
54
  export declare const START_DEV_SCRIPT = "tsx src/server.ts";
46
55
  /** The `start` script every create-basalt release before the production path wrote. */
47
56
  export declare const LEGACY_START_SCRIPT = "tsx src/server.ts";
@@ -80,7 +89,9 @@ export declare function envPrefix(name: string): string;
80
89
  * {@link LEGACY_ENV_EXAMPLE_LEAD} ("nothing loads this file"); `update`
81
90
  * swaps that for this one in existing apps.
82
91
  */
83
- export declare const ENV_EXAMPLE_LEAD = "# Development: copy this file to .env \u2014 `pnpm dev` and `pnpm basalt` load it\n# automatically (a new app already has one). `pnpm start` (production) does\n# NOT: there the variables come from the environment (or `node --env-file`).";
92
+ export declare const ENV_EXAMPLE_LEAD = "# Copy this file to .env (a new app already has one). `pnpm dev`, `pnpm basalt`\n# and `pnpm start` load it when it exists; a variable exported in your shell\n# (or by the platform: Docker, systemd, the deploy) always wins.";
93
+ /** The lead create-basalt 1.11–1.14 wrote (\`pnpm start\` did not load .env); \`update\` swaps it. */
94
+ export declare const PREVIOUS_ENV_EXAMPLE_LEAD = "# Development: copy this file to .env \u2014 `pnpm dev` and `pnpm basalt` load it\n# automatically (a new app already has one). `pnpm start` (production) does\n# NOT: there the variables come from the environment (or `node --env-file`).";
84
95
  export declare const LEGACY_ENV_EXAMPLE_LEAD = "# Nothing loads this file for you: copy it to .env and start with\n# `node --env-file=.env` / `tsx --env-file=.env`, or export the variables.";
85
96
  export declare function envExample(options: ProjectOptions): string;
86
97
  /**
@@ -99,9 +110,9 @@ export declare function serverTs(): string;
99
110
  * semantics — an exported variable wins, the file only fills in what is
100
111
  * missing — and opts into NODE_ENV=development (dev secret defaults, pretty
101
112
  * logs) unless NODE_ENV is already set, then starts the server. Kept out of
102
- * server.ts on purpose: `pnpm start` runs server.ts directly, where nothing
103
- * loads `.env` (production configuration comes from the real environment) and
104
- * an unset NODE_ENV counts as production, so secrets are required (fail-closed).
113
+ * server.ts on purpose: `pnpm start` runs the compiled server, which loads
114
+ * `.env` through `--env-file-if-exists` but never opts into development — an
115
+ * unset NODE_ENV counts as production, so secrets are required (fail-closed).
105
116
  */
106
117
  export declare function devTs(): string;
107
118
  /**
package/dist/templates.js CHANGED
@@ -38,7 +38,16 @@ export const thirdPartyVersionOf = (pkg) => {
38
38
  export const BUILD_SCRIPT = 'tsc -p tsconfig.build.json';
39
39
  /** The `db:seed` script of a `--prisma` scaffold with tenancy (prisma.config.ts loads .env, then runs `migrations.seed`). */
40
40
  export const DB_SEED_SCRIPT = 'prisma db seed';
41
- export const START_SCRIPT = `node --enable-source-maps ${PRODUCTION_ENTRY}`;
41
+ /**
42
+ * `start` loads `.env` when it exists (`--env-file-if-exists`): an exported
43
+ * variable still wins, and the file sets no NODE_ENV, so an unset NODE_ENV
44
+ * stays production — secrets required (fail-closed).
45
+ */
46
+ export const START_SCRIPT = `node --env-file-if-exists=.env --enable-source-maps ${PRODUCTION_ENTRY}`;
47
+ /** The `start` create-basalt wrote before it loaded `.env`: `update` replaces it when untouched. */
48
+ export const PREVIOUS_START_SCRIPT = `node --enable-source-maps ${PRODUCTION_ENTRY}`;
49
+ /** `--env-file-if-exists` (the `start` script) needs Node 22.9. */
50
+ export const ENGINES_NODE = '>=22.9.0';
42
51
  export const START_DEV_SCRIPT = 'tsx src/server.ts';
43
52
  /** The `start` script every create-basalt release before the production path wrote. */
44
53
  export const LEGACY_START_SCRIPT = 'tsx src/server.ts';
@@ -138,10 +147,12 @@ export function packageJson(options) {
138
147
  version: '0.1.0',
139
148
  private: true,
140
149
  type: 'module',
150
+ engines: { node: ENGINES_NODE },
141
151
  ...(options.prisma ? { imports: PRISMA_IMPORTS } : {}),
142
152
  scripts: {
143
- // dev opts into NODE_ENV=development (src/dev.ts); start does not, so an
144
- // unset NODE_ENV counts as production and secrets are required.
153
+ // dev opts into NODE_ENV=development (src/dev.ts); start loads .env but
154
+ // never opts in, so an unset NODE_ENV counts as production and secrets
155
+ // are required.
145
156
  dev: 'tsx watch src/dev.ts',
146
157
  // Production: compile once, run on plain node (no tsx at runtime).
147
158
  build: BUILD_SCRIPT,
@@ -287,7 +298,11 @@ export function envPrefix(name) {
287
298
  * {@link LEGACY_ENV_EXAMPLE_LEAD} ("nothing loads this file"); `update`
288
299
  * swaps that for this one in existing apps.
289
300
  */
290
- export const ENV_EXAMPLE_LEAD = `# Development: copy this file to .env — \`pnpm dev\` and \`pnpm basalt\` load it
301
+ export const ENV_EXAMPLE_LEAD = `# Copy this file to .env (a new app already has one). \`pnpm dev\`, \`pnpm basalt\`
302
+ # and \`pnpm start\` load it when it exists; a variable exported in your shell
303
+ # (or by the platform: Docker, systemd, the deploy) always wins.`;
304
+ /** The lead create-basalt 1.11–1.14 wrote (\`pnpm start\` did not load .env); \`update\` swaps it. */
305
+ export const PREVIOUS_ENV_EXAMPLE_LEAD = `# Development: copy this file to .env — \`pnpm dev\` and \`pnpm basalt\` load it
291
306
  # automatically (a new app already has one). \`pnpm start\` (production) does
292
307
  # NOT: there the variables come from the environment (or \`node --env-file\`).`;
293
308
  export const LEGACY_ENV_EXAMPLE_LEAD = `# Nothing loads this file for you: copy it to .env and start with
@@ -303,8 +318,9 @@ export function envExample(options) {
303
318
  ${prefix}_PORT=3000
304
319
  ${prefix}_HOST=0.0.0.0
305
320
  ${prefix}_LOG_LEVEL=info
306
- # NODE_ENV is a Node-wide convention and is never prefixed.
307
- NODE_ENV=development
321
+ # NODE_ENV is a Node-wide convention and is never prefixed. Leave it out of this
322
+ # file: \`pnpm dev\` and \`pnpm basalt\` set development, and \`pnpm start\`, with it
323
+ # unset, runs as production — where the secrets below are required.
308
324
  ${options.auth
309
325
  ? `# Required unless NODE_ENV is development/test — \`pnpm start\` refuses to boot
310
326
  # without it. Generate a strong one: openssl rand -base64 48
@@ -331,8 +347,8 @@ export function devEnvFile(options, appSecret) {
331
347
  const example = envExample(options);
332
348
  const body = example.slice(example.indexOf(`${prefix}_PORT=`));
333
349
  return `# Local development settings, generated by create-basalt from .env.example.
334
- # Git-ignored: never commit it. \`pnpm dev\` and \`pnpm basalt\` load it (a
335
- # variable exported in your shell wins); \`pnpm start\` (production) does not.
350
+ # Git-ignored: never commit it. \`pnpm dev\`, \`pnpm basalt\` and \`pnpm start\` load it
351
+ # (a variable exported in your shell wins).
336
352
  ${body.replace(`# ${prefix}_APP_SECRET=\n`, `${prefix}_APP_SECRET=${appSecret}\n`)}`;
337
353
  }
338
354
  /** A PostgreSQL-safe database name derived from the project name. */
@@ -367,9 +383,13 @@ export function appTs(options) {
367
383
  if (options.prisma) {
368
384
  imports.push(`import { prismaPlugin } from '@basaltkit/prisma'`);
369
385
  imports.push(`import { db, prisma } from './db.js'`);
370
- plugins.push(`prismaPlugin({
386
+ plugins.push(`prismaPlugin({${options.tenancy
387
+ ? `
371
388
  // \`db\` is the tenant-scoped client (src/db.ts): db<PrismaClient>() in a
372
- // handler returns it, already filtered by the request's tenant.
389
+ // handler returns it, already filtered by the request's tenant.`
390
+ : `
391
+ // \`db\` is the application client (src/db.ts): db<PrismaClient>() in a
392
+ // handler returns it. No tenancy, so queries are not filtered by tenant.`}
373
393
  client: db,
374
394
  // Fail the BOOT when this database is not the migrated one — a shell that
375
395
  // exported another project's DATABASE_URL, a typo in the database name.
@@ -621,20 +641,19 @@ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
621
641
  * semantics — an exported variable wins, the file only fills in what is
622
642
  * missing — and opts into NODE_ENV=development (dev secret defaults, pretty
623
643
  * logs) unless NODE_ENV is already set, then starts the server. Kept out of
624
- * server.ts on purpose: `pnpm start` runs server.ts directly, where nothing
625
- * loads `.env` (production configuration comes from the real environment) and
626
- * an unset NODE_ENV counts as production, so secrets are required (fail-closed).
644
+ * server.ts on purpose: `pnpm start` runs the compiled server, which loads
645
+ * `.env` through `--env-file-if-exists` but never opts into development — an
646
+ * unset NODE_ENV counts as production, so secrets are required (fail-closed).
627
647
  */
628
648
  export function devTs() {
629
- return `// Development entrypoint (\`pnpm dev\`). \`pnpm start\` runs server.ts directly,
649
+ return `// Development entrypoint (\`pnpm dev\`). \`pnpm start\` runs the compiled server,
630
650
  // where an unset NODE_ENV counts as production: secrets are then required.
631
651
  import { existsSync } from 'node:fs'
632
652
  import { fileURLToPath } from 'node:url'
633
653
 
634
654
  // Development loads .env (when present) like \`node --env-file=.env\`: a variable
635
655
  // already exported in your shell still wins, the file only fills in what is
636
- // missing. \`pnpm start\` (production) never loads it — there the configuration
637
- // comes from the real environment.
656
+ // missing. Only development opts into NODE_ENV=development, below.
638
657
  const envFile = fileURLToPath(new URL('../.env', import.meta.url))
639
658
  if (existsSync(envFile)) process.loadEnvFile(envFile)
640
659
 
@@ -1136,8 +1155,10 @@ scaffold with \`--cli\` — \`pnpm basalt prisma:sync\` does the merge for you.)
1136
1155
  \`src/env.ts\` validates \`process.env\`. For development, ${options.cli ? '`pnpm dev` and `pnpm basalt` load' : '`pnpm dev` loads'} \`.env\`
1137
1156
  automatically — create-basalt wrote one for you: git-ignored, with local
1138
1157
  values${options.auth ? ' and a generated secret' : ''}. \`.env.example\` is its committed template.
1139
- \`pnpm start\` (production, \`${PRODUCTION_ENTRY}\`) loads **nothing**: there the
1140
- variables come from the real environment (or \`node --env-file=.env\`).
1158
+ \`pnpm start\` (production, \`${PRODUCTION_ENTRY}\`) loads \`.env\` too when it exists
1159
+ (\`node --env-file-if-exists\`), but the file sets no \`NODE_ENV\`: unset, it counts
1160
+ as production, so the secrets are required. Variables the platform exports —
1161
+ Docker, systemd, \`@basaltkit/deploy\` — always win over the file.
1141
1162
 
1142
1163
  The variables are **app-prefixed**: \`${envPrefix(options.name)}_PORT\`,
1143
1164
  \`${envPrefix(options.name)}_HOST\`, \`${envPrefix(options.name)}_LOG_LEVEL\`${options.auth ? `, \`${envPrefix(options.name)}_APP_SECRET\`` : ''}${options.prisma ? `, \`${envPrefix(options.name)}_DATABASE_URL\`` : ''}. \`NODE_ENV\` is never prefixed.
@@ -1164,7 +1185,7 @@ your shell exports: \`env | grep DATABASE_URL\`.
1164
1185
 
1165
1186
  \`\`\`bash
1166
1187
  pnpm build # tsc -p tsconfig.build.json → dist/ (src only)
1167
- pnpm start # node --enable-source-maps ${PRODUCTION_ENTRY} — no tsx at runtime
1188
+ pnpm start # ${START_SCRIPT} — no tsx at runtime
1168
1189
  pnpm start:dev # the server from source with tsx, without building
1169
1190
  docker build -t ${options.name} . # the Dockerfile: build stage + plain-node runtime
1170
1191
  \`\`\`
package/dist/versions.js CHANGED
@@ -11,15 +11,15 @@ export const SCAFFOLD_VERSIONS = {
11
11
  '@basaltkit/cli': '^1.4.2',
12
12
  '@basaltkit/config': '^1.1.2',
13
13
  '@basaltkit/core': '^1.6.0',
14
- '@basaltkit/deploy': '^0.5.0',
14
+ '@basaltkit/deploy': '^0.10.0',
15
15
  '@basaltkit/env': '^3.1.1',
16
16
  '@basaltkit/events': '^1.3.0',
17
17
  '@basaltkit/fastify': '^3.0.0',
18
- '@basaltkit/generator': '^1.6.0',
18
+ '@basaltkit/generator': '^1.7.0',
19
19
  '@basaltkit/logger': '^1.3.0',
20
20
  '@basaltkit/mailer': '^2.1.0',
21
21
  '@basaltkit/mcp': '^6.0.0',
22
- '@basaltkit/prisma': '^4.0.0',
22
+ '@basaltkit/prisma': '^4.1.1',
23
23
  '@basaltkit/sdk': '^2.1.0',
24
24
  '@basaltkit/subscriptions': '^6.0.0',
25
25
  '@basaltkit/subscriptions-prisma': '^4.0.0',
@@ -28,5 +28,5 @@ export const SCAFFOLD_VERSIONS = {
28
28
  '@basaltkit/tenancy': '^4.0.0',
29
29
  '@basaltkit/tenancy-prisma': '^3.0.0',
30
30
  '@basaltkit/testing': '^3.0.0',
31
- 'create-basalt': '^1.14.1',
31
+ 'create-basalt': '^1.15.0',
32
32
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-basalt",
3
- "version": "1.14.1",
3
+ "version": "1.15.0",
4
4
  "engines": {
5
5
  "node": ">=22.5.0"
6
6
  },
@@ -21,38 +21,38 @@
21
21
  "dist"
22
22
  ],
23
23
  "devDependencies": {
24
- "@prisma/adapter-pg": "^7.10.0",
25
- "@prisma/client": "^7.10.0",
26
- "@prisma/client-runtime-utils": "^7.10.0",
27
- "@types/node": "^26.6.1",
24
+ "@prisma/adapter-pg": "^7.10.1",
25
+ "@prisma/client": "^7.10.1",
26
+ "@prisma/client-runtime-utils": "^7.10.1",
27
+ "@types/node": "^26.6.5",
28
28
  "@types/pg": "^8.23.1",
29
- "pg": "^8.23.0",
30
- "prisma": "^7.10.0",
31
- "tsx": "^4.23.13",
29
+ "pg": "^8.23.1",
30
+ "prisma": "^7.10.1",
31
+ "tsx": "^4.23.15",
32
32
  "typescript": "^7.0.2",
33
- "vitest": "^5.0.1",
34
- "zod": "^4.6.5",
33
+ "vitest": "^5.0.5",
34
+ "zod": "^4.6.7",
35
35
  "@basaltkit/auth": "^5.0.0",
36
36
  "@basaltkit/auth-prisma": "^3.0.0",
37
- "@basaltkit/config": "^1.1.2",
38
37
  "@basaltkit/cli": "^1.4.2",
38
+ "@basaltkit/config": "^1.1.2",
39
39
  "@basaltkit/core": "^1.6.0",
40
- "@basaltkit/deploy": "^0.5.0",
41
40
  "@basaltkit/events": "^1.3.0",
41
+ "@basaltkit/env": "^3.1.1",
42
+ "@basaltkit/deploy": "^0.10.0",
42
43
  "@basaltkit/fastify": "^3.0.0",
43
- "@basaltkit/generator": "^1.6.0",
44
+ "@basaltkit/prisma": "^4.1.1",
44
45
  "@basaltkit/logger": "^1.3.0",
45
- "@basaltkit/mcp": "^6.0.0",
46
- "@basaltkit/prisma": "^4.0.0",
46
+ "@basaltkit/generator": "^1.7.0",
47
47
  "@basaltkit/subscriptions": "^6.0.0",
48
+ "@basaltkit/mcp": "^6.0.0",
48
49
  "@basaltkit/subscriptions-prisma": "^4.0.0",
49
- "@basaltkit/teams": "^4.1.1",
50
50
  "@basaltkit/teams-prisma": "^1.1.0",
51
51
  "@basaltkit/tenancy": "^4.0.0",
52
52
  "@basaltkit/tenancy-prisma": "^3.0.0",
53
- "@basaltkit/env": "^3.1.1",
54
53
  "@basaltkit/tsconfig": "^0.24.0",
55
- "@basaltkit/testing": "^3.0.0"
54
+ "@basaltkit/testing": "^3.0.0",
55
+ "@basaltkit/teams": "^4.1.1"
56
56
  },
57
57
  "publishConfig": {
58
58
  "access": "public"