create-basalt 1.14.2 → 1.16.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.
@@ -4,6 +4,8 @@ export declare const PROJECT_COMMANDS_MARKER = "['update', 'add', 'doctor', 'inf
4
4
  export declare const DEV_PRELUDE_MARKER = "process.loadEnvFile(envFile)";
5
5
  /** Marker proving a bin/basalt.ts already runs the generators without booting the app. */
6
6
  export declare const GENERATORS_PRELUDE_MARKER = "process.argv[2]?.startsWith('make:')";
7
+ /** Marker proving a bin/basalt.ts already starts `dev` without booting the app. */
8
+ export declare const DEV_SERVER_PRELUDE_MARKER = "if (process.argv[2] === 'dev')";
7
9
  export declare function basaltBin(): string;
8
10
  /**
9
11
  * Hashes of every bin/basalt.ts an earlier create-basalt release generated
@@ -14,6 +16,7 @@ export declare const LEGACY_BIN_HASHES: ReadonlySet<string>;
14
16
  export declare const hasProjectCommands: (content: string) => boolean;
15
17
  export declare const hasDevPrelude: (content: string) => boolean;
16
18
  export declare const hasGeneratorsPrelude: (content: string) => boolean;
19
+ export declare const hasDevServerPrelude: (content: string) => boolean;
17
20
  /**
18
21
  * Upgrades a template-generated bin/basalt.ts to the current template, step by
19
22
  * step: (1) without the project commands, inserts their prelude after the
@@ -21,8 +24,9 @@ export declare const hasGeneratorsPrelude: (content: string) => boolean;
21
24
  * longer run before it); (2) without the dev prelude, inserts it right after
22
25
  * the project commands and routes the app import through `explainBootFailure`;
23
26
  * (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
27
+ * are imported for the booted CLI; (4) without the dev server prelude, inserts
28
+ * it at the same place, after the generators prelude.
29
+ * Deterministic for the known templates — for the latest ones (1.9 to 1.15) the
26
30
  * result is exactly {@link basaltBin}.
27
31
  */
28
32
  export declare function patchBasaltBin(content: string): string;
@@ -197,12 +197,31 @@ if (process.argv[2]?.startsWith('make:')) {
197
197
  }
198
198
  }
199
199
  `;
200
+ /**
201
+ * The DEV SERVER PRELUDE, after the generators prelude and still before
202
+ * anything of the app is imported: `basalt dev` must start while the app
203
+ * cannot boot (a variable missing, the database down, code mid-edit) — that
204
+ * is when a watcher is needed. `runDev` boots the app only for the route
205
+ * table, prints why when it does not, and starts the watcher either way.
206
+ */
207
+ const DEV_SERVER_PRELUDE = `// \`dev\` starts the watcher even while the app cannot boot (database down, a
208
+ // variable missing, code mid-edit): the route table is printed when it boots.
209
+ if (process.argv[2] === 'dev') {
210
+ const { runDev } = await import('@basaltkit/cli')
211
+ process.env['NODE_ENV'] ??= 'development'
212
+ process.exit(await runDev({
213
+ loadApp: async () => (await import('../src/app.js')).buildApp({ logLevel: 'silent' }),
214
+ }))
215
+ }
216
+ `;
200
217
  /** Marker proving a bin/basalt.ts already delegates the project commands. */
201
218
  export const PROJECT_COMMANDS_MARKER = "['update', 'add', 'doctor', 'info'].includes(process.argv[2]";
202
219
  /** Marker proving a bin/basalt.ts already has the dev prelude (.env loading, `upgrade`, env errors). */
203
220
  export const DEV_PRELUDE_MARKER = 'process.loadEnvFile(envFile)';
204
221
  /** Marker proving a bin/basalt.ts already runs the generators without booting the app. */
205
222
  export const GENERATORS_PRELUDE_MARKER = "process.argv[2]?.startsWith('make:')";
223
+ /** Marker proving a bin/basalt.ts already starts `dev` without booting the app. */
224
+ export const DEV_SERVER_PRELUDE_MARKER = "if (process.argv[2] === 'dev')";
206
225
  const APP_IMPORT = "await import('../src/app.js')";
207
226
  const APP_IMPORT_EXPLAINED = `${APP_IMPORT}.catch(explainBootFailure)`;
208
227
  const RUN_CLI = 'process.exit(await runCli({ app })';
@@ -211,6 +230,7 @@ export function basaltBin() {
211
230
  ${PRELUDE}
212
231
  ${DEV_PRELUDE}
213
232
  ${GENERATORS_PRELUDE}
233
+ ${DEV_SERVER_PRELUDE}
214
234
  // The dev tools are imported dynamically, after the project commands above.
215
235
  const { runCli } = await import('@basaltkit/cli')
216
236
  const { generatorCommands } = await import('@basaltkit/generator')
@@ -259,10 +279,14 @@ export const LEGACY_BIN_HASHES = new Set([
259
279
  'sha256-bd0b6c9c40089a18c2c2f1a70953044d6a74a329d38a72903b494b1f2265a114',
260
280
  // 2026-10-08: database diagnoses in explainBootFailure — make:* still booted.
261
281
  'sha256-abea8cbfdb41a302cabad7929617f7fc2fe822345aa8db6ff777e923eb0fc96b',
282
+ // 2026-10-25 (create-basalt ≤ 1.15): make:* without a boot — but `dev`
283
+ // booted the app first, so it did not start while the app could not boot.
284
+ 'sha256-5c2cf3f431399b961de890af457c7be5f9a0cb810cd96d6bdf080ac942e33f1b',
262
285
  ]);
263
286
  export const hasProjectCommands = (content) => content.includes(PROJECT_COMMANDS_MARKER);
264
287
  export const hasDevPrelude = (content) => content.includes(DEV_PRELUDE_MARKER);
265
288
  export const hasGeneratorsPrelude = (content) => content.includes(GENERATORS_PRELUDE_MARKER);
289
+ export const hasDevServerPrelude = (content) => content.includes(DEV_SERVER_PRELUDE_MARKER);
266
290
  /**
267
291
  * Upgrades a template-generated bin/basalt.ts to the current template, step by
268
292
  * step: (1) without the project commands, inserts their prelude after the
@@ -270,8 +294,9 @@ export const hasGeneratorsPrelude = (content) => content.includes(GENERATORS_PRE
270
294
  * longer run before it); (2) without the dev prelude, inserts it right after
271
295
  * the project commands and routes the app import through `explainBootFailure`;
272
296
  * (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
297
+ * are imported for the booted CLI; (4) without the dev server prelude, inserts
298
+ * it at the same place, after the generators prelude.
299
+ * Deterministic for the known templates — for the latest ones (1.9 to 1.15) the
275
300
  * result is exactly {@link basaltBin}.
276
301
  */
277
302
  export function patchBasaltBin(content) {
@@ -282,6 +307,8 @@ export function patchBasaltBin(content) {
282
307
  text = addDevPrelude(text);
283
308
  if (!hasGeneratorsPrelude(text))
284
309
  text = addGeneratorsPrelude(text);
310
+ if (!hasDevServerPrelude(text))
311
+ text = addDevServerPrelude(text);
285
312
  return text;
286
313
  }
287
314
  function addProjectCommands(content) {
@@ -317,11 +344,19 @@ function addGeneratorsPrelude(content) {
317
344
  return content;
318
345
  return `${content.slice(0, anchor.index)}${GENERATORS_PRELUDE}\n${content.slice(anchor.index)}`;
319
346
  }
347
+ /** Inserts the dev server prelude right before the dev tools are imported for the booted CLI (after the generators prelude). */
348
+ function addDevServerPrelude(content) {
349
+ const anchor = /^(?:\/\/ The dev tools are imported dynamically[^\n]*\n)?const \{ runCli \} = await import\('@basaltkit\/cli'\)$/m.exec(content);
350
+ // Without the template's dynamic CLI import, the file is not ours to rearrange.
351
+ if (!anchor || !hasGeneratorsPrelude(content))
352
+ return content;
353
+ return `${content.slice(0, anchor.index)}${DEV_SERVER_PRELUDE}\n${content.slice(anchor.index)}`;
354
+ }
320
355
  /** Where a bin/basalt.ts stands: already current, an untouched old template, or user code. */
321
356
  export function binStatus(content, manifestHash) {
322
357
  if (content === undefined)
323
358
  return 'absent';
324
- if (hasProjectCommands(content) && hasDevPrelude(content) && hasGeneratorsPrelude(content))
359
+ if (hasProjectCommands(content) && hasDevPrelude(content) && hasGeneratorsPrelude(content) && hasDevServerPrelude(content))
325
360
  return 'current';
326
361
  const hash = hashContent(content);
327
362
  return LEGACY_BIN_HASHES.has(hash) || hash === manifestHash ? 'patchable' : 'modified';
@@ -351,6 +386,13 @@ booted CLI) — it runs the make:* generators without booting the app, so they
351
386
  work before the database is up or migrated:
352
387
 
353
388
  ${GENERATORS_PRELUDE}`);
389
+ }
390
+ if (!hasDevServerPrelude(content)) {
391
+ steps.push(`${steps.length > 0 ? 'Then paste' : 'Paste'} this after the generators prelude (before the dev tools are imported for
392
+ the booted CLI) — \`basalt dev\` then starts its watcher even while the app cannot
393
+ boot (database down, a variable missing):
394
+
395
+ ${DEV_SERVER_PRELUDE}`);
354
396
  }
355
397
  return steps.join('\n');
356
398
  }
@@ -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,7 +382,7 @@ 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 (it lacks some of: .env loading, pre-boot project commands, make:* without booting the app) — `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:* and dev without booting the app) — `npx create-basalt@latest update` patches it.');
386
386
  }
387
387
  if (status === 'modified') {
388
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.');
@@ -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, hasDevPrelude, hasProjectCommands, manualBinSnippet, patchBasaltBin } from '../bin-template.js';
3
+ import { binStatus, hasDevPrelude, hasGeneratorsPrelude, 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; '}${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)`);
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; '}${hasGeneratorsPrelude(bin) ? '' : 'runs make:* without booting the app (no database needed to generate code); '}starts \`basalt dev\` even while the app cannot boot (the route table when it does)`);
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. */
@@ -625,20 +641,19 @@ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
625
641
  * semantics — an exported variable wins, the file only fills in what is
626
642
  * missing — and opts into NODE_ENV=development (dev secret defaults, pretty
627
643
  * logs) unless NODE_ENV is already set, then starts the server. Kept out of
628
- * server.ts on purpose: `pnpm start` runs server.ts directly, where nothing
629
- * loads `.env` (production configuration comes from the real environment) and
630
- * 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).
631
647
  */
632
648
  export function devTs() {
633
- return `// Development entrypoint (\`pnpm dev\`). \`pnpm start\` runs server.ts directly,
649
+ return `// Development entrypoint (\`pnpm dev\`). \`pnpm start\` runs the compiled server,
634
650
  // where an unset NODE_ENV counts as production: secrets are then required.
635
651
  import { existsSync } from 'node:fs'
636
652
  import { fileURLToPath } from 'node:url'
637
653
 
638
654
  // Development loads .env (when present) like \`node --env-file=.env\`: a variable
639
655
  // already exported in your shell still wins, the file only fills in what is
640
- // missing. \`pnpm start\` (production) never loads it — there the configuration
641
- // comes from the real environment.
656
+ // missing. Only development opts into NODE_ENV=development, below.
642
657
  const envFile = fileURLToPath(new URL('../.env', import.meta.url))
643
658
  if (existsSync(envFile)) process.loadEnvFile(envFile)
644
659
 
@@ -1140,8 +1155,10 @@ scaffold with \`--cli\` — \`pnpm basalt prisma:sync\` does the merge for you.)
1140
1155
  \`src/env.ts\` validates \`process.env\`. For development, ${options.cli ? '`pnpm dev` and `pnpm basalt` load' : '`pnpm dev` loads'} \`.env\`
1141
1156
  automatically — create-basalt wrote one for you: git-ignored, with local
1142
1157
  values${options.auth ? ' and a generated secret' : ''}. \`.env.example\` is its committed template.
1143
- \`pnpm start\` (production, \`${PRODUCTION_ENTRY}\`) loads **nothing**: there the
1144
- 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.
1145
1162
 
1146
1163
  The variables are **app-prefixed**: \`${envPrefix(options.name)}_PORT\`,
1147
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.
@@ -1168,7 +1185,7 @@ your shell exports: \`env | grep DATABASE_URL\`.
1168
1185
 
1169
1186
  \`\`\`bash
1170
1187
  pnpm build # tsc -p tsconfig.build.json → dist/ (src only)
1171
- pnpm start # node --enable-source-maps ${PRODUCTION_ENTRY} — no tsx at runtime
1188
+ pnpm start # ${START_SCRIPT} — no tsx at runtime
1172
1189
  pnpm start:dev # the server from source with tsx, without building
1173
1190
  docker build -t ${options.name} . # the Dockerfile: build stage + plain-node runtime
1174
1191
  \`\`\`
package/dist/versions.js CHANGED
@@ -8,10 +8,10 @@ export const SCAFFOLD_VERSIONS = {
8
8
  '@basaltkit/ai-mcp': '^0.4.0',
9
9
  '@basaltkit/auth': '^5.0.0',
10
10
  '@basaltkit/auth-prisma': '^3.0.0',
11
- '@basaltkit/cli': '^1.4.2',
11
+ '@basaltkit/cli': '^1.6.0',
12
12
  '@basaltkit/config': '^1.1.2',
13
13
  '@basaltkit/core': '^1.6.0',
14
- '@basaltkit/deploy': '^0.8.0',
14
+ '@basaltkit/deploy': '^0.10.1',
15
15
  '@basaltkit/env': '^3.1.1',
16
16
  '@basaltkit/events': '^1.3.0',
17
17
  '@basaltkit/fastify': '^3.0.0',
@@ -19,7 +19,7 @@ export const SCAFFOLD_VERSIONS = {
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.2',
31
+ 'create-basalt': '^1.16.0',
32
32
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-basalt",
3
- "version": "1.14.2",
3
+ "version": "1.16.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",
24
+ "@prisma/adapter-pg": "^7.10.1",
25
+ "@prisma/client": "^7.10.1",
26
+ "@prisma/client-runtime-utils": "^7.10.1",
27
27
  "@types/node": "^26.6.5",
28
28
  "@types/pg": "^8.23.1",
29
29
  "pg": "^8.23.1",
30
- "prisma": "^7.10.0",
30
+ "prisma": "^7.10.1",
31
31
  "tsx": "^4.23.15",
32
32
  "typescript": "^7.0.2",
33
- "vitest": "^5.0.3",
34
- "zod": "^4.6.6",
35
- "@basaltkit/cli": "^1.4.2",
33
+ "vitest": "^5.0.5",
34
+ "zod": "^4.6.7",
35
+ "@basaltkit/auth-prisma": "^3.0.0",
36
36
  "@basaltkit/config": "^1.1.2",
37
+ "@basaltkit/auth": "^5.0.0",
37
38
  "@basaltkit/core": "^1.6.0",
38
- "@basaltkit/auth-prisma": "^3.0.0",
39
- "@basaltkit/deploy": "^0.8.0",
39
+ "@basaltkit/cli": "^1.6.0",
40
+ "@basaltkit/deploy": "^0.10.1",
40
41
  "@basaltkit/env": "^3.1.1",
41
- "@basaltkit/events": "^1.3.0",
42
42
  "@basaltkit/fastify": "^3.0.0",
43
- "@basaltkit/generator": "^1.7.0",
43
+ "@basaltkit/events": "^1.3.0",
44
44
  "@basaltkit/logger": "^1.3.0",
45
45
  "@basaltkit/mcp": "^6.0.0",
46
- "@basaltkit/prisma": "^4.0.0",
47
46
  "@basaltkit/subscriptions": "^6.0.0",
48
47
  "@basaltkit/subscriptions-prisma": "^4.0.0",
49
48
  "@basaltkit/teams": "^4.1.1",
49
+ "@basaltkit/prisma": "^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/generator": "^1.7.0",
53
54
  "@basaltkit/testing": "^3.0.0",
54
- "@basaltkit/tsconfig": "^0.24.0",
55
- "@basaltkit/auth": "^5.0.0"
55
+ "@basaltkit/tsconfig": "^0.24.0"
56
56
  },
57
57
  "publishConfig": {
58
58
  "access": "public"