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 +42 -23
- package/dist/args.d.ts +1 -1
- package/dist/args.js +5 -2
- package/dist/bin-template.d.ts +13 -8
- package/dist/bin-template.js +164 -13
- package/dist/cli.js +2 -1
- package/dist/dev-entry.d.ts +23 -0
- package/dist/dev-entry.js +49 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +21 -2
- package/dist/project/doctor.d.ts +10 -0
- package/dist/project/doctor.js +123 -13
- package/dist/project/run.js +8 -4
- package/dist/project/update.js +34 -11
- package/dist/templates.d.ts +20 -4
- package/dist/templates.js +54 -16
- package/dist/versions.js +1 -1
- package/dist/wizard.js +3 -3
- package/package.json +11 -11
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` |
|
|
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 --
|
|
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
|
|
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
|
-
│
|
|
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`
|
|
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
|
-
|
|
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` (
|
|
206
|
+
`pnpm basalt prisma:sync` (unless `--no-cli`) and then `pnpm db:migrate`.
|
|
205
207
|
|
|
206
|
-
###
|
|
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.
|
|
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`)
|
|
282
|
-
generated by an earlier release gains the project commands
|
|
283
|
-
|
|
284
|
-
|
|
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()`)
|
|
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
|
-
|
|
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`).
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
package/dist/bin-template.d.ts
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
* static imports into dynamic ones (so they no
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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
|
|
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;
|
package/dist/bin-template.js
CHANGED
|
@@ -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 } =
|
|
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
|
|
107
|
-
*
|
|
108
|
-
* static imports into dynamic ones (so they no
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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';
|
package/dist/project/doctor.d.ts
CHANGED
|
@@ -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. */
|
package/dist/project/doctor.js
CHANGED
|
@@ -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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
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
|
+
}
|
package/dist/project/run.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
89
|
-
(from the environment or .env), Prisma client/migrations,
|
|
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:
|
package/dist/project/update.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { binStatus,
|
|
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
|
-
//
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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.
|
package/dist/templates.d.ts
CHANGED
|
@@ -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.
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* NODE_ENV
|
|
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
|
|
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.
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
* NODE_ENV
|
|
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
|
|
796
|
-
// mirrors \`node --env-file=.env\`: a variable already
|
|
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
|
-
#
|
|
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
|
|
1009
|
-
|
|
1010
|
-
|
|
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:
|
|
1016
|
-
shell where another project exported \`DATABASE_URL\`
|
|
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
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: '
|
|
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.
|
|
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/
|
|
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
|
|
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/
|
|
51
|
+
"@basaltkit/testing": "^2.0.3",
|
|
52
|
+
"@basaltkit/tenancy-prisma": "^2.0.0"
|
|
53
53
|
},
|
|
54
54
|
"publishConfig": {
|
|
55
55
|
"access": "public"
|