create-basalt 1.8.0 → 1.10.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
@@ -16,6 +16,8 @@ Starting a backend project from scratch involves dozens of decisions and files b
16
16
 
17
17
  It works in two ways: interactive mode (answers questions in the terminal) or direct mode with flags (ideal for scripts). It doesn't install dependencies or touch git unless you ask it to (`--install`, `--git`).
18
18
 
19
+ It also stays useful **after** the project exists: `create-basalt update` moves an app to the latest dependency versions, `create-basalt add ui|cli|mcp` adds a feature you didn't pick at creation time, and `create-basalt doctor` / `info` check the project — see [Project commands](#project-commands-update-add-doctor-info). In a scaffolded app they are simply `pnpm basalt update`, `pnpm basalt add ui`, …
20
+
19
21
  ## Installation
20
22
 
21
23
  You don't need to install anything — your package manager's `create` command downloads and runs the package on the spot:
@@ -84,7 +86,8 @@ Usage: npm create basalt <name> [options]
84
86
 
85
87
  | Flag | Default | What it does |
86
88
  | --- | --- | --- |
87
- | `<name>` | — (asked in interactive mode) | Project name (and folder name, unless `--dir`) |
89
+ | `<name>` | — (asked in interactive mode) | Project name (and folder name, unless `--dir`). `update`, `add`, `doctor` and `info` are reserved for the [project commands](#project-commands-update-add-doctor-info) |
90
+ | `--name=<name>` | — | Project name as a flag — the way to create a project literally called `update`/`add`/`doctor`/`info` |
88
91
  | `--dir=<path>` | `./<name>` | Destination folder |
89
92
  | `--no-tenancy` | tenancy **on** | Removes multi-tenancy (`@basaltkit/tenancy`) |
90
93
  | `--no-auth` | auth **on** | Removes authentication (`@basaltkit/auth`, `APP_SECRET`, `/auth/*` routes) |
@@ -135,12 +138,13 @@ Always:
135
138
 
136
139
  ```
137
140
  my-app/
138
- ├── package.json # scripts: dev, start, test, typecheck (+ basalt with --cli)
141
+ ├── package.json # scripts: dev, start, test, typecheck, basalt (+ dev:web with --ui); create-basalt as a devDependency
142
+ ├── .basalt/project.json # scaffold manifest: create-basalt version, options, a hash per generated file — commit it
139
143
  ├── tsconfig.json # strict TypeScript, ESM
140
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
141
145
  ├── .gitignore
142
146
  ├── README.md # instructions adapted to the chosen options
143
- ├── pnpm-workspace.yaml # esbuild allowBuilds, @basaltkit/* release-age exclusion (+ "web" member with --ui) + pnpm 11 notes
147
+ ├── pnpm-workspace.yaml # esbuild allowBuilds, @basaltkit/* + create-basalt release-age exclusion (+ "web" member with --ui) + pnpm 11 notes
144
148
  ├── src/
145
149
  │ ├── env.ts # environment variables validated with Zod (@basaltkit/env), app-prefixed
146
150
  │ ├── app.ts # buildApp() with the chosen plugins
@@ -149,14 +153,14 @@ my-app/
149
153
  └── tests/app.test.ts # smoke test adapted to the options
150
154
  ```
151
155
 
152
- 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. With `--cli`, adds `bin/basalt.ts` and the `"basalt": "tsx bin/basalt.ts"` script. 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).
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).
153
157
 
154
158
  ### With `--ui`: running the API and frontend
155
159
 
156
160
  ```bash
157
161
  pnpm install
158
- pnpm dev # terminal 1 — API on :3000
159
- pnpm --filter my-app-web dev # terminal 2 — UI at http://localhost:5180
162
+ pnpm dev # terminal 1 — API on :3000
163
+ pnpm dev:web # terminal 2 — UI at http://localhost:5180 (= pnpm --filter my-app-web dev)
160
164
  ```
161
165
 
162
166
  Vite's dev server proxies `/api` to the API — there's no CORS to configure.
@@ -209,6 +213,131 @@ pnpm basalt make:resource Project # generates schema → repository → servic
209
213
 
210
214
  With pnpm 11, every `pnpm <script>` and `pnpm exec` first verifies dependencies (`verifyDepsBeforeRun`, default `install`) and runs `pnpm install` when any workspace project is out of sync — so `pnpm basalt …` can need the network. `node_modules/.bin/tsx bin/basalt.ts …` skips that check; `verifyDepsBeforeRun: warn` (commented out in the generated `pnpm-workspace.yaml`) turns it into a warning for the whole project.
211
215
 
216
+ ## Project commands: update, add, doctor, info
217
+
218
+ Run inside an existing Basalt app — any app, created with or without `--cli`,
219
+ by any create-basalt release:
220
+
221
+ ```bash
222
+ npx create-basalt@latest update # or, in a scaffolded app: pnpm basalt update
223
+ npx create-basalt@latest add ui # pnpm basalt add ui
224
+ npx create-basalt@latest doctor # pnpm basalt doctor
225
+ npx create-basalt@latest info # pnpm basalt info
226
+ ```
227
+
228
+ The first positional argument picks the mode: `update`, `add`, `doctor` and
229
+ `info` are project commands; anything else is a new project's name (use
230
+ `--name=update` to create a project with one of those names). Outside a Basalt
231
+ app (no `package.json`, or no `@basaltkit/*` dependency) they stop with an
232
+ actionable error. Every command has `--help`; `--cwd=<dir>` targets another
233
+ directory, `--pm=` overrides the detected package manager (`packageManager`
234
+ field, then the lockfile), and output is colored unless `NO_COLOR` is set,
235
+ `--no-color` is passed or stdout is not a terminal.
236
+
237
+ **How `pnpm basalt <command>` reaches them.** New apps list `create-basalt` as a
238
+ devDependency. Without `--cli` the `basalt` script is `create-basalt --project`;
239
+ with `--cli`, `bin/basalt.ts` handles `update`/`add`/`doctor`/`info` **before**
240
+ importing anything of the app (they must work while the app is broken
241
+ mid-upgrade) and runs the installed create-basalt — or `pnpm dlx` /
242
+ `bunx` / `npx create-basalt@latest` when it isn't installed.
243
+
244
+ ### Updating an app
245
+
246
+ ```bash
247
+ pnpm basalt update --dry # the table, nothing written
248
+ pnpm basalt update # asks, writes, installs, runs the codemods
249
+ ```
250
+
251
+ | Flag | What it does |
252
+ | --- | --- |
253
+ | `--dry` | Prints the plan; writes nothing |
254
+ | `-y`, `--yes` | Applies without asking (required when stdin is not a terminal) |
255
+ | `--major` | Lets third-party packages cross a major too |
256
+ | `--only=@basaltkit` | Only the framework packages (and `create-basalt`) |
257
+ | `--no-install` | Writes `package.json`, skips the install and the codemods |
258
+ | `--no-tooling` | Leaves `bin/basalt.ts` and the `create-basalt` devDependency alone |
259
+
260
+ The policy is the scaffold's own (the same registry code): `@basaltkit/*` and
261
+ `create-basalt` go to `latest` across majors — each framework major prints a
262
+ link to its CHANGELOG and to the upgrade notes; third-party packages go to the
263
+ newest release **on the app's current major** (a newer major is listed as
264
+ "kept … pass --major"); a version younger than the release-age window (pnpm
265
+ `minimumReleaseAge`, read from the project's `pnpm-workspace.yaml` when set) is
266
+ left for later so the install cannot refuse it. When a framework release
267
+ declares a peer range the app would not meet (say `zod ^5` while zod is held on
268
+ 4), the plan warns before anything is written.
269
+
270
+ Both `package.json` and `web/package.json` are updated, **in place**: only the
271
+ version strings change — order, indentation and range style (`^`, `~`, exact)
272
+ are kept, and ranges `update` does not manage (`workspace:`, tags, git, `>=`) are
273
+ left alone. The lockfile is never edited by hand: the detected package manager
274
+ installs, then the installed `@basaltkit/cli` upgrade codemods run (no app
275
+ boot), then it suggests `pnpm typecheck && pnpm test`. If the install fails the
276
+ edits stay and the command says how to revert (`git checkout -- package.json …`).
277
+ `--offline` is refused: update needs the registry.
278
+
279
+ Project tooling rides along (skip with `--no-tooling`): an app without the
280
+ `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.
285
+
286
+ ### Adding features later
287
+
288
+ ```bash
289
+ pnpm basalt add ui --dry # what would be created/merged/skipped
290
+ pnpm basalt add ui # web/ exactly as --ui scaffolds it
291
+ pnpm basalt add cli # bin/basalt.ts + @basaltkit/cli, generator, prisma
292
+ pnpm basalt add mcp # @basaltkit/mcp at POST /mcp + the dev-only ai-mcp bridge + .mcp.json
293
+ ```
294
+
295
+ `add` adapts the templates to the project as it is now (name, auth/tenancy from
296
+ its dependencies) and plans every change before applying any:
297
+
298
+ - **Existing files are never overwritten** — skipped with a notice; `--force`
299
+ overwrites generated files.
300
+ - **Merged, not replaced:** `package.json` (new dependencies in sorted
301
+ position, scripts such as `dev:web`; an existing entry is never changed),
302
+ `pnpm-workspace.yaml` (`web` added to `packages:`), `.gitignore`, `README.md`.
303
+ - **Your code** (`src/app.ts`, `src/routes.ts`) is regenerated only when
304
+ `.basalt/project.json` proves it untouched, patched where the template's
305
+ anchors are still unambiguous (works with `fastifyPlugin`, `expressPlugin` or
306
+ `honoPlugin`), and otherwise left alone with the exact manual steps printed.
307
+ - `add ui` needs a pnpm project (`web/` is a pnpm workspace member) and touches
308
+ no API code: the Vite dev server proxies `/api`, so there is no CORS to set up.
309
+ - Dependencies are installed afterwards unless `--no-install`; `--offline` uses
310
+ the ranges bundled with this create-basalt instead of the registry.
311
+
312
+ A project created without `--ui` followed by `add ui` ends up with the same
313
+ files as one created with `--ui` (the test suite checks exactly that).
314
+
315
+ ### doctor and info
316
+
317
+ `doctor` is read-only and exits non-zero only on errors (warnings exit 0). It
318
+ checks Node against `engines` and Basalt's `>=22.5.0`; the package manager and
319
+ lockfiles; installed versus declared versions, duplicated `@basaltkit/*`
320
+ versions and unmet peer ranges; framework packages behind `latest` (skip with
321
+ `--offline`); the auth secret (`<PREFIX>_APP_SECRET`, from the environment or
322
+ `.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
324
+ migrations exist (whether they are *applied* needs a database —
325
+ `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`.
327
+
328
+ `info` prints create-basalt, Node, OS, package manager, the app's features and
329
+ the declared/installed versions of the framework and key tools — paste it into
330
+ bug reports.
331
+
332
+ ### The scaffold manifest
333
+
334
+ `.basalt/project.json` records the create-basalt version, the options and a
335
+ `sha256` of every file the scaffold wrote (`package.json` excluded — installs
336
+ rewrite it). Commit it: it is how `add` and `update` tell an untouched template
337
+ file from one you edited. Projects created before it existed are handled too —
338
+ features are inferred from `package.json` and the tree, and every file counts as
339
+ yours; the first `add` creates the manifest.
340
+
212
341
  ### `pnpm-workspace.yaml` and pnpm 11
213
342
 
214
343
  - `minimumReleaseAgeExclude` is evaluated **first-match-wins by package name**. To exclude several versions of one package, write ONE entry with a union — `'@types/node@22.20.4 || 26.6.2'` — never two `@types/node@…` entries (only the first would apply). The generated file carries this example as a comment.
@@ -299,6 +428,17 @@ Throws `TargetNotEmptyError` if the destination folder exists and isn't empty.
299
428
 
300
429
  Detects the manager that invoked the command from `npm_config_user_agent` (or the string passed in). Returns `'pnpm' | 'yarn' | 'bun'` when recognized; otherwise `'npm'`.
301
430
 
431
+ ### Project commands (programmatic)
432
+
433
+ | Export | What it does |
434
+ | --- | --- |
435
+ | `runProjectCommand(argv, deps)` | Runs `update`/`add`/`doctor`/`info` and returns the exit code; every side effect (output, prompts, package-manager runs, registry fetch, codemods) is injected through `deps` |
436
+ | `loadProject(dir, pm?)` | Reads an app: package.json (+ `web/`), manifest, detected features and package manager. Throws `NotABasaltAppError` |
437
+ | `planUpdate(ctx, { major?, only?, tooling?, registry? })` | The update plan — entries, the full before/after of every file, tooling, warnings, framework majors. Writes nothing |
438
+ | `planAdd(ctx, feature, { force?, resolveLatest?, registry? })` | The add plan — created/merged/skipped files and manual steps. Writes nothing |
439
+ | `runDoctor(ctx, options)` | Doctor findings (`{ level: 'ok' \| 'info' \| 'warn' \| 'error', area, message }`) |
440
+ | `MANIFEST_PATH`, `readManifest(dir)`, `hashContent(text)` | The scaffold manifest |
441
+
302
442
  ### `TargetNotEmptyError`
303
443
 
304
444
  Error (extends `Error`) with the message `Target directory "<dir>" already exists and is not empty.`
@@ -351,8 +491,20 @@ A variable exported in your shell beats `--env-file`. The generated `src/env.ts`
351
491
  **`pnpm basalt …` starts by running `pnpm install` (or fails offline).**
352
492
  pnpm 11's `verifyDepsBeforeRun` (default `install`). Run `node_modules/.bin/tsx bin/basalt.ts …` instead, or set `verifyDepsBeforeRun: warn` in `pnpm-workspace.yaml`.
353
493
 
354
- **I want to change my mind after generating (e.g. add billing).**
355
- There's no "re-scaffold" command. Either generate a new project with the right flags and compare, or add it by hand: install `@basaltkit/subscriptions` and add the `subscriptionsPlugin` to `src/app.ts` (the generated README and the templates serve as reference).
494
+ **I want to change my mind after generating (e.g. add the frontend).**
495
+ `pnpm basalt add ui` (or `npx create-basalt@latest add ui`) — likewise `cli` and `mcp`; see [Adding features later](#adding-features-later). Billing, tenancy and auth are not `add`-able (they reshape `src/app.ts` and the schema): install `@basaltkit/subscriptions` and add the `subscriptionsPlugin` to `src/app.ts` by hand (the generated README and the templates serve as reference).
496
+
497
+ **`npm create basalt update` did not create a project called "update".**
498
+ `update`, `add`, `doctor` and `info` are project commands. Use `npm create basalt -- --name=update`.
499
+
500
+ **`update cannot run with --offline` / `Could not reach https://registry.npmjs.org for any package`.**
501
+ `update` resolves versions from the registry. Drop `--offline`, check the connection, or point `npm_config_registry` at your mirror.
502
+
503
+ **`Not a terminal — nothing written. Re-run with --yes`.**
504
+ `update` and `add` only apply after a confirmation; in CI or a pipe, pass `--yes`.
505
+
506
+ **`pnpm basalt update` says `Unknown command "update"`.**
507
+ The app's `bin/basalt.ts` predates the project commands. Run `npx create-basalt@latest update` once — it patches an unmodified `bin/basalt.ts` (or prints the snippet for a customised one).
356
508
 
357
509
  ## How it connects to other modules
358
510
 
@@ -363,6 +515,6 @@ There's no "re-scaffold" command. Either generate a new project with the right f
363
515
  - **`@basaltkit/auth`** — included by default (remove with `--no-auth`): `/auth/*` routes and `APP_SECRET` validated in `env.ts`.
364
516
  - **`@basaltkit/subscriptions`** — with `--billing`: example `free`/`pro` plans with trial and feature limits.
365
517
  - **`@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.
366
- - **`@basaltkit/cli` + `@basaltkit/generator`** — with `--cli`: `bin/basalt.ts` calls `runCli`, and `commandsPlugin(generatorCommands())` registers the `make:*` generators.
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.
367
519
  - **`@basaltkit/sdk` + `@basaltkit/admin-shadcn` + `@basaltkit/admin`** — with `--ui`: the `web/` frontend calls the API through a typed client and uses the shadcn components.
368
520
  - **`@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 --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";
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";
3
3
  export interface Flags {
4
4
  name?: string;
5
5
  dir?: string;
package/dist/args.js CHANGED
@@ -2,6 +2,8 @@ import { stdout } from 'node:process';
2
2
  export const USAGE = `Usage: npm create basalt <name> [options]
3
3
 
4
4
  Options:
5
+ --name=<name> Project name (the way to create a project named like a
6
+ project command: update, add, doctor, info)
5
7
  --dir=<path> Target directory (default: ./<name>)
6
8
  --no-tenancy Skip multi-tenancy
7
9
  --no-auth Skip authentication
@@ -25,6 +27,13 @@ Options:
25
27
  -h, --help Show this help
26
28
 
27
29
  Run with no name in a terminal to be prompted interactively.
30
+
31
+ Project commands — inside an existing app (in a scaffolded app: pnpm basalt <command>):
32
+ create-basalt update Update dependencies to the latest versions (--dry, --major, …)
33
+ create-basalt add <feature> Add ui | cli | mcp to the app
34
+ create-basalt doctor Check the project's health
35
+ create-basalt info Versions summary for bug reports
36
+ create-basalt <command> --help Options of one command
28
37
  `;
29
38
  export function parseArgs(argv) {
30
39
  const flags = {
@@ -69,6 +78,8 @@ export function parseArgs(argv) {
69
78
  flags.offline = true;
70
79
  else if (token.startsWith('--dir='))
71
80
  flags.dir = token.slice('--dir='.length);
81
+ else if (token.startsWith('--name='))
82
+ flags.name = token.slice('--name='.length);
72
83
  else if (token.startsWith('--pm='))
73
84
  flags.pm = token.slice('--pm='.length);
74
85
  else if (token === '--help' || token === '-h') {
@@ -0,0 +1,23 @@
1
+ /** Marker proving a bin/basalt.ts already delegates the project commands. */
2
+ export declare const PROJECT_COMMANDS_MARKER = "['update', 'add', 'doctor', 'info'].includes(process.argv[2]";
3
+ export declare function basaltBin(): string;
4
+ /**
5
+ * Hashes of every bin/basalt.ts an earlier create-basalt release generated
6
+ * (CRLF-insensitive). Only these — or a file the project's manifest records as
7
+ * untouched — are patched automatically.
8
+ */
9
+ export declare const LEGACY_BIN_HASHES: ReadonlySet<string>;
10
+ export declare const hasProjectCommands: (content: string) => boolean;
11
+ /**
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}.
17
+ */
18
+ export declare function patchBasaltBin(content: string): string;
19
+ export type BinStatus = 'current' | 'patchable' | 'modified' | 'absent';
20
+ /** Where a bin/basalt.ts stands: already delegating, an untouched old template, or user code. */
21
+ 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";
@@ -0,0 +1,140 @@
1
+ import { hashContent } from './project/manifest.js';
2
+ /**
3
+ * `bin/basalt.ts` — the app's `basalt` CLI (scaffolded with `--cli`, or added
4
+ * later with `create-basalt add cli`).
5
+ *
6
+ * The file opens with the PROJECT-COMMANDS PRELUDE: `update`, `add`, `doctor`
7
+ * and `info` are handed to create-basalt BEFORE anything of the app is
8
+ * imported — those commands exist precisely for the moments the app does not
9
+ * boot (mid-upgrade, a missing dependency). That is also why the dev tools
10
+ * below are imported dynamically: a static import is evaluated before the
11
+ * first statement, so a broken `@basaltkit/generator` would take `pnpm basalt
12
+ * update` down with it.
13
+ */
14
+ const PRELUDE = `import { spawnSync } from 'node:child_process'
15
+ import { existsSync, readFileSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+
19
+ // Project commands run BEFORE anything of the app is imported, so they keep
20
+ // working while the app is broken mid-upgrade. create-basalt provides them —
21
+ // the project's devDependency when installed, else its latest release:
22
+ // pnpm basalt update — update dependencies to the latest versions (--dry to preview)
23
+ // pnpm basalt add ui — add a feature later (ui, cli, mcp)
24
+ // pnpm basalt doctor — check the project's health
25
+ // pnpm basalt info — versions summary for bug reports
26
+ const root = fileURLToPath(new URL('..', import.meta.url))
27
+ if (['update', 'add', 'doctor', 'info'].includes(process.argv[2] ?? '')) {
28
+ process.exit(createBasalt(process.argv.slice(2)))
29
+ }
30
+
31
+ /** Runs create-basalt: the installed devDependency, else \`<pm> dlx create-basalt@latest\`. */
32
+ function createBasalt(args: string[]): number {
33
+ const local = join(root, 'node_modules', 'create-basalt', 'dist', 'cli.js')
34
+ if (existsSync(local)) {
35
+ return spawnSync(process.execPath, [local, ...args], { cwd: root, stdio: 'inherit' }).status ?? 1
36
+ }
37
+ let declared = ''
38
+ try {
39
+ declared = String(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).packageManager ?? '')
40
+ } catch {
41
+ // No readable package.json: the lockfiles decide.
42
+ }
43
+ const uses = (pm: string, ...lockfiles: string[]): boolean =>
44
+ declared.startsWith(\`\${pm}@\`) || lockfiles.some((file) => existsSync(join(root, file)))
45
+ const [command, prefix]: [string, string[]] = uses('pnpm', 'pnpm-lock.yaml')
46
+ ? ['pnpm', ['dlx']]
47
+ : uses('bun', 'bun.lock', 'bun.lockb')
48
+ ? ['bunx', []]
49
+ : ['npx', ['--yes']]
50
+ const result = spawnSync(command, [...prefix, 'create-basalt@latest', ...args], {
51
+ cwd: root,
52
+ stdio: 'inherit',
53
+ shell: process.platform === 'win32',
54
+ })
55
+ return result.status ?? 1
56
+ }
57
+ `;
58
+ /** Marker proving a bin/basalt.ts already delegates the project commands. */
59
+ export const PROJECT_COMMANDS_MARKER = "['update', 'add', 'doctor', 'info'].includes(process.argv[2]";
60
+ export function basaltBin() {
61
+ return `#!/usr/bin/env node
62
+ ${PRELUDE}
63
+ // The dev tools are imported dynamically, after the project commands above.
64
+ const { runCli } = await import('@basaltkit/cli')
65
+ const { generatorCommands } = await import('@basaltkit/generator')
66
+ const { prismaSyncCommand } = await import('@basaltkit/prisma')
67
+
68
+ // Dev tooling: opt into development defaults unless NODE_ENV is already set
69
+ // (imported dynamically below so env.ts is evaluated after this line).
70
+ process.env['NODE_ENV'] ??= 'development'
71
+ const { buildApp } = await import('../src/app.js')
72
+
73
+ // The 'basalt' CLI: boots the app WITH the dev/CLI commands, runs one, shuts down.
74
+ // The dev tools (@basaltkit/generator; add @basaltkit/ai for ai:*) are imported
75
+ // ONLY here — the runtime server (src/server.ts) never loads them, so the SaaS
76
+ // runs without the codegen/AI layer.
77
+ // pnpm basalt dev — dev server: route table + watch (--worker for a queue worker)
78
+ // pnpm basalt list — show available commands
79
+ // pnpm basalt routes — list registered HTTP routes
80
+ // pnpm basalt make:resource Project — generate a full resource vertical
81
+ // pnpm basalt make:service Project — generate a single artifact (schema/service/…)
82
+ const app = buildApp({
83
+ logLevel: 'silent',
84
+ commands: [...generatorCommands(), prismaSyncCommand()],
85
+ })
86
+ process.exit(await runCli({ app }))
87
+ `;
88
+ }
89
+ /**
90
+ * Hashes of every bin/basalt.ts an earlier create-basalt release generated
91
+ * (CRLF-insensitive). Only these — or a file the project's manifest records as
92
+ * untouched — are patched automatically.
93
+ */
94
+ export const LEGACY_BIN_HASHES = new Set([
95
+ // create-basalt 0.x (rebrand, 2026-08-09): static imports, no dev commands.
96
+ 'sha256-78537f4bf21ca44d78dc50f8841f39898b86300c4eb2c696f3d1a6e6c9921cbd',
97
+ // 2026-08-13: generator + prisma:sync passed via `commands`.
98
+ 'sha256-0c898eb1faf32792fc9d2593070c8421905882ba8d8f2aba9a69c74ce2dfa5d8',
99
+ // 2026-08-22: `basalt dev` documented.
100
+ 'sha256-d09d583f1a39bffcb72f302c5f6c3e2651badea358ba38019238a4aa4b842545',
101
+ // 2026-09-19 (create-basalt ≤ 1.9): NODE_ENV defaulted before the app import.
102
+ 'sha256-a0df89dee04091a9470f67968ff72cfc3bf64076c1bf3d141ae657c7438a94c8',
103
+ ]);
104
+ export const hasProjectCommands = (content) => content.includes(PROJECT_COMMANDS_MARKER);
105
+ /**
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}.
111
+ */
112
+ export function patchBasaltBin(content) {
113
+ const lines = content.replace(/\r\n/g, '\n').split('\n');
114
+ const shebang = lines[0]?.startsWith('#!') ? `${lines.shift()}\n` : '';
115
+ const body = lines.map((line) => {
116
+ const match = /^import \{ ([^}]+) \} from '([^']+)'$/.exec(line);
117
+ return match ? `const { ${match[1]} } = await import('${match[2]}')` : line;
118
+ });
119
+ const firstDynamic = body.findIndex((line) => line.startsWith('const {') && line.includes('= await import('));
120
+ if (firstDynamic >= 0 && !(body[firstDynamic] ?? '').includes("'../src/app.js'")) {
121
+ body.splice(firstDynamic, 0, '// The dev tools are imported dynamically, after the project commands above.');
122
+ }
123
+ return `${shebang}${PRELUDE}\n${body.join('\n')}`;
124
+ }
125
+ /** Where a bin/basalt.ts stands: already delegating, an untouched old template, or user code. */
126
+ export function binStatus(content, manifestHash) {
127
+ if (content === undefined)
128
+ return 'absent';
129
+ if (hasProjectCommands(content))
130
+ return 'current';
131
+ const hash = hashContent(content);
132
+ return LEGACY_BIN_HASHES.has(hash) || hash === manifestHash ? 'patchable' : 'modified';
133
+ }
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
136
+ static imports of @basaltkit/* and ../src/app.js into dynamic ones
137
+ (\`import { runCli } from '@basaltkit/cli'\` → \`const { runCli } = await import('@basaltkit/cli')\`)
138
+ so they no longer run before it:
139
+
140
+ ${PRELUDE}`;
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from 'node:child_process';
2
+ import { execFile, spawn } from 'node:child_process';
3
3
  import { stdin, stdout } from 'node:process';
4
4
  import { createProject, describeResolution, detectPackageManager, resolveRunDefaults, runWizard, ttyPrompter, TargetNotEmptyError, WizardCancelledError, } from './index.js';
5
+ import { basaltCommand, isProjectCommand, runProjectCommand } from './project/run.js';
5
6
  import { parseArgs, resolvesLatest, USAGE } from './args.js';
6
7
  import { envPrefix } from './templates.js';
7
8
  /** Runs a command inheriting stdio; resolves false on non-zero exit (never throws). */
@@ -16,12 +17,49 @@ function run(command, args, cwd) {
16
17
  child.on('close', (code) => resolve(code === 0));
17
18
  });
18
19
  }
20
+ /** Runs a command and returns its trimmed stdout, or undefined (never throws). */
21
+ function capture(command, args, cwd) {
22
+ return new Promise((resolve) => {
23
+ execFile(command, args, { cwd, shell: process.platform === 'win32', timeout: 10_000 }, (error, out) => {
24
+ resolve(error ? undefined : String(out).trim() || undefined);
25
+ });
26
+ });
27
+ }
19
28
  /** Cleanly abort on Ctrl+C instead of dumping a Node AbortError stack trace. */
20
29
  function cancel() {
21
30
  stdout.write('\nCancelled.\n');
22
31
  process.exit(130); // 128 + SIGINT
23
32
  }
24
- const flags = parseArgs(process.argv.slice(2));
33
+ const argv = process.argv.slice(2);
34
+ // Project commands (update | add | doctor | info) operate on an EXISTING app.
35
+ // They are selected by the first positional argument — those four words are
36
+ // reserved, so a project literally named "update" is created with
37
+ // --name=update. `--project` (the `basalt` script of apps without --cli) forces
38
+ // project mode: `pnpm basalt` alone then prints the project-command help.
39
+ const firstPositional = argv.find((token) => !token.startsWith('-'));
40
+ if (argv.includes('--project') || isProjectCommand(firstPositional)) {
41
+ const code = await runProjectCommand(argv.filter((token) => token !== '--project'), {
42
+ cwd: process.cwd(),
43
+ out: { log: (line = '') => stdout.write(`${line}\n`), error: (line) => process.stderr.write(`${line}\n`) },
44
+ env: process.env,
45
+ interactive: Boolean(stdin.isTTY) && !process.env['CI'],
46
+ isTTY: Boolean(stdout.isTTY),
47
+ run,
48
+ capture,
49
+ confirm: async (message) => {
50
+ try {
51
+ return await ttyPrompter().confirm({ message, initial: true });
52
+ }
53
+ catch (error) {
54
+ if (error instanceof WizardCancelledError)
55
+ cancel();
56
+ throw error;
57
+ }
58
+ },
59
+ });
60
+ process.exit(code);
61
+ }
62
+ const flags = parseArgs(argv);
25
63
  // Rich interactive wizard when nothing was specified in a terminal. CI, piped
26
64
  // input, and `--yes` keep the flag-driven path.
27
65
  if (!flags.yes && flags.name === undefined && stdin.isTTY) {
@@ -118,11 +156,12 @@ try {
118
156
  steps.push(`${pm} run db:migrate${flags.tenancy ? ' # creates the tables and seeds the demo tenant' : ' # creates the tables'}`);
119
157
  }
120
158
  steps.push(`${pm} run dev${flags.ui ? ' # API on :3000' : ''}`);
121
- // `web` is wired as a pnpm workspace member (pnpm-workspace.yaml), so its dev
122
- // server is launched with a pnpm filter regardless of the root package manager.
159
+ // `web` is a pnpm workspace member (pnpm-workspace.yaml); `dev:web` runs its
160
+ // dev server through a pnpm filter.
123
161
  if (flags.ui)
124
- steps.push(`pnpm --filter ${result.options.name}-web dev # UI on :5180`);
162
+ steps.push(`pnpm dev:web # UI on :5180`);
125
163
  console.log(`\nNext steps:\n${steps.map((s) => ` ${s}`).join('\n')}\n`);
164
+ console.log(`Later: \`${basaltCommand(pm, 'update')}\` updates the dependencies, \`${basaltCommand(pm, 'add ui|cli|mcp')}\` adds a feature, \`${basaltCommand(pm, 'doctor')}\` checks the project.\n`);
126
165
  }
127
166
  catch (error) {
128
167
  if (error instanceof TargetNotEmptyError) {
package/dist/index.d.ts CHANGED
@@ -64,8 +64,16 @@ export declare function resolveRunDefaults(input: {
64
64
  git: boolean;
65
65
  interactive: boolean;
66
66
  };
67
+ /** Every file a scaffold with `options` writes (dependency ranges = the bundled fallbacks). */
68
+ export declare function scaffoldFiles(options: ProjectOptions): Record<string, string>;
67
69
  /** Generates a ready-to-run Basalt app. Does not install dependencies. */
68
70
  export declare function createProject(input: CreateProjectInput): Promise<CreateProjectResult>;
69
- export { THIRD_PARTY_VERSIONS, DEFAULT_REGISTRY, DEFAULT_MINIMUM_RELEASE_AGE_MINUTES, describeResolution, minimumReleaseAgeMinutes, resolveLatestVersions, registryUrl, type FreshVersion, type HeldBackVersion, type ResolveLatestOptions, type VersionResolution, } from './latest-versions.js';
71
+ 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
+ export { runProjectCommand, PROJECT_COMMANDS, isProjectCommand, type ProjectCommandDeps } from './project/run.js';
73
+ export { planUpdate, type UpdatePlan, type UpdateEntry } from './project/update.js';
74
+ export { planAdd, ADDABLE_FEATURES, type AddPlan, type AddableFeature } from './project/add.js';
75
+ export { runDoctor, type DoctorFinding } from './project/doctor.js';
76
+ export { loadProject, NotABasaltAppError, type ProjectContext } from './project/context.js';
77
+ export { MANIFEST_PATH, readManifest, hashContent, type ProjectManifest } from './project/manifest.js';
70
78
  export { runWizard, validateProjectName, PRESETS, FEATURES, type WizardResult, type WizardOptions, type FeatureKey } from './wizard.js';
71
79
  export { ttyPrompter, scriptedPrompter, WizardCancelledError, type Prompter, type Choice } from './prompt.js';
package/dist/index.js CHANGED
@@ -2,7 +2,8 @@ import { mkdir, readdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join, resolve } from 'node:path';
3
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
4
  import { uiFiles } from './templates-ui.js';
5
- import { applyVersions, collectDependencies, resolveLatestVersions, } from './latest-versions.js';
5
+ import { createManifest, MANIFEST_PATH, serializeManifest } from './project/manifest.js';
6
+ import { resolveFileVersions } from './latest-versions.js';
6
7
  export class TargetNotEmptyError extends Error {
7
8
  constructor(dir) {
8
9
  super(`Target directory "${dir}" already exists and is not empty.`);
@@ -35,23 +36,9 @@ export function resolveRunDefaults(input) {
35
36
  git: input.git ?? interactive,
36
37
  };
37
38
  }
38
- /** Generates a ready-to-run Basalt app. Does not install dependencies. */
39
- export async function createProject(input) {
40
- const options = {
41
- name: input.name,
42
- tenancy: input.tenancy ?? true,
43
- auth: input.auth ?? true,
44
- billing: input.billing ?? false,
45
- ui: input.ui ?? false,
46
- cli: input.cli ?? false,
47
- mcp: input.mcp ?? false,
48
- prisma: input.prisma ?? false,
49
- };
50
- const dir = resolve(input.dir ?? input.name);
51
- const existing = await readdir(dir).catch(() => null);
52
- if (existing && existing.length > 0)
53
- throw new TargetNotEmptyError(dir);
54
- const files = {
39
+ /** Every file a scaffold with `options` writes (dependency ranges = the bundled fallbacks). */
40
+ export function scaffoldFiles(options) {
41
+ return {
55
42
  'package.json': packageJson(options),
56
43
  'tsconfig.json': tsconfigJson(options),
57
44
  '.env.example': envExample(options),
@@ -78,13 +65,26 @@ export async function createProject(input) {
78
65
  ...(options.mcp ? { '.mcp.json': mcpJson(options) } : {}),
79
66
  ...(options.ui ? uiFiles(options) : {}),
80
67
  };
81
- let versions;
82
- if (input.resolveLatest) {
83
- const manifests = Object.keys(files).filter((path) => path === 'package.json' || path.endsWith('/package.json'));
84
- versions = await resolveLatestVersions(collectDependencies(manifests.map((path) => files[path])), input.registry);
85
- for (const path of manifests)
86
- files[path] = applyVersions(files[path], versions.versions);
87
- }
68
+ }
69
+ /** Generates a ready-to-run Basalt app. Does not install dependencies. */
70
+ export async function createProject(input) {
71
+ const options = {
72
+ name: input.name,
73
+ tenancy: input.tenancy ?? true,
74
+ auth: input.auth ?? true,
75
+ billing: input.billing ?? false,
76
+ ui: input.ui ?? false,
77
+ cli: input.cli ?? false,
78
+ mcp: input.mcp ?? false,
79
+ prisma: input.prisma ?? false,
80
+ };
81
+ const dir = resolve(input.dir ?? input.name);
82
+ const existing = await readdir(dir).catch(() => null);
83
+ if (existing && existing.length > 0)
84
+ throw new TargetNotEmptyError(dir);
85
+ const files = scaffoldFiles(options);
86
+ const versions = input.resolveLatest ? await resolveFileVersions(files, input.registry) : undefined;
87
+ files[MANIFEST_PATH] = serializeManifest(createManifest(options, files));
88
88
  for (const [path, content] of Object.entries(files)) {
89
89
  const target = join(dir, path);
90
90
  await mkdir(dirname(target), { recursive: true });
@@ -92,6 +92,12 @@ export async function createProject(input) {
92
92
  }
93
93
  return { dir, files: Object.keys(files).sort(), options, ...(versions ? { versions } : {}) };
94
94
  }
95
- export { THIRD_PARTY_VERSIONS, DEFAULT_REGISTRY, DEFAULT_MINIMUM_RELEASE_AGE_MINUTES, describeResolution, minimumReleaseAgeMinutes, resolveLatestVersions, registryUrl, } from './latest-versions.js';
95
+ export { THIRD_PARTY_VERSIONS, DEFAULT_REGISTRY, DEFAULT_MINIMUM_RELEASE_AGE_MINUTES, describeResolution, resolveFileVersions, minimumReleaseAgeMinutes, resolveLatestVersions, registryUrl, } from './latest-versions.js';
96
+ export { runProjectCommand, PROJECT_COMMANDS, isProjectCommand } from './project/run.js';
97
+ export { planUpdate } from './project/update.js';
98
+ export { planAdd, ADDABLE_FEATURES } from './project/add.js';
99
+ export { runDoctor } from './project/doctor.js';
100
+ export { loadProject, NotABasaltAppError } from './project/context.js';
101
+ export { MANIFEST_PATH, readManifest, hashContent } from './project/manifest.js';
96
102
  export { runWizard, validateProjectName, PRESETS, FEATURES } from './wizard.js';
97
103
  export { ttyPrompter, scriptedPrompter, WizardCancelledError } from './prompt.js';