create-basalt 1.10.0 → 1.11.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
@@ -93,7 +93,7 @@ Usage: npm create basalt <name> [options]
93
93
  | `--no-auth` | auth **on** | Removes authentication (`@basaltkit/auth`, `APP_SECRET`, `/auth/*` routes) |
94
94
  | `--billing` | off | Includes subscriptions/plans (`@basaltkit/subscriptions`, example `free` and `pro` plans) |
95
95
  | `--ui` | off | Generates the `web/` frontend (React + shadcn via `@basaltkit/admin-shadcn` + `@basaltkit/sdk`). **Forces pnpm** — see note below |
96
- | `--cli` | off | Generates the `basalt` CLI (`bin/basalt.ts`, `pnpm basalt` script, `make:*` generators from `@basaltkit/generator`) |
96
+ | `--no-cli` | CLI on | Skips the `basalt` CLI (by default every app gets it: `bin/basalt.ts`, the `pnpm basalt` script, `make:*` generators from `@basaltkit/generator`) |
97
97
  | `--mcp` | off | Exposes read-only routes as MCP tools (`@basaltkit/mcp`) over HTTP at `POST /mcp` — the overview and health endpoints are opted in via `meta.mcp` |
98
98
  | `--prisma` (alias `--db`) | off | Backs the app with PostgreSQL through Prisma: `prisma/schema.prisma`, `prisma.config.ts`, `src/db.ts`, the `@basaltkit/*-prisma` stores instead of the in-memory ones, a required `<APP>_DATABASE_URL`, the `db:*` scripts and `prismaPlugin({ assertMigrated: true })` — see *With `--prisma`* below |
99
99
  | `--install` | off | Installs dependencies at the end (with the detected/chosen manager) |
@@ -120,7 +120,7 @@ Behavior notes (faithful to the code):
120
120
  pnpm create basalt
121
121
 
122
122
  # Full project, no questions, with everything:
123
- pnpm create basalt my-app --billing --ui --cli --prisma --install --git
123
+ pnpm create basalt my-app --billing --ui --prisma --install --git
124
124
 
125
125
  # Minimal API (no tenancy or auth), in another folder:
126
126
  npm create basalt service-api --no-tenancy --no-auth --dir=./apps/service-api
@@ -141,7 +141,8 @@ my-app/
141
141
  ├── package.json # scripts: dev, start, 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
- ├── .env.example # MY_APP_PORT, MY_APP_HOST, MY_APP_LOG_LEVEL, NODE_ENV (+ MY_APP_APP_SECRET with auth) + the --env-file precedence warning
144
+ ├── .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
+ ├── .env # the same values for local development (+ a generated MY_APP_APP_SECRET with auth) — git-ignored, loaded by dev/basalt
145
146
  ├── .gitignore
146
147
  ├── README.md # instructions adapted to the chosen options
147
148
  ├── pnpm-workspace.yaml # esbuild allowBuilds, @basaltkit/* + create-basalt release-age exclusion (+ "web" member with --ui) + pnpm 11 notes
@@ -149,11 +150,12 @@ my-app/
149
150
  │ ├── env.ts # environment variables validated with Zod (@basaltkit/env), app-prefixed
150
151
  │ ├── app.ts # buildApp() with the chosen plugins
151
152
  │ ├── routes.ts # GET / (friendly index) and GET /health
152
- │ └── server.ts # startup + clean shutdown on SIGINT/SIGTERM
153
+ │ ├── server.ts # startup + clean shutdown on SIGINT/SIGTERM (`pnpm start` — loads no .env)
154
+ │ └── dev.ts # `pnpm dev`: loads .env, defaults NODE_ENV=development, starts server.ts
153
155
  └── tests/app.test.ts # smoke test adapted to the options
154
156
  ```
155
157
 
156
- 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` without `--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).
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).
157
159
 
158
160
  ### With `--ui`: running the API and frontend
159
161
 
@@ -185,7 +187,7 @@ the next restart.
185
187
  ```bash
186
188
  pnpm create basalt my-app --prisma
187
189
  cd my-app && pnpm install # postinstall runs `prisma generate`
188
- cp .env.example .env # point MY_APP_DATABASE_URL at your database
190
+ # .env already points MY_APP_DATABASE_URL at postgres://…@localhost:5432/my_app — start PostgreSQL or edit it
189
191
  pnpm db:migrate # prisma migrate dev — creates the tables and seeds `demo`
190
192
  pnpm dev
191
193
  ```
@@ -201,9 +203,9 @@ the database and host instead of a `500` on the first request. In production:
201
203
  The generated `tests/app.test.ts` skips itself when no database is configured, so
202
204
  `pnpm test` stays green on a machine without PostgreSQL. To add another Basalt
203
205
  domain later, install its `@basaltkit/<domain>-prisma` package, run
204
- `pnpm basalt prisma:sync` (with `--cli`) and then `pnpm db:migrate`.
206
+ `pnpm basalt prisma:sync` (unless `--no-cli`) and then `pnpm db:migrate`.
205
207
 
206
- ### With `--cli`: the `basalt` command line
208
+ ### The `basalt` command line (default; `--no-cli` skips it)
207
209
 
208
210
  ```bash
209
211
  pnpm basalt list # available commands
@@ -215,7 +217,7 @@ With pnpm 11, every `pnpm <script>` and `pnpm exec` first verifies dependencies
215
217
 
216
218
  ## Project commands: update, add, doctor, info
217
219
 
218
- Run inside an existing Basalt app — any app, created with or without `--cli`,
220
+ Run inside an existing Basalt app — any app, created with or without the CLI (`--no-cli`),
219
221
  by any create-basalt release:
220
222
 
221
223
  ```bash
@@ -235,7 +237,7 @@ field, then the lockfile), and output is colored unless `NO_COLOR` is set,
235
237
  `--no-color` is passed or stdout is not a terminal.
236
238
 
237
239
  **How `pnpm basalt <command>` reaches them.** New apps list `create-basalt` as a
238
- devDependency. Without `--cli` the `basalt` script is `create-basalt --project`;
240
+ devDependency. With `--no-cli` the `basalt` script is `create-basalt --project`;
239
241
  with `--cli`, `bin/basalt.ts` handles `update`/`add`/`doctor`/`info` **before**
240
242
  importing anything of the app (they must work while the app is broken
241
243
  mid-upgrade) and runs the installed create-basalt — or `pnpm dlx` /
@@ -255,7 +257,7 @@ pnpm basalt update # asks, writes, installs, runs the codemods
255
257
  | `--major` | Lets third-party packages cross a major too |
256
258
  | `--only=@basaltkit` | Only the framework packages (and `create-basalt`) |
257
259
  | `--no-install` | Writes `package.json`, skips the install and the codemods |
258
- | `--no-tooling` | Leaves `bin/basalt.ts` and the `create-basalt` devDependency alone |
260
+ | `--no-tooling` | Leaves `bin/basalt.ts`, `src/dev.ts`, `.env.example` and the `create-basalt` devDependency alone |
259
261
 
260
262
  The policy is the scaffold's own (the same registry code): `@basaltkit/*` and
261
263
  `create-basalt` go to `latest` across majors — each framework major prints a
@@ -278,10 +280,13 @@ edits stay and the command says how to revert (`git checkout -- package.json …
278
280
 
279
281
  Project tooling rides along (skip with `--no-tooling`): an app without the
280
282
  `create-basalt` devDependency gets it plus a `basalt` script (and a
281
- `create-basalt` entry in `minimumReleaseAgeExclude`), and a `bin/basalt.ts`
282
- generated by an earlier release gains the project commands — **only** when it is
283
- byte-for-byte a known template (or the manifest records it untouched); a
284
- customised one is left alone and the exact snippet to paste is printed.
283
+ `create-basalt` entry in `minimumReleaseAgeExclude`); a `bin/basalt.ts`
284
+ generated by an earlier release gains the project commands, `.env` loading, a
285
+ pre-boot `upgrade` and the readable env error; a `src/dev.ts` from 1.9/1.10 gains
286
+ `.env` loading; and the `.env.example` header stops saying "nothing loads this
287
+ file". Files are patched **only** when they are byte-for-byte a known template
288
+ (or the manifest records them untouched); a customised one is left alone and the
289
+ exact snippet to paste is printed.
285
290
 
286
291
  ### Adding features later
287
292
 
@@ -320,10 +325,14 @@ lockfiles; installed versus declared versions, duplicated `@basaltkit/*`
320
325
  versions and unmet peer ranges; framework packages behind `latest` (skip with
321
326
  `--offline`); the auth secret (`<PREFIX>_APP_SECRET`, from the environment or
322
327
  `.env`, against the `minLength` in `src/env.ts` and the placeholder rules of
323
- `secret()`) and `DATABASE_URL`; whether the Prisma client is generated and
328
+ `secret()`); every variable `src/env.ts` **requires** (declared without a
329
+ default — `DATABASE_URL` with `--prisma`) that is set neither in the environment
330
+ nor in `.env` — an error, with the fix (`cp .env.example .env`, set it, start
331
+ PostgreSQL); a missing `.env` next to a `.env.example` (warning); whether the Prisma client is generated and
324
332
  migrations exist (whether they are *applied* needs a database —
325
333
  `prisma migrate status`); `.mcp.json` when `@basaltkit/ai-mcp` is installed; dev
326
- tooling declared as a runtime dependency; and an outdated `bin/basalt.ts`.
334
+ tooling declared as a runtime dependency; and an outdated `bin/basalt.ts` or
335
+ `src/dev.ts`.
327
336
 
328
337
  `info` prints create-basalt, Node, OS, package manager, the app's features and
329
338
  the declared/installed versions of the framework and key tools — paste it into
@@ -345,7 +354,9 @@ yours; the first `add` creates the manifest.
345
354
 
346
355
  ### Environment variables and `--env-file`
347
356
 
348
- Nothing loads `.env` for you. `node --env-file=.env` / `tsx --env-file=.env` **never override a variable already exported in the shell** — in a terminal where another project exported `DATABASE_URL` or `PORT`, an app reading the generic name boots against that value and only fails on the first request that touches it.
357
+ **Development loads `.env`; production does not.** `pnpm dev` (`src/dev.ts`) and `pnpm basalt` (`bin/basalt.ts`, scaffolded unless `--no-cli`) load `.env` from the project root when it exists, with `node --env-file` semantics. `pnpm start` (`src/server.ts`) loads **nothing**: in production the configuration comes from the real environment (or start it yourself with `node --env-file=…`). A new app gets a ready-made `.env` — a copy of `.env.example` with local values and, with auth, a generated `APP_SECRET` — git-ignored (`.gitignore` and `.dockerignore`) and written with mode `0600`; `.env.example` stays the committed template. `prisma.config.ts` loads the same file for the Prisma CLI.
358
+
359
+ Loading `.env` — like `node --env-file=.env` / `tsx --env-file=.env` — **never overrides a variable already exported in the shell**: in a terminal where another project exported `DATABASE_URL` or `PORT`, an app reading the generic name boots against that value and only fails on the first request that touches it.
349
360
 
350
361
  The scaffold closes that trap instead of only warning about it: `src/env.ts` passes an **app-specific prefix** derived from the project name (`my-saas` → `MY_SAAS`) to `defineEnv`, and `.env.example` uses the prefixed names.
351
362
 
@@ -366,6 +377,8 @@ NODE_ENV=development # never prefixed — a Node-wide convention
366
377
  # MY_SAAS_APP_SECRET= # with auth
367
378
  ```
368
379
 
380
+ When a variable is invalid or missing, `pnpm basalt <command>` does not print a stack trace: it lists the variables (`EnvValidationError.report`), says where they were read from (the environment, and `.env` when it exists) and how to fix it — `cp .env.example .env`, fill them in, and for `DATABASE_URL` start PostgreSQL — then exits 1. `BASALT_DEBUG=1` (or `--debug`) shows the stack. The database gets the same treatment: PostgreSQL not answering (`ECONNREFUSED`, Prisma `P1001`, or an `assertMigrated` that could not query it) and an unmigrated database (`PRISMA_NOT_MIGRATED`) print what failed, the database in use — `postgres://host:port/name` and the variable it came from, never the credentials — and the fix (start PostgreSQL, e.g. `docker compose up -d`; check `MY_SAAS_DATABASE_URL`; `pnpm db:migrate`). Any other boot error keeps its stack trace. `pnpm basalt upgrade` (the `@basaltkit/cli` codemods) never boots the app, so it runs even when the environment is incomplete.
381
+
369
382
  Each variable is read as `MY_SAAS_<NAME>` first and **falls back** to the bare `<NAME>`, so a deployment that already exports the generic names keeps booting — while a stray `PORT` in your shell no longer wins. The rest of the app is unchanged: the keys stay bare (`env.PORT`). To require the prefixed names only, edit the generated file to `prefix: { value: 'MY_SAAS', fallback: false }`. Full rules: [`@basaltkit/env`](https://github.com/basaltkit/basalt/tree/main/packages/env#app-specific-prefix-prefix).
370
383
 
371
384
  ### Programmatic usage (Advanced)
@@ -418,7 +431,7 @@ Exported from `create-basalt` (in addition to the `create-basalt` executable):
418
431
  | Field | Type | Description |
419
432
  | --- | --- | --- |
420
433
  | `dir` | `string` | Absolute path of the created folder |
421
- | `files` | `string[]` | Files written (relative, sorted) |
434
+ | `files` | `string[]` | Files written (relative, sorted) — includes the development `.env` |
422
435
  | `options` | `ProjectOptions` | The options actually applied (with defaults resolved) |
423
436
  | `versions` | `VersionResolution \| undefined` | With `resolveLatest`: `{ versions, resolved, failed, heldBack, tooFresh, registry }` — the final ranges, which packages got `^<latest>`, which kept the fallback after a registry failure, third-party packages held back because their latest is a new major, and third-party packages whose latest is not provably older than the release-age window (`{ name, latest, range, reason: 'recent' \| 'unknown' }`) |
424
437
 
@@ -469,9 +482,12 @@ Outside an interactive terminal (no TTY) the questions are skipped. Always pass
469
482
  Automatic installation failed (network, Node version, etc.). Go into the folder and run `pnpm install` (or the indicated manager) to see the real error.
470
483
 
471
484
  **I started the app and `GET /auth/login` gives a secret error.**
472
- 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`). Copy `.env.example` to `.env` and set your own secret before going to production.
485
+ 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`.
473
486
 
474
- **With `--prisma`, the app refuses to boot: `PRISMA_NOT_MIGRATED`.**
487
+ **`The app cannot start — the database did not answer`.**
488
+ 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.
489
+
490
+ **With `--prisma`, the app refuses to boot: `PRISMA_NOT_MIGRATED`** (from `pnpm basalt`: `The app cannot start — the database is not migrated`).
475
491
  `assertMigrated` reached a database without the `_prisma_migrations` table. The
476
492
  message names the database and host it actually reached (never the
477
493
  credentials): either it is not the database you meant — check
@@ -486,7 +502,10 @@ The Prisma client is generated code, not a checked-in file. `pnpm install` runs
486
502
  or after editing the schema, run `pnpm db:generate`.
487
503
 
488
504
  **My app connects to the wrong database / port.**
489
- A variable exported in your shell beats `--env-file`. The generated `src/env.ts` already reads app-prefixed names (`MY_SAAS_PORT`), so set those rather than the generic ones — the bare names are only a fallback. Check `env | grep DATABASE_URL`, and see *Environment variables and `--env-file`*.
505
+ A variable exported in your shell beats `.env` (as with `--env-file`). The generated `src/env.ts` already reads app-prefixed names (`MY_SAAS_PORT`), so set those rather than the generic ones — the bare names are only a fallback. Check `env | grep DATABASE_URL`, and see *Environment variables and `--env-file`*.
506
+
507
+ **`The app cannot start — invalid environment variables` (or `EnvValidationError: … expected string, received undefined`).**
508
+ A required variable is set neither in your shell nor in `.env`. Apps from create-basalt ≤ 1.10 did not load `.env` at all in `pnpm dev` / `pnpm basalt`: run `npx create-basalt@latest update` once — it patches an unmodified `bin/basalt.ts` and `src/dev.ts`. Then `cp .env.example .env` if there is no `.env`, fill the variables listed, and run `pnpm basalt doctor` to check. `pnpm start` never reads `.env`: export the variables there.
490
509
 
491
510
  **`pnpm basalt …` starts by running `pnpm install` (or fails offline).**
492
511
  pnpm 11's `verifyDepsBeforeRun` (default `install`). Run `node_modules/.bin/tsx bin/basalt.ts …` instead, or set `verifyDepsBeforeRun: warn` in `pnpm-workspace.yaml`.
@@ -515,6 +534,6 @@ The app's `bin/basalt.ts` predates the project commands. Run `npx create-basalt@
515
534
  - **`@basaltkit/auth`** — included by default (remove with `--no-auth`): `/auth/*` routes and `APP_SECRET` validated in `env.ts`.
516
535
  - **`@basaltkit/subscriptions`** — with `--billing`: example `free`/`pro` plans with trial and feature limits.
517
536
  - **`@basaltkit/prisma` + `@basaltkit/{tenancy,auth,teams,subscriptions}-prisma`** — with `--prisma`: the PostgreSQL layer — `prismaPlugin` (tenant-scoped client, boot-time `assertMigrated`), the composed `prisma/schema.prisma` and the Prisma-backed stores for whichever domains are on.
518
- - **`@basaltkit/cli` + `@basaltkit/generator`** — with `--cli`: `bin/basalt.ts` calls `runCli`, and `commandsPlugin(generatorCommands())` registers the `make:*` generators. `update` runs the installed `@basaltkit/cli` upgrade codemods after an install.
537
+ - **`@basaltkit/cli` + `@basaltkit/generator`** — by default (not with `--no-cli`): `bin/basalt.ts` calls `runCli`, and `commandsPlugin(generatorCommands())` registers the `make:*` generators. `update` runs the installed `@basaltkit/cli` upgrade codemods after an install.
519
538
  - **`@basaltkit/sdk` + `@basaltkit/admin-shadcn` + `@basaltkit/admin`** — with `--ui`: the `web/` frontend calls the API through a typed client and uses the shadcn components.
520
539
  - **`@basaltkit/testing`** — always present in `devDependencies`, with a generated smoke test in `tests/app.test.ts`.
package/dist/args.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { PackageManager } from './index.js';
2
- export declare const USAGE = "Usage: npm create basalt <name> [options]\n\nOptions:\n --name=<name> Project name (the way to create a project named like a\n project command: update, add, doctor, info)\n --dir=<path> Target directory (default: ./<name>)\n --no-tenancy Skip multi-tenancy\n --no-auth Skip authentication\n --billing Include subscriptions/billing\n --ui Scaffold a web/ frontend (React + shadcn + SDK)\n --cli Scaffold the 'basalt' CLI (code generators + commands)\n --mcp Expose read-only routes as MCP tools at /mcp\n --prisma, --db Back the app with PostgreSQL through Prisma: schema,\n migrations, prisma-backed stores and a boot-time check\n that the database is the migrated one\n --install Install dependencies (default: yes in an interactive\n terminal, skipped in CI/non-TTY; --no-install to opt out)\n --no-install Never install dependencies\n --git Initialize a git repository with a first commit\n (same default rule as --install; --no-git to opt out)\n --no-git Never initialize a git repository\n --offline Don't query the npm registry for the latest versions;\n use the ranges bundled with this create-basalt release\n --pm=<manager> Package manager: pnpm | npm | yarn | bun (default: auto-detect)\n -y, --yes Skip prompts and accept defaults\n -h, --help Show this help\n\nRun with no name in a terminal to be prompted interactively.\n\nProject commands \u2014 inside an existing app (in a scaffolded app: pnpm basalt <command>):\n create-basalt update Update dependencies to the latest versions (--dry, --major, \u2026)\n create-basalt add <feature> Add ui | cli | mcp to the app\n create-basalt doctor Check the project's health\n create-basalt info Versions summary for bug reports\n create-basalt <command> --help Options of one command\n";
2
+ export declare const USAGE = "Usage: npm create basalt <name> [options]\n\nOptions:\n --name=<name> Project name (the way to create a project named like a\n project command: update, add, doctor, info)\n --dir=<path> Target directory (default: ./<name>)\n --no-tenancy Skip multi-tenancy\n --no-auth Skip authentication\n --billing Include subscriptions/billing\n --ui Scaffold a web/ frontend (React + shadcn + SDK)\n --no-cli Skip the 'basalt' CLI (code generators + commands;\n on by default)\n --mcp Expose read-only routes as MCP tools at /mcp\n --prisma, --db Back the app with PostgreSQL through Prisma: schema,\n migrations, prisma-backed stores and a boot-time check\n that the database is the migrated one\n --install Install dependencies (default: yes in an interactive\n terminal, skipped in CI/non-TTY; --no-install to opt out)\n --no-install Never install dependencies\n --git Initialize a git repository with a first commit\n (same default rule as --install; --no-git to opt out)\n --no-git Never initialize a git repository\n --offline Don't query the npm registry for the latest versions;\n use the ranges bundled with this create-basalt release\n --pm=<manager> Package manager: pnpm | npm | yarn | bun (default: auto-detect)\n -y, --yes Skip prompts and accept defaults\n -h, --help Show this help\n\nRun with no name in a terminal to be prompted interactively.\n\nProject commands \u2014 inside an existing app (in a scaffolded app: pnpm basalt <command>):\n create-basalt update Update dependencies to the latest versions (--dry, --major, \u2026)\n create-basalt add <feature> Add ui | cli | mcp to the app\n create-basalt doctor Check the project's health\n create-basalt info Versions summary for bug reports\n create-basalt <command> --help Options of one command\n";
3
3
  export interface Flags {
4
4
  name?: string;
5
5
  dir?: string;
package/dist/args.js CHANGED
@@ -9,7 +9,8 @@ Options:
9
9
  --no-auth Skip authentication
10
10
  --billing Include subscriptions/billing
11
11
  --ui Scaffold a web/ frontend (React + shadcn + SDK)
12
- --cli Scaffold the 'basalt' CLI (code generators + commands)
12
+ --no-cli Skip the 'basalt' CLI (code generators + commands;
13
+ on by default)
13
14
  --mcp Expose read-only routes as MCP tools at /mcp
14
15
  --prisma, --db Back the app with PostgreSQL through Prisma: schema,
15
16
  migrations, prisma-backed stores and a boot-time check
@@ -41,7 +42,7 @@ export function parseArgs(argv) {
41
42
  auth: true,
42
43
  billing: false,
43
44
  ui: false,
44
- cli: false,
45
+ cli: true,
45
46
  mcp: false,
46
47
  prisma: false,
47
48
  yes: false,
@@ -58,6 +59,8 @@ export function parseArgs(argv) {
58
59
  flags.ui = true;
59
60
  else if (token === '--cli')
60
61
  flags.cli = true;
62
+ else if (token === '--no-cli')
63
+ flags.cli = false;
61
64
  else if (token === '--mcp')
62
65
  flags.mcp = true;
63
66
  // `--db` reads naturally in a sentence ("scaffold it with a db") and is the
@@ -1,5 +1,7 @@
1
1
  /** Marker proving a bin/basalt.ts already delegates the project commands. */
2
2
  export declare const PROJECT_COMMANDS_MARKER = "['update', 'add', 'doctor', 'info'].includes(process.argv[2]";
3
+ /** Marker proving a bin/basalt.ts already has the dev prelude (.env loading, `upgrade`, env errors). */
4
+ export declare const DEV_PRELUDE_MARKER = "process.loadEnvFile(envFile)";
3
5
  export declare function basaltBin(): string;
4
6
  /**
5
7
  * Hashes of every bin/basalt.ts an earlier create-basalt release generated
@@ -8,16 +10,19 @@ export declare function basaltBin(): string;
8
10
  */
9
11
  export declare const LEGACY_BIN_HASHES: ReadonlySet<string>;
10
12
  export declare const hasProjectCommands: (content: string) => boolean;
13
+ export declare const hasDevPrelude: (content: string) => boolean;
11
14
  /**
12
- * Upgrades a template-generated bin/basalt.ts to delegate the project
13
- * commands: inserts the prelude after the shebang and turns the remaining
14
- * static imports into dynamic ones (so they no longer run before it).
15
- * Deterministic for the known templates — for the latest one the result is
16
- * exactly {@link basaltBin}.
15
+ * Upgrades a template-generated bin/basalt.ts to the current template, step by
16
+ * step: (1) without the project commands, inserts their prelude after the
17
+ * shebang and turns the remaining static imports into dynamic ones (so they no
18
+ * 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
21
+ * result is exactly {@link basaltBin}.
17
22
  */
18
23
  export declare function patchBasaltBin(content: string): string;
19
24
  export type BinStatus = 'current' | 'patchable' | 'modified' | 'absent';
20
- /** Where a bin/basalt.ts stands: already delegating, an untouched old template, or user code. */
25
+ /** Where a bin/basalt.ts stands: already current, an untouched old template, or user code. */
21
26
  export declare function binStatus(content: string | undefined, manifestHash?: string): BinStatus;
22
- /** What to paste by hand into a customised bin/basalt.ts. */
23
- export declare const MANUAL_BIN_SNIPPET = "Paste this right after the shebang line of bin/basalt.ts, then turn its\nstatic imports of @basaltkit/* and ../src/app.js into dynamic ones\n(`import { runCli } from '@basaltkit/cli'` \u2192 `const { runCli } = await import('@basaltkit/cli')`)\nso they no longer run before it:\n\nimport { spawnSync } from 'node:child_process'\nimport { existsSync, readFileSync } from 'node:fs'\nimport { join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\n// Project commands run BEFORE anything of the app is imported, so they keep\n// working while the app is broken mid-upgrade. create-basalt provides them \u2014\n// the project's devDependency when installed, else its latest release:\n// pnpm basalt update \u2014 update dependencies to the latest versions (--dry to preview)\n// pnpm basalt add ui \u2014 add a feature later (ui, cli, mcp)\n// pnpm basalt doctor \u2014 check the project's health\n// pnpm basalt info \u2014 versions summary for bug reports\nconst root = fileURLToPath(new URL('..', import.meta.url))\nif (['update', 'add', 'doctor', 'info'].includes(process.argv[2] ?? '')) {\n process.exit(createBasalt(process.argv.slice(2)))\n}\n\n/** Runs create-basalt: the installed devDependency, else `<pm> dlx create-basalt@latest`. */\nfunction createBasalt(args: string[]): number {\n const local = join(root, 'node_modules', 'create-basalt', 'dist', 'cli.js')\n if (existsSync(local)) {\n return spawnSync(process.execPath, [local, ...args], { cwd: root, stdio: 'inherit' }).status ?? 1\n }\n let declared = ''\n try {\n declared = String(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).packageManager ?? '')\n } catch {\n // No readable package.json: the lockfiles decide.\n }\n const uses = (pm: string, ...lockfiles: string[]): boolean =>\n declared.startsWith(`${pm}@`) || lockfiles.some((file) => existsSync(join(root, file)))\n const [command, prefix]: [string, string[]] = uses('pnpm', 'pnpm-lock.yaml')\n ? ['pnpm', ['dlx']]\n : uses('bun', 'bun.lock', 'bun.lockb')\n ? ['bunx', []]\n : ['npx', ['--yes']]\n const result = spawnSync(command, [...prefix, 'create-basalt@latest', ...args], {\n cwd: root,\n stdio: 'inherit',\n shell: process.platform === 'win32',\n })\n return result.status ?? 1\n}\n";
27
+ /** What to paste by hand into a customised bin/basalt.ts (only the parts it lacks). */
28
+ export declare function manualBinSnippet(content: string): string;
@@ -55,11 +55,122 @@ function createBasalt(args: string[]): number {
55
55
  return result.status ?? 1
56
56
  }
57
57
  `;
58
+ /**
59
+ * The DEV PRELUDE, right after the project commands and still before anything
60
+ * of the app is imported:
61
+ *
62
+ * - `upgrade` (the @basaltkit/cli codemods) only rewrites source files, so it
63
+ * runs without booting the app — it must keep working while the app cannot
64
+ * start (that is usually why you are upgrading);
65
+ * - `.env` is loaded for development with `node --env-file` semantics: a
66
+ * variable already exported in the shell wins, the file only fills in what
67
+ * is missing (the app-prefixed names in src/env.ts exist because of exactly
68
+ * that rule). `pnpm start` / src/server.ts never loads it;
69
+ * - a boot-time `EnvValidationError` becomes a fix (which variables, where they
70
+ * are read from, what to do) instead of a stack trace.
71
+ */
72
+ const DEV_PRELUDE = `// \`upgrade\` (framework codemods) only rewrites source files: it runs without
73
+ // booting the app, so it works even while the app cannot start.
74
+ if (process.argv[2] === 'upgrade') {
75
+ const { consoleIo, parseArgv, upgradeCommand } = await import('@basaltkit/cli')
76
+ const { args, flags } = parseArgv(process.argv.slice(2))
77
+ // upgradeCommand reads only io/args/flags — there is no app to hand it.
78
+ const context = { io: consoleIo(), args, flags } as unknown as Parameters<typeof upgradeCommand.handle>[0]
79
+ process.exit((await upgradeCommand.handle(context)) ?? 0)
80
+ }
81
+
82
+ // Development loads .env (when present) like \`node --env-file=.env\`: a variable
83
+ // already exported in your shell still wins, the file only fills in what is
84
+ // missing. \`pnpm start\` (production) never loads it — there the configuration
85
+ // comes from the real environment.
86
+ const envFile = join(root, '.env')
87
+ if (existsSync(envFile)) process.loadEnvFile(envFile)
88
+
89
+ /**
90
+ * A boot failure as a fix instead of a stack trace (stack: BASALT_DEBUG=1 or --debug):
91
+ * invalid environment variables, a database that does not answer, one that is not
92
+ * migrated. Anything else is rethrown untouched.
93
+ */
94
+ async function explainBootFailure(error: unknown): Promise<never> {
95
+ const failure = (error ?? {}) as { code?: unknown; report?: unknown; message?: unknown; cause?: unknown; errors?: unknown }
96
+ const message = String(failure.message ?? '')
97
+ const codes = [failure, failure.cause, ...(Array.isArray(failure.errors) ? failure.errors : [])].map((item) =>
98
+ String((item as { code?: unknown } | null | undefined)?.code ?? ''),
99
+ )
100
+ const unreachable =
101
+ codes.some((code) => /^(ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ETIMEDOUT|EAI_AGAIN|P1001|P1002)$/.test(code)) ||
102
+ /ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|Can't reach database server/.test(message) ||
103
+ // assertMigrated could not even query the database (down, wrong host/port or credentials).
104
+ (failure.code === 'PRISMA_NOT_MIGRATED' && message.startsWith('Could not verify'))
105
+ const debugHint = 'BASALT_DEBUG=1 shows the stack trace.'
106
+ let explanation: string | undefined
107
+ if (failure.code === 'ENV_INVALID' && Array.isArray(failure.report)) {
108
+ const hasEnvFile = existsSync(envFile)
109
+ explanation = [
110
+ 'The app cannot start — invalid environment variables:',
111
+ ...failure.report.map((line) => ' - ' + String(line)),
112
+ '',
113
+ "Read from: your shell's environment" +
114
+ (hasEnvFile ? ' and ' + envFile + ' (exported variables win).' : ' only — there is no ' + envFile + '.'),
115
+ 'Fix: ' + (hasEnvFile ? 'set them in .env' : 'cp .env.example .env, then fill them in') + ' (or export them)' +
116
+ (existsSync(join(root, 'prisma')) ? ' — for DATABASE_URL, start PostgreSQL and point the URL at it' : '') +
117
+ '. \`pnpm basalt doctor\` checks the environment; ' + debugHint,
118
+ ].join('\\n')
119
+ } else if (unreachable || failure.code === 'PRISMA_NOT_MIGRATED') {
120
+ const database = await databaseInUse()
121
+ const where = 'Database: ' + database.target + ' (from ' + database.name + '; credentials never shown).'
122
+ explanation = unreachable
123
+ ? [
124
+ 'The app cannot start — the database did not answer.',
125
+ where,
126
+ 'Fix: start PostgreSQL (e.g. \`docker compose up -d\`, or your local service) and check that ' +
127
+ database.name + ' in .env points at it (host, port, user, password); on a new database, then run \`pnpm db:migrate\`. ' + debugHint,
128
+ ].join('\\n')
129
+ : [
130
+ 'The app cannot start — the database is not migrated:',
131
+ ' ' + message,
132
+ where,
133
+ 'Fix: run \`pnpm db:migrate\` (development; \`pnpm db:deploy\` in production) — or, if this is not the database you meant, check ' +
134
+ database.name + ' (a shell may have exported another project\\'s). ' + debugHint,
135
+ ].join('\\n')
136
+ }
137
+ if (explanation === undefined || process.env['BASALT_DEBUG'] === '1' || process.argv.includes('--debug')) throw error
138
+ console.error(explanation)
139
+ process.exit(1)
140
+ }
141
+
142
+ /** The database URL the app booted with — protocol, host, port and database only, never the credentials. */
143
+ async function databaseInUse(): Promise<{ name: string; target: string }> {
144
+ let url: unknown
145
+ try {
146
+ url = ((await import('../src/env.js')).env as unknown as Record<string, unknown>)['DATABASE_URL']
147
+ } catch {
148
+ // env.ts itself failed: there is no URL to show.
149
+ }
150
+ // The variable it came from: the app-prefixed name first (it wins in src/env.ts).
151
+ const name =
152
+ Object.keys(process.env)
153
+ .filter((key) => key.endsWith('DATABASE_URL') && process.env[key] === url)
154
+ .sort((a, b) => b.length - a.length)[0] ?? 'DATABASE_URL'
155
+ try {
156
+ const parsed = new URL(String(url))
157
+ return { name, target: parsed.protocol + '//' + parsed.hostname + ':' + (parsed.port || '5432') + parsed.pathname }
158
+ } catch {
159
+ return { name, target: '(no valid URL)' }
160
+ }
161
+ }
162
+ `;
58
163
  /** Marker proving a bin/basalt.ts already delegates the project commands. */
59
164
  export const PROJECT_COMMANDS_MARKER = "['update', 'add', 'doctor', 'info'].includes(process.argv[2]";
165
+ /** Marker proving a bin/basalt.ts already has the dev prelude (.env loading, `upgrade`, env errors). */
166
+ export const DEV_PRELUDE_MARKER = 'process.loadEnvFile(envFile)';
167
+ const APP_IMPORT = "await import('../src/app.js')";
168
+ const APP_IMPORT_EXPLAINED = `${APP_IMPORT}.catch(explainBootFailure)`;
169
+ const RUN_CLI = 'process.exit(await runCli({ app })';
60
170
  export function basaltBin() {
61
171
  return `#!/usr/bin/env node
62
172
  ${PRELUDE}
173
+ ${DEV_PRELUDE}
63
174
  // The dev tools are imported dynamically, after the project commands above.
64
175
  const { runCli } = await import('@basaltkit/cli')
65
176
  const { generatorCommands } = await import('@basaltkit/generator')
@@ -68,7 +179,7 @@ const { prismaSyncCommand } = await import('@basaltkit/prisma')
68
179
  // Dev tooling: opt into development defaults unless NODE_ENV is already set
69
180
  // (imported dynamically below so env.ts is evaluated after this line).
70
181
  process.env['NODE_ENV'] ??= 'development'
71
- const { buildApp } = await import('../src/app.js')
182
+ const { buildApp } = ${APP_IMPORT_EXPLAINED}
72
183
 
73
184
  // The 'basalt' CLI: boots the app WITH the dev/CLI commands, runs one, shuts down.
74
185
  // The dev tools (@basaltkit/generator; add @basaltkit/ai for ai:*) are imported
@@ -83,7 +194,7 @@ const app = buildApp({
83
194
  logLevel: 'silent',
84
195
  commands: [...generatorCommands(), prismaSyncCommand()],
85
196
  })
86
- process.exit(await runCli({ app }))
197
+ process.exit(await runCli({ app }).catch(explainBootFailure))
87
198
  `;
88
199
  }
89
200
  /**
@@ -100,17 +211,31 @@ export const LEGACY_BIN_HASHES = new Set([
100
211
  'sha256-d09d583f1a39bffcb72f302c5f6c3e2651badea358ba38019238a4aa4b842545',
101
212
  // 2026-09-19 (create-basalt ≤ 1.9): NODE_ENV defaulted before the app import.
102
213
  'sha256-a0df89dee04091a9470f67968ff72cfc3bf64076c1bf3d141ae657c7438a94c8',
214
+ // 2026-10-01 (create-basalt 1.10): project commands (update/add/doctor/info)
215
+ // delegated before boot — but no .env loading and `upgrade` booted the app.
216
+ 'sha256-9091e137a5de39929d0e70b2fd973386b9cc1c2f996e130989a22c795b9d0893',
103
217
  ]);
104
218
  export const hasProjectCommands = (content) => content.includes(PROJECT_COMMANDS_MARKER);
219
+ export const hasDevPrelude = (content) => content.includes(DEV_PRELUDE_MARKER);
105
220
  /**
106
- * Upgrades a template-generated bin/basalt.ts to delegate the project
107
- * commands: inserts the prelude after the shebang and turns the remaining
108
- * static imports into dynamic ones (so they no longer run before it).
109
- * Deterministic for the known templates — for the latest one the result is
110
- * exactly {@link basaltBin}.
221
+ * Upgrades a template-generated bin/basalt.ts to the current template, step by
222
+ * step: (1) without the project commands, inserts their prelude after the
223
+ * shebang and turns the remaining static imports into dynamic ones (so they no
224
+ * longer run before it); (2) without the dev prelude, inserts it right after
225
+ * the project commands and routes the app import through `explainBootFailure`.
226
+ * Deterministic for the known templates — for the latest ones (1.9, 1.10) the
227
+ * result is exactly {@link basaltBin}.
111
228
  */
112
229
  export function patchBasaltBin(content) {
113
- const lines = content.replace(/\r\n/g, '\n').split('\n');
230
+ let text = content.replace(/\r\n/g, '\n');
231
+ if (!hasProjectCommands(text))
232
+ text = addProjectCommands(text);
233
+ if (!hasDevPrelude(text))
234
+ text = addDevPrelude(text);
235
+ return text;
236
+ }
237
+ function addProjectCommands(content) {
238
+ const lines = content.split('\n');
114
239
  const shebang = lines[0]?.startsWith('#!') ? `${lines.shift()}\n` : '';
115
240
  const body = lines.map((line) => {
116
241
  const match = /^import \{ ([^}]+) \} from '([^']+)'$/.exec(line);
@@ -122,19 +247,45 @@ export function patchBasaltBin(content) {
122
247
  }
123
248
  return `${shebang}${PRELUDE}\n${body.join('\n')}`;
124
249
  }
125
- /** Where a bin/basalt.ts stands: already delegating, an untouched old template, or user code. */
250
+ function addDevPrelude(content) {
251
+ const at = content.indexOf(PRELUDE);
252
+ // A file without the template's prelude block is not ours to rearrange.
253
+ if (at < 0)
254
+ return content;
255
+ const end = at + PRELUDE.length + (content.startsWith('\n', at + PRELUDE.length) ? 1 : 0);
256
+ const rest = content
257
+ .slice(end)
258
+ .replace(`= ${APP_IMPORT}\n`, `= ${APP_IMPORT_EXPLAINED}\n`)
259
+ .replace(`${RUN_CLI})\n`, `${RUN_CLI}.catch(explainBootFailure))\n`);
260
+ return `${content.slice(0, end)}${DEV_PRELUDE}\n${rest}`;
261
+ }
262
+ /** Where a bin/basalt.ts stands: already current, an untouched old template, or user code. */
126
263
  export function binStatus(content, manifestHash) {
127
264
  if (content === undefined)
128
265
  return 'absent';
129
- if (hasProjectCommands(content))
266
+ if (hasProjectCommands(content) && hasDevPrelude(content))
130
267
  return 'current';
131
268
  const hash = hashContent(content);
132
269
  return LEGACY_BIN_HASHES.has(hash) || hash === manifestHash ? 'patchable' : 'modified';
133
270
  }
134
- /** What to paste by hand into a customised bin/basalt.ts. */
135
- export const MANUAL_BIN_SNIPPET = `Paste this right after the shebang line of bin/basalt.ts, then turn its
271
+ /** What to paste by hand into a customised bin/basalt.ts (only the parts it lacks). */
272
+ export function manualBinSnippet(content) {
273
+ const steps = [];
274
+ if (!hasProjectCommands(content)) {
275
+ steps.push(`Paste this right after the shebang line of bin/basalt.ts, then turn its
136
276
  static imports of @basaltkit/* and ../src/app.js into dynamic ones
137
277
  (\`import { runCli } from '@basaltkit/cli'\` → \`const { runCli } = await import('@basaltkit/cli')\`)
138
278
  so they no longer run before it:
139
279
 
140
- ${PRELUDE}`;
280
+ ${PRELUDE}`);
281
+ }
282
+ if (!hasDevPrelude(content)) {
283
+ steps.push(`${steps.length > 0 ? 'Then paste' : 'Paste'} this after the project-commands block (before anything of the app is
284
+ imported) — it runs \`upgrade\` without booting the app, loads .env for development
285
+ and explains env errors — and change the app import to
286
+ \`const { buildApp } = ${APP_IMPORT_EXPLAINED}\`:
287
+
288
+ ${DEV_PRELUDE}`);
289
+ }
290
+ return steps.join('\n');
291
+ }
package/dist/cli.js CHANGED
@@ -152,7 +152,8 @@ try {
152
152
  if (flags.prisma) {
153
153
  // Nothing boots before the database exists: env.DATABASE_URL is required and
154
154
  // the app asserts at boot that it reached the MIGRATED database.
155
- steps.push(`cp .env.example .env # then point ${envPrefix(result.options.name)}_DATABASE_URL at your database`);
155
+ // .env is already written with a local URL (pnpm dev / pnpm basalt load it).
156
+ steps.push(`# start PostgreSQL, or edit ${envPrefix(result.options.name)}_DATABASE_URL in .env`);
156
157
  steps.push(`${pm} run db:migrate${flags.tenancy ? ' # creates the tables and seeds the demo tenant' : ' # creates the tables'}`);
157
158
  }
158
159
  steps.push(`${pm} run dev${flags.ui ? ' # API on :3000' : ''}`);
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `src/dev.ts` (the `pnpm dev` entrypoint) and the `.env.example` header across
3
+ * create-basalt releases. Up to 1.10, nothing loaded `.env`: `pnpm dev` booted
4
+ * without the variables the example told you to set. `update` brings untouched
5
+ * copies to the current templates; customised ones get the snippet instead.
6
+ */
7
+ /** Hashes of every src/dev.ts an earlier create-basalt release generated (CRLF-insensitive). */
8
+ export declare const LEGACY_DEV_HASHES: ReadonlySet<string>;
9
+ /** Marker proving a src/dev.ts already loads .env. */
10
+ export declare const DEV_ENV_MARKER = "process.loadEnvFile(";
11
+ export type DevEntryStatus = 'current' | 'patchable' | 'modified' | 'absent';
12
+ /** Where a src/dev.ts stands: loads .env already, an untouched old template, or user code. */
13
+ export declare function devEntryStatus(content: string | undefined, manifestHash?: string): DevEntryStatus;
14
+ /** The current src/dev.ts — what a patchable one becomes. */
15
+ export declare const patchDevEntry: () => string;
16
+ /** What to paste by hand into a customised src/dev.ts. */
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
+ /**
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
21
+ * newer or hand-written file is left alone — it is only comments).
22
+ */
23
+ export declare function patchEnvExample(content: string): string | undefined;
@@ -0,0 +1,49 @@
1
+ import { hashContent } from './project/manifest.js';
2
+ import { devTs, ENV_EXAMPLE_LEAD, LEGACY_ENV_EXAMPLE_LEAD } from './templates.js';
3
+ /**
4
+ * `src/dev.ts` (the `pnpm dev` entrypoint) and the `.env.example` header across
5
+ * create-basalt releases. Up to 1.10, nothing loaded `.env`: `pnpm dev` booted
6
+ * without the variables the example told you to set. `update` brings untouched
7
+ * copies to the current templates; customised ones get the snippet instead.
8
+ */
9
+ /** Hashes of every src/dev.ts an earlier create-basalt release generated (CRLF-insensitive). */
10
+ export const LEGACY_DEV_HASHES = new Set([
11
+ // 2026-09-19 → create-basalt 1.10: NODE_ENV defaulted, no .env loading.
12
+ 'sha256-f25f09fed1b36281a806e29f5aab139a1dda0c5fa898f7c4c71b6ac294326cb7',
13
+ ]);
14
+ /** Marker proving a src/dev.ts already loads .env. */
15
+ export const DEV_ENV_MARKER = 'process.loadEnvFile(';
16
+ /** Where a src/dev.ts stands: loads .env already, an untouched old template, or user code. */
17
+ export function devEntryStatus(content, manifestHash) {
18
+ if (content === undefined)
19
+ return 'absent';
20
+ if (content.includes(DEV_ENV_MARKER))
21
+ return 'current';
22
+ const hash = hashContent(content);
23
+ return LEGACY_DEV_HASHES.has(hash) || hash === manifestHash ? 'patchable' : 'modified';
24
+ }
25
+ /** The current src/dev.ts — what a patchable one becomes. */
26
+ export const patchDevEntry = () => devTs();
27
+ /** What to paste by hand into a customised src/dev.ts. */
28
+ export const MANUAL_DEV_SNIPPET = `Paste this at the top of src/dev.ts, BEFORE the server is imported (keep that
29
+ import dynamic — \`await import('./server.js')\` — so the variables are set first):
30
+
31
+ import { existsSync } from 'node:fs'
32
+ import { fileURLToPath } from 'node:url'
33
+
34
+ // Development loads .env (when present) like \`node --env-file=.env\`: a variable
35
+ // already exported in your shell still wins, the file only fills in what is missing.
36
+ const envFile = fileURLToPath(new URL('../.env', import.meta.url))
37
+ if (existsSync(envFile)) process.loadEnvFile(envFile)
38
+ `;
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
42
+ * newer or hand-written file is left alone — it is only comments).
43
+ */
44
+ export function patchEnvExample(content) {
45
+ const normalized = content.replace(/\r\n/g, '\n');
46
+ if (!normalized.startsWith(`${LEGACY_ENV_EXAMPLE_LEAD}\n`))
47
+ return undefined;
48
+ return `${ENV_EXAMPLE_LEAD}${normalized.slice(LEGACY_ENV_EXAMPLE_LEAD.length)}`;
49
+ }
package/dist/index.d.ts CHANGED
@@ -68,6 +68,12 @@ export declare function resolveRunDefaults(input: {
68
68
  export declare function scaffoldFiles(options: ProjectOptions): Record<string, string>;
69
69
  /** Generates a ready-to-run Basalt app. Does not install dependencies. */
70
70
  export declare function createProject(input: CreateProjectInput): Promise<CreateProjectResult>;
71
+ /**
72
+ * A random APP_SECRET for the development .env: 48 random bytes, base64url —
73
+ * redrawn in the (rare) case it happens to contain a word `secret()` rejects
74
+ * as a placeholder (`test`, `0000`, …).
75
+ */
76
+ export declare function generateAppSecret(random?: (size: number) => Buffer): string;
71
77
  export { THIRD_PARTY_VERSIONS, DEFAULT_REGISTRY, DEFAULT_MINIMUM_RELEASE_AGE_MINUTES, describeResolution, resolveFileVersions, minimumReleaseAgeMinutes, resolveLatestVersions, registryUrl, type FreshVersion, type HeldBackVersion, type ResolveLatestOptions, type VersionResolution, } from './latest-versions.js';
72
78
  export { runProjectCommand, PROJECT_COMMANDS, isProjectCommand, type ProjectCommandDeps } from './project/run.js';
73
79
  export { planUpdate, type UpdatePlan, type UpdateEntry } from './project/update.js';
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { randomBytes } from 'node:crypto';
1
2
  import { mkdir, readdir, writeFile } from 'node:fs/promises';
2
3
  import { dirname, join, resolve } from 'node:path';
3
- import { appTest, appTs, dbTs, 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, dockerignore, envExample, envTs, gitignore, mcpJson, basaltBin, packageJson, pnpmWorkspaceYaml, prismaConfigTs, prismaSchema, prismaSeedTs, readme, routesTs, serverTs, tsconfigJson, } from './templates.js';
4
5
  import { uiFiles } from './templates-ui.js';
6
+ import { INSECURE_SECRET } from './project/doctor.js';
5
7
  import { createManifest, MANIFEST_PATH, serializeManifest } from './project/manifest.js';
6
8
  import { resolveFileVersions } from './latest-versions.js';
7
9
  export class TargetNotEmptyError extends Error {
@@ -85,13 +87,30 @@ export async function createProject(input) {
85
87
  const files = scaffoldFiles(options);
86
88
  const versions = input.resolveLatest ? await resolveFileVersions(files, input.registry) : undefined;
87
89
  files[MANIFEST_PATH] = serializeManifest(createManifest(options, files));
90
+ // A ready-to-use .env for development, so `pnpm dev` / `pnpm basalt` boot
91
+ // without a copy step. Written after the manifest on purpose: it is
92
+ // git-ignored local state (with a generated secret), not a template file.
93
+ files['.env'] = devEnvFile(options, generateAppSecret());
88
94
  for (const [path, content] of Object.entries(files)) {
89
95
  const target = join(dir, path);
90
96
  await mkdir(dirname(target), { recursive: true });
91
- await writeFile(target, content);
97
+ // .env holds a secret: owner-only, like `umask 077` would make it.
98
+ await writeFile(target, content, path === '.env' ? { mode: 0o600 } : undefined);
92
99
  }
93
100
  return { dir, files: Object.keys(files).sort(), options, ...(versions ? { versions } : {}) };
94
101
  }
102
+ /**
103
+ * A random APP_SECRET for the development .env: 48 random bytes, base64url —
104
+ * redrawn in the (rare) case it happens to contain a word `secret()` rejects
105
+ * as a placeholder (`test`, `0000`, …).
106
+ */
107
+ export function generateAppSecret(random = randomBytes) {
108
+ for (;;) {
109
+ const value = random(48).toString('base64url');
110
+ if (!INSECURE_SECRET.test(value))
111
+ return value;
112
+ }
113
+ }
95
114
  export { THIRD_PARTY_VERSIONS, DEFAULT_REGISTRY, DEFAULT_MINIMUM_RELEASE_AGE_MINUTES, describeResolution, resolveFileVersions, minimumReleaseAgeMinutes, resolveLatestVersions, registryUrl, } from './latest-versions.js';
96
115
  export { runProjectCommand, PROJECT_COMMANDS, isProjectCommand } from './project/run.js';
97
116
  export { planUpdate } from './project/update.js';
@@ -23,6 +23,8 @@ export interface DoctorOptions {
23
23
  /** `<pm> --version` (injected; undefined when unavailable). */
24
24
  pmVersion?: string;
25
25
  }
26
+ /** Mirrors the placeholder check of `secret()` in @basaltkit/env (src/secret.ts). */
27
+ export declare const INSECURE_SECRET: RegExp;
26
28
  /** The Node range every @basaltkit package declares. */
27
29
  export declare const BASALT_NODE_RANGE = ">=22.5.0";
28
30
  /** Installed version of `name` as the app resolves it (top-level node_modules). */
@@ -31,6 +33,14 @@ export declare function installedVersion(dir: string, name: string): Promise<str
31
33
  export declare function parseDotenv(text: string): Record<string, string>;
32
34
  /** The env prefix src/env.ts passes to defineEnv (`prefix: 'X'` or `prefix: { value: 'X' … }`). */
33
35
  export declare function envPrefixOf(envTs: string | undefined, name: string): string | undefined;
36
+ /**
37
+ * The variables src/env.ts requires: keys of the schema object passed to
38
+ * `defineEnv` whose declaration has no `.default(`, `.optional(`, `.catch(` or
39
+ * `.nullish(` (`secret()` is checked separately — it has a dev default).
40
+ * A light scan, not a parser: comments and strings are skipped, nesting is
41
+ * tracked. Undefined when there is no `defineEnv({ … })` to read.
42
+ */
43
+ export declare function requiredEnvKeys(envTs: string | undefined): string[] | undefined;
34
44
  export declare function runDoctor(ctx: ProjectContext, options?: DoctorOptions): Promise<DoctorFinding[]>;
35
45
  export declare function renderFindings(findings: readonly DoctorFinding[], colors: Colors): string[];
36
46
  /** `create-basalt info`: a paste-able versions summary for bug reports. */
@@ -3,13 +3,14 @@ import { readdir, readFile } 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
+ import { devEntryStatus } from '../dev-entry.js';
6
7
  import { alwaysLatest, lookupLatestVersions } from '../latest-versions.js';
7
8
  import { envPrefix } from '../templates.js';
8
9
  import { allDependencies } from './context.js';
9
10
  import { ownVersion } from './manifest.js';
10
11
  import { compareVersions, parseSimpleRange, parseVersion, satisfies } from './semver.js';
11
12
  /** Mirrors the placeholder check of `secret()` in @basaltkit/env (src/secret.ts). */
12
- const INSECURE_SECRET = /change.?me|changeme|placeholder|example|secret|password|default|test|xxxx+|0000+/i;
13
+ export const INSECURE_SECRET = /change.?me|changeme|placeholder|example|secret|password|default|test|xxxx+|0000+/i;
13
14
  /** The Node range every @basaltkit package declares. */
14
15
  export const BASALT_NODE_RANGE = '>=22.5.0';
15
16
  async function readJson(path) {
@@ -87,6 +88,72 @@ export function envPrefixOf(envTs, name) {
87
88
  const match = /prefix:\s*(?:\{\s*value:\s*)?['"]([A-Z0-9_]+)['"]/.exec(envTs);
88
89
  return match ? match[1] : /prefix:/.test(envTs) ? undefined : '';
89
90
  }
91
+ /**
92
+ * The variables src/env.ts requires: keys of the schema object passed to
93
+ * `defineEnv` whose declaration has no `.default(`, `.optional(`, `.catch(` or
94
+ * `.nullish(` (`secret()` is checked separately — it has a dev default).
95
+ * A light scan, not a parser: comments and strings are skipped, nesting is
96
+ * tracked. Undefined when there is no `defineEnv({ … })` to read.
97
+ */
98
+ export function requiredEnvKeys(envTs) {
99
+ if (envTs === undefined)
100
+ return undefined;
101
+ const call = envTs.indexOf('defineEnv(');
102
+ const open = call < 0 ? -1 : envTs.indexOf('{', call);
103
+ if (open < 0)
104
+ return undefined;
105
+ const entries = [];
106
+ let depth = 0;
107
+ let current;
108
+ for (let i = open; i < envTs.length; i++) {
109
+ const char = envTs[i];
110
+ if (char === '/' && envTs[i + 1] === '/') {
111
+ const end = envTs.indexOf('\n', i);
112
+ i = end < 0 ? envTs.length : end - 1;
113
+ continue;
114
+ }
115
+ if (char === '/' && envTs[i + 1] === '*') {
116
+ const end = envTs.indexOf('*/', i + 2);
117
+ i = end < 0 ? envTs.length : end + 1;
118
+ continue;
119
+ }
120
+ if (char === "'" || char === '"' || char === '`') {
121
+ let end = i + 1;
122
+ while (end < envTs.length && envTs[end] !== char)
123
+ end += envTs[end] === '\\' ? 2 : 1;
124
+ if (current)
125
+ current.body += envTs.slice(i, end + 1);
126
+ i = end;
127
+ continue;
128
+ }
129
+ if (char === '{' || char === '(' || char === '[')
130
+ depth++;
131
+ else if (char === '}' || char === ')' || char === ']') {
132
+ depth--;
133
+ if (depth === 0)
134
+ break;
135
+ }
136
+ if (depth === 1 && (char === ',' || char === '{')) {
137
+ if (current)
138
+ entries.push(current);
139
+ current = undefined;
140
+ const start = skipTrivia(envTs, i + 1);
141
+ const match = /^([A-Z][A-Z0-9_]*)\s*:/.exec(envTs.slice(start, start + 200));
142
+ if (match) {
143
+ current = { key: match[1], body: '' };
144
+ i = start + match[0].length - 1;
145
+ }
146
+ continue;
147
+ }
148
+ if (current)
149
+ current.body += char;
150
+ }
151
+ if (current)
152
+ entries.push(current);
153
+ return entries
154
+ .filter(({ body }) => !/\.(default|optional|catch|nullish)\(|\bsecret\(/.test(body))
155
+ .map(({ key }) => key);
156
+ }
90
157
  export async function runDoctor(ctx, options = {}) {
91
158
  const findings = [];
92
159
  const push = (level, area, message) => {
@@ -203,8 +270,10 @@ export async function runDoctor(ctx, options = {}) {
203
270
  const envTs = await readText(join(dir, 'src', 'env.ts'));
204
271
  const dotenvText = await readText(join(dir, '.env'));
205
272
  const dotenv = dotenvText ? parseDotenv(dotenvText) : {};
206
- if (dotenvText === undefined && existsSync(join(dir, '.env.example'))) {
207
- push('info', 'env', 'No .env — copy .env.example to .env (and start with --env-file=.env) or export the variables.');
273
+ const exampleText = await readText(join(dir, '.env.example'));
274
+ const example = exampleText ? parseDotenv(exampleText) : {};
275
+ if (dotenvText === undefined && exampleText !== undefined) {
276
+ push('warn', 'env', 'No .env — `dev` and `basalt` load it for development: `cp .env.example .env` (or export the variables).');
208
277
  }
209
278
  const prefix = envPrefixOf(envTs, ctx.options.name);
210
279
  const lookup = (name) => {
@@ -237,12 +306,25 @@ export async function runDoctor(ctx, options = {}) {
237
306
  push('ok', 'env', `${label} is set (${secret.from}, ${secret.value.length} characters).`);
238
307
  }
239
308
  }
240
- if (ctx.options.prisma) {
241
- const url = lookup('DATABASE_URL');
242
- if (!url)
243
- push('warn', 'env', `${prefix ? `${prefix}_` : ''}DATABASE_URL is not set — the app does not boot without it.`);
244
- else
245
- push('ok', 'env', `${prefix ? `${prefix}_` : ''}DATABASE_URL is set (${url.from}).`);
309
+ // Required variables: declared in src/env.ts without a default (from the
310
+ // environment or .env, the way the dev entrypoints load it). Without a
311
+ // readable schema, a Prisma app still needs DATABASE_URL.
312
+ const required = requiredEnvKeys(envTs) ?? (ctx.options.prisma ? ['DATABASE_URL'] : []);
313
+ for (const name of required) {
314
+ const label = prefix ? `${prefix}_${name}` : name;
315
+ const found = lookup(name);
316
+ if (found) {
317
+ push('ok', 'env', `${label} is set (${found.from}).`);
318
+ continue;
319
+ }
320
+ const inExample = [label, name].some((key) => example[key] !== undefined && example[key] !== '');
321
+ const fix = dotenvText === undefined
322
+ ? exampleText !== undefined
323
+ ? `\`cp .env.example .env\`${inExample ? '' : ` and set ${label} in it`}`
324
+ : `create .env with ${label}=…`
325
+ : `set ${label} in .env${inExample ? ' (.env.example has an example value)' : ''}`;
326
+ const database = name === 'DATABASE_URL' && ctx.options.prisma ? ' — and start PostgreSQL where it points' : '';
327
+ push('error', 'env', `${label} is not set (environment or .env) — the app does not boot without it. Fix: ${fix}, or export it${database}.`);
246
328
  }
247
329
  // --- Prisma -------------------------------------------------------------------
248
330
  const schemaPath = join(dir, 'prisma', 'schema.prisma');
@@ -293,10 +375,18 @@ export async function runDoctor(ctx, options = {}) {
293
375
  // --- Project tooling -------------------------------------------------------------
294
376
  const bin = await readText(join(dir, 'bin', 'basalt.ts'));
295
377
  const status = binStatus(bin, ctx.manifest?.files['bin/basalt.ts']);
296
- if (status === 'patchable')
297
- push('warn', 'tooling', 'bin/basalt.ts predates the project commands — `npx create-basalt@latest update` patches it.');
298
- if (status === 'modified')
299
- push('info', 'tooling', 'bin/basalt.ts is customised and does not delegate update/add/doctor/info — see `create-basalt update` for the snippet.');
378
+ if (status === 'patchable') {
379
+ push('warn', 'tooling', 'bin/basalt.ts is an older template (no .env loading / pre-boot project commands) — `npx create-basalt@latest update` patches it.');
380
+ }
381
+ if (status === 'modified') {
382
+ 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.');
383
+ }
384
+ const dev = await readText(join(dir, 'src', 'dev.ts'));
385
+ const devStatus = devEntryStatus(dev, ctx.manifest?.files['src/dev.ts']);
386
+ if (devStatus === 'patchable')
387
+ push('warn', 'tooling', 'src/dev.ts does not load .env (older template) — `npx create-basalt@latest update` patches it.');
388
+ if (devStatus === 'modified')
389
+ push('info', 'tooling', 'src/dev.ts is customised and does not load .env — see `create-basalt update` for the snippet.');
300
390
  if (!ctx.manifest)
301
391
  push('info', 'tooling', 'No .basalt/project.json (scaffolded before create-basalt 1.10) — features are inferred from package.json.');
302
392
  return findings;
@@ -348,3 +438,23 @@ export async function projectInfo(ctx, options = {}) {
348
438
  }
349
439
  return lines;
350
440
  }
441
+ /** Index of the first character at or after `from` that is not whitespace or a comment (linear scan). */
442
+ function skipTrivia(source, from) {
443
+ let i = from;
444
+ while (i < source.length) {
445
+ const char = source[i];
446
+ if (char === ' ' || char === '\t' || char === '\n' || char === '\r')
447
+ i++;
448
+ else if (source.startsWith('//', i)) {
449
+ const end = source.indexOf('\n', i);
450
+ i = end === -1 ? source.length : end + 1;
451
+ }
452
+ else if (source.startsWith('/*', i)) {
453
+ const end = source.indexOf('*/', i + 2);
454
+ i = end === -1 ? source.length : end + 2;
455
+ }
456
+ else
457
+ break;
458
+ }
459
+ return i;
460
+ }
@@ -47,7 +47,9 @@ present) to their latest published versions, with the scaffold's policy:
47
47
  Range style (^, ~, exact) and package.json formatting are preserved. Then it
48
48
  installs, runs the @basaltkit/cli upgrade codemods, and suggests typecheck+test.
49
49
  It also upgrades project tooling: an unmodified old bin/basalt.ts gains the
50
- project commands; create-basalt becomes a devDependency (pnpm basalt update).
50
+ project commands, .env loading for development and a pre-boot \`upgrade\`; an
51
+ unmodified src/dev.ts loads .env; the .env.example header is refreshed;
52
+ create-basalt becomes a devDependency (pnpm basalt update).
51
53
 
52
54
  Options:
53
55
  --dry Show the plan, write nothing
@@ -56,7 +58,8 @@ Options:
56
58
  --only=@basaltkit
57
59
  Only the framework packages (and create-basalt)
58
60
  --no-install Write package.json, skip the install (and the codemods)
59
- --no-tooling Leave bin/basalt.ts and the create-basalt devDependency alone
61
+ --no-tooling Leave bin/basalt.ts, src/dev.ts, .env.example and the
62
+ create-basalt devDependency alone
60
63
  ${COMMON_FLAGS}
61
64
  `,
62
65
  add: `Usage: create-basalt add <feature> [options] (in an app: pnpm basalt add <feature>)
@@ -85,8 +88,9 @@ ${COMMON_FLAGS}
85
88
 
86
89
  Read-only health check: Node vs engines, package manager and lockfiles,
87
90
  installed vs declared versions, duplicated @basaltkit versions and unmet peer
88
- ranges, framework packages behind latest, the auth secret and DATABASE_URL
89
- (from the environment or .env), Prisma client/migrations, .mcp.json.
91
+ ranges, framework packages behind latest, the required variables of src/env.ts
92
+ and the auth secret (from the environment or .env), Prisma client/migrations,
93
+ .mcp.json, and whether bin/basalt.ts / src/dev.ts are current.
90
94
  Exits 1 when an error is found; warnings alone exit 0.
91
95
 
92
96
  Options:
@@ -1,6 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { binStatus, MANUAL_BIN_SNIPPET, patchBasaltBin } from '../bin-template.js';
3
+ import { binStatus, hasProjectCommands, manualBinSnippet, patchBasaltBin } from '../bin-template.js';
4
+ import { devEntryStatus, MANUAL_DEV_SNIPPET, patchDevEntry, patchEnvExample } from '../dev-entry.js';
4
5
  import { alwaysLatest, lookupLatestVersions, minimumReleaseAgeMinutes, } from '../latest-versions.js';
5
6
  import { BASALT_PROJECT_SCRIPT } from '../templates.js';
6
7
  import { extendManifest, MANIFEST_PATH, serializeManifest } from './manifest.js';
@@ -194,22 +195,44 @@ export async function planUpdate(ctx, options = {}) {
194
195
  }
195
196
  }
196
197
  }
197
- // bin/basalt.ts from an earlier template: make it delegate the project commands.
198
+ // Template files from an earlier release: bring them to the current
199
+ // templates when untouched (known hash or the manifest's), else print the
200
+ // snippet. Patched files get their manifest hash refreshed.
201
+ const patched = {};
198
202
  const bin = await readOptional(join(ctx.dir, 'bin', 'basalt.ts'));
199
203
  const status = binStatus(bin, ctx.manifest?.files['bin/basalt.ts']);
200
204
  if (status === 'patchable' && bin !== undefined) {
201
205
  const after = patchBasaltBin(bin);
202
206
  files['bin/basalt.ts'] = { before: bin, after };
203
- toolingNotes.push('bin/basalt.ts: unmodified template from an earlier release — gains update/add/doctor/info');
204
- if (ctx.manifest) {
205
- files[MANIFEST_PATH] = {
206
- before: serializeManifest(ctx.manifest),
207
- after: serializeManifest(extendManifest(ctx.manifest, ctx.options, { 'bin/basalt.ts': after })),
208
- };
209
- }
207
+ patched['bin/basalt.ts'] = after;
208
+ 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`);
209
+ }
210
+ else if (status === 'modified' && bin !== undefined) {
211
+ manual.push(`bin/basalt.ts was customised, so it was not patched. To bring it up to date:\n${manualBinSnippet(bin)}`);
212
+ }
213
+ const dev = await readOptional(join(ctx.dir, 'src', 'dev.ts'));
214
+ const devStatus = devEntryStatus(dev, ctx.manifest?.files['src/dev.ts']);
215
+ if (devStatus === 'patchable' && dev !== undefined) {
216
+ const after = patchDevEntry();
217
+ files['src/dev.ts'] = { before: dev, after };
218
+ patched['src/dev.ts'] = after;
219
+ toolingNotes.push('src/dev.ts: unmodified template from an earlier release — `dev` loads .env (exported variables still win)');
220
+ }
221
+ else if (devStatus === 'modified') {
222
+ manual.push(`src/dev.ts was customised, so it was not patched. To load .env in development:\n${MANUAL_DEV_SNIPPET}`);
223
+ }
224
+ const example = await readOptional(join(ctx.dir, '.env.example'));
225
+ const exampleAfter = example === undefined ? undefined : patchEnvExample(example);
226
+ if (example !== undefined && exampleAfter !== undefined) {
227
+ files['.env.example'] = { before: example, after: exampleAfter };
228
+ patched['.env.example'] = exampleAfter;
229
+ toolingNotes.push('.env.example: header now says dev/CLI load .env automatically and `start` does not');
210
230
  }
211
- else if (status === 'modified') {
212
- manual.push(`bin/basalt.ts was customised, so it was not patched. To give it the project commands:\n${MANUAL_BIN_SNIPPET}`);
231
+ if (ctx.manifest && Object.keys(patched).length > 0) {
232
+ files[MANIFEST_PATH] = {
233
+ before: serializeManifest(ctx.manifest),
234
+ after: serializeManifest(extendManifest(ctx.manifest, ctx.options, patched)),
235
+ };
213
236
  }
214
237
  }
215
238
  // Peer ranges of the framework versions we end up on vs the app's resulting ranges.
@@ -48,17 +48,33 @@ export declare function envTs(options: ProjectOptions): string;
48
48
  * (`DATABASE_URL`, …) other projects export too.
49
49
  */
50
50
  export declare function envPrefix(name: string): string;
51
+ /**
52
+ * The opening lines of `.env.example`. Up to create-basalt 1.10 they read
53
+ * {@link LEGACY_ENV_EXAMPLE_LEAD} ("nothing loads this file"); `update`
54
+ * swaps that for this one in existing apps.
55
+ */
56
+ 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`).";
57
+ 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.";
51
58
  export declare function envExample(options: ProjectOptions): string;
59
+ /**
60
+ * The `.env` a new app starts with (git-ignored, never in the manifest): the
61
+ * values of `.env.example` for local development plus — with auth — a freshly
62
+ * generated APP_SECRET, so `pnpm dev` works without a copy step.
63
+ */
64
+ export declare function devEnvFile(options: ProjectOptions, appSecret: string): string;
52
65
  /** A PostgreSQL-safe database name derived from the project name. */
53
66
  export declare function databaseName(name: string): string;
54
67
  export declare function appTs(options: ProjectOptions): string;
55
68
  export declare function routesTs(options: ProjectOptions): string;
56
69
  export declare function serverTs(): string;
57
70
  /**
58
- * `pnpm dev` entrypoint. Opts into NODE_ENV=development (dev secret defaults,
59
- * pretty logs) unless NODE_ENV is already set, then starts the server. Kept out
60
- * of server.ts on purpose: `pnpm start` runs server.ts directly, where an unset
61
- * NODE_ENV counts as production and secrets are required (fail-closed).
71
+ * `pnpm dev` entrypoint. Loads `.env` (when present) with `node --env-file`
72
+ * semantics — an exported variable wins, the file only fills in what is
73
+ * missing — and opts into NODE_ENV=development (dev secret defaults, pretty
74
+ * logs) unless NODE_ENV is already set, then starts the server. Kept out of
75
+ * server.ts on purpose: `pnpm start` runs server.ts directly, where nothing
76
+ * loads `.env` (production configuration comes from the real environment) and
77
+ * an unset NODE_ENV counts as production, so secrets are required (fail-closed).
62
78
  */
63
79
  export declare function devTs(): string;
64
80
  /**
package/dist/templates.js CHANGED
@@ -222,10 +222,19 @@ export function envPrefix(name) {
222
222
  base = base.slice(start, end);
223
223
  return base === '' || /^\d/.test(base) ? `APP_${base}` : base;
224
224
  }
225
+ /**
226
+ * The opening lines of `.env.example`. Up to create-basalt 1.10 they read
227
+ * {@link LEGACY_ENV_EXAMPLE_LEAD} ("nothing loads this file"); `update`
228
+ * swaps that for this one in existing apps.
229
+ */
230
+ export const ENV_EXAMPLE_LEAD = `# Development: copy this file to .env — \`pnpm dev\` and \`pnpm basalt\` load it
231
+ # automatically (a new app already has one). \`pnpm start\` (production) does
232
+ # NOT: there the variables come from the environment (or \`node --env-file\`).`;
233
+ export const LEGACY_ENV_EXAMPLE_LEAD = `# Nothing loads this file for you: copy it to .env and start with
234
+ # \`node --env-file=.env\` / \`tsx --env-file=.env\`, or export the variables.`;
225
235
  export function envExample(options) {
226
236
  const prefix = envPrefix(options.name);
227
- return `# Nothing loads this file for you: copy it to .env and start with
228
- # \`node --env-file=.env\` / \`tsx --env-file=.env\`, or export the variables.
237
+ return `${ENV_EXAMPLE_LEAD}
229
238
  # --env-file NEVER overrides a variable already exported in your shell: if
230
239
  # another project exported PORT or DATABASE_URL, an app reading the generic
231
240
  # name would silently use THAT one. That is why src/env.ts reads app-prefixed
@@ -252,6 +261,20 @@ ${prefix}_DATABASE_URL=postgres://postgres:postgres@localhost:5432/${databaseNam
252
261
  # ${prefix}_DATABASE_URL=postgres://user:pass@localhost:5432/${options.name.replace(/^@[^/]+\//, '')}
253
262
  `}`;
254
263
  }
264
+ /**
265
+ * The `.env` a new app starts with (git-ignored, never in the manifest): the
266
+ * values of `.env.example` for local development plus — with auth — a freshly
267
+ * generated APP_SECRET, so `pnpm dev` works without a copy step.
268
+ */
269
+ export function devEnvFile(options, appSecret) {
270
+ const prefix = envPrefix(options.name);
271
+ const example = envExample(options);
272
+ const body = example.slice(example.indexOf(`${prefix}_PORT=`));
273
+ return `# Local development settings, generated by create-basalt from .env.example.
274
+ # Git-ignored: never commit it. \`pnpm dev\` and \`pnpm basalt\` load it (a
275
+ # variable exported in your shell wins); \`pnpm start\` (production) does not.
276
+ ${body.replace(`# ${prefix}_APP_SECRET=\n`, `${prefix}_APP_SECRET=${appSecret}\n`)}`;
277
+ }
255
278
  /** A PostgreSQL-safe database name derived from the project name. */
256
279
  export function databaseName(name) {
257
280
  return name.replace(/^@[^/]+\//, '').replace(/[^a-zA-Z0-9]+/g, '_').toLowerCase();
@@ -526,14 +549,27 @@ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
526
549
  `;
527
550
  }
528
551
  /**
529
- * `pnpm dev` entrypoint. Opts into NODE_ENV=development (dev secret defaults,
530
- * pretty logs) unless NODE_ENV is already set, then starts the server. Kept out
531
- * of server.ts on purpose: `pnpm start` runs server.ts directly, where an unset
532
- * NODE_ENV counts as production and secrets are required (fail-closed).
552
+ * `pnpm dev` entrypoint. Loads `.env` (when present) with `node --env-file`
553
+ * semantics — an exported variable wins, the file only fills in what is
554
+ * missing — and opts into NODE_ENV=development (dev secret defaults, pretty
555
+ * logs) unless NODE_ENV is already set, then starts the server. Kept out of
556
+ * server.ts on purpose: `pnpm start` runs server.ts directly, where nothing
557
+ * loads `.env` (production configuration comes from the real environment) and
558
+ * an unset NODE_ENV counts as production, so secrets are required (fail-closed).
533
559
  */
534
560
  export function devTs() {
535
561
  return `// Development entrypoint (\`pnpm dev\`). \`pnpm start\` runs server.ts directly,
536
562
  // where an unset NODE_ENV counts as production: secrets are then required.
563
+ import { existsSync } from 'node:fs'
564
+ import { fileURLToPath } from 'node:url'
565
+
566
+ // Development loads .env (when present) like \`node --env-file=.env\`: a variable
567
+ // already exported in your shell still wins, the file only fills in what is
568
+ // missing. \`pnpm start\` (production) never loads it — there the configuration
569
+ // comes from the real environment.
570
+ const envFile = fileURLToPath(new URL('../.env', import.meta.url))
571
+ if (existsSync(envFile)) process.loadEnvFile(envFile)
572
+
537
573
  process.env['NODE_ENV'] ??= 'development'
538
574
  await import('./server.js')
539
575
  `;
@@ -792,9 +828,9 @@ export function prismaConfigTs(options) {
792
828
  return `import { loadEnvFile } from 'node:process'
793
829
  import { defineConfig } from 'prisma/config'
794
830
 
795
- // Prisma loads no .env for you (neither does the app — see src/env.ts). This
796
- // mirrors \`node --env-file=.env\`: a variable already exported in your shell
797
- // still wins, the file only fills in what is missing.
831
+ // Prisma loads no .env for you. Like the app's dev entrypoints (src/dev.ts,
832
+ // bin/basalt.ts) this mirrors \`node --env-file=.env\`: a variable already
833
+ // exported in your shell still wins, the file only fills in what is missing.
798
834
  try {
799
835
  loadEnvFile('.env')
800
836
  } catch {
@@ -947,7 +983,7 @@ Included: ${features.join(' · ')}.
947
983
  \`\`\`bash
948
984
  pnpm install${options.prisma
949
985
  ? `
950
- # point ${envPrefix(options.name)}_DATABASE_URL at a PostgreSQL database (.env.example), then
986
+ # start PostgreSQL — .env points ${envPrefix(options.name)}_DATABASE_URL at localhost:5432 — then
951
987
  pnpm db:migrate # create the tables${options.tenancy ? ` (runs the seed too)` : ''}`
952
988
  : ''}
953
989
  pnpm dev # API on http://localhost:3000
@@ -1005,16 +1041,18 @@ scaffold with \`--cli\` — \`pnpm basalt prisma:sync\` does the merge for you.)
1005
1041
  : ''}
1006
1042
  ## Environment
1007
1043
 
1008
- \`src/env.ts\` validates \`process.env\` — nothing loads \`.env\` for you. Copy
1009
- \`.env.example\` to \`.env\` and launch with \`--env-file=.env\` (Node/tsx), or
1010
- export the variables.
1044
+ \`src/env.ts\` validates \`process.env\`. For development, ${options.cli ? '`pnpm dev` and `pnpm basalt` load' : '`pnpm dev` loads'} \`.env\`
1045
+ automatically — create-basalt wrote one for you: git-ignored, with local
1046
+ values${options.auth ? ' and a generated secret' : ''}. \`.env.example\` is its committed template.
1047
+ \`pnpm start\` (production, \`src/server.ts\`) loads **nothing**: there the
1048
+ variables come from the real environment (or \`node --env-file=.env\`).
1011
1049
 
1012
1050
  The variables are **app-prefixed**: \`${envPrefix(options.name)}_PORT\`,
1013
1051
  \`${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.
1014
1052
 
1015
- Why: **\`--env-file\` never overrides a variable that is already exported.** In a
1016
- shell where another project exported \`DATABASE_URL\` (or \`PORT\`), an app reading
1017
- the generic name boots against THAT value and only fails on the first request
1053
+ Why: **loading \`.env\` (like \`--env-file\`) never overrides a variable that is
1054
+ already exported.** In a shell where another project exported \`DATABASE_URL\`
1055
+ (or \`PORT\`), an app reading the generic name boots against THAT value and only fails on the first request
1018
1056
  that touches it. \`src/env.ts\` therefore passes \`prefix: '${envPrefix(options.name)}'\` to
1019
1057
  \`defineEnv\`: each variable is read as \`${envPrefix(options.name)}_<NAME>\` first and
1020
1058
  **falls back** to the bare \`<NAME>\`, so a deployment that already exports the
package/dist/versions.js CHANGED
@@ -25,5 +25,5 @@ export const SCAFFOLD_VERSIONS = {
25
25
  '@basaltkit/tenancy': '^3.1.0',
26
26
  '@basaltkit/tenancy-prisma': '^2.0.0',
27
27
  '@basaltkit/testing': '^2.0.0',
28
- 'create-basalt': '^1.10.0',
28
+ 'create-basalt': '^1.11.0',
29
29
  };
package/dist/wizard.js CHANGED
@@ -11,9 +11,9 @@ export const FEATURES = [
11
11
  ];
12
12
  export const PRESETS = [
13
13
  { value: 'saas', label: 'SaaS starter', hint: 'tenancy + auth + billing + database + CLI', features: ['tenancy', 'auth', 'billing', 'prisma', 'cli'] },
14
- { value: 'api', label: 'API only', hint: 'auth + MCP, no tenancy or UI', features: ['auth', 'mcp'] },
14
+ { value: 'api', label: 'API only', hint: 'auth + MCP + CLI, no tenancy or UI', features: ['auth', 'mcp', 'cli'] },
15
15
  { value: 'full', label: 'Full stack', hint: 'everything + web UI', features: ['tenancy', 'auth', 'billing', 'prisma', 'ui', 'cli', 'mcp'] },
16
- { value: 'minimal', label: 'Minimal', hint: 'no batteries, add them later', features: [] },
16
+ { value: 'minimal', label: 'Minimal', hint: 'just the CLI, add the rest later', features: ['cli'] },
17
17
  { value: 'custom', label: 'Custom', hint: 'pick features yourself', features: null },
18
18
  ];
19
19
  const PACKAGE_MANAGERS = [
@@ -59,7 +59,7 @@ export async function runWizard(prompter, options = {}) {
59
59
  (await prompter.multiselect({
60
60
  message: 'Select features',
61
61
  choices: FEATURES,
62
- initial: ['tenancy', 'auth'],
62
+ initial: ['tenancy', 'auth', 'cli'],
63
63
  })));
64
64
  let pm = await prompter.select({
65
65
  message: 'Package manager',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-basalt",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "engines": {
5
5
  "node": ">=22.5.0"
6
6
  },
@@ -30,26 +30,26 @@
30
30
  "typescript": "^7.0.2",
31
31
  "vitest": "^5.0.1",
32
32
  "zod": "^4.6.5",
33
+ "@basaltkit/auth": "^4.1.0",
34
+ "@basaltkit/auth-prisma": "^2.0.0",
35
+ "@basaltkit/config": "^1.1.2",
36
+ "@basaltkit/cli": "^1.3.0",
33
37
  "@basaltkit/core": "^1.5.0",
34
- "@basaltkit/events": "^1.3.0",
35
38
  "@basaltkit/env": "^3.1.1",
39
+ "@basaltkit/events": "^1.3.0",
36
40
  "@basaltkit/fastify": "^2.5.0",
37
- "@basaltkit/auth": "^4.1.0",
38
- "@basaltkit/auth-prisma": "^2.0.0",
41
+ "@basaltkit/generator": "^1.5.0",
39
42
  "@basaltkit/logger": "^1.3.0",
40
43
  "@basaltkit/mcp": "^5.0.0",
41
44
  "@basaltkit/prisma": "^3.0.0",
45
+ "@basaltkit/subscriptions": "^5.0.0",
42
46
  "@basaltkit/subscriptions-prisma": "^3.0.0",
43
47
  "@basaltkit/teams": "^4.0.0",
44
- "@basaltkit/generator": "^1.5.0",
45
- "@basaltkit/cli": "^1.3.0",
46
- "@basaltkit/tenancy": "^3.1.0",
47
48
  "@basaltkit/teams-prisma": "^1.1.0",
48
- "@basaltkit/tenancy-prisma": "^2.0.0",
49
- "@basaltkit/testing": "^2.0.3",
50
- "@basaltkit/subscriptions": "^5.0.0",
49
+ "@basaltkit/tenancy": "^3.1.0",
51
50
  "@basaltkit/tsconfig": "^0.24.0",
52
- "@basaltkit/config": "^1.1.2"
51
+ "@basaltkit/testing": "^2.0.3",
52
+ "@basaltkit/tenancy-prisma": "^2.0.0"
53
53
  },
54
54
  "publishConfig": {
55
55
  "access": "public"