create-basalt 1.4.2 → 1.6.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
@@ -30,7 +30,7 @@ yarn create basalt my-app
30
30
  bun create basalt my-app
31
31
  ```
32
32
 
33
- > Requirements: Node.js 18+ and a package manager. Projects with `--ui` require **pnpm** (explained below).
33
+ > Requirements: Node.js 22.5+ and a package manager. Projects with `--ui` require **pnpm** (explained below).
34
34
 
35
35
  ## Get started in 5 minutes
36
36
 
@@ -93,6 +93,7 @@ Usage: npm create basalt <name> [options]
93
93
  | `--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` |
94
94
  | `--install` | off | Installs dependencies at the end (with the detected/chosen manager) |
95
95
  | `--git` | off | Runs `git init` + first commit ("Initial commit from create-basalt") |
96
+ | `--offline` | off | Don't query the npm registry for the latest versions; use the ranges bundled with this create-basalt release |
96
97
  | `--pm=<manager>` | autodetect | Package manager: `pnpm` \| `npm` \| `yarn` \| `bun` |
97
98
  | `-y`, `--yes` | — | Skips the questions and accepts the defaults |
98
99
  | `-h`, `--help` | — | Shows help and exits |
@@ -102,6 +103,8 @@ Behavior notes (faithful to the code):
102
103
  - **Package manager detection**: by default, detects who invoked the command via the `npm_config_user_agent` variable (set by npm/pnpm/yarn/bun); unknown managers fall back to `npm`. `--pm=` overrides this.
103
104
  - **`--ui` forces pnpm**: the `web/` frontend is a member of a pnpm *workspace* (declared in the generated `pnpm-workspace.yaml`). npm, yarn, and bun can't install or run that structure, so if you request `--ui` with another manager, you'll see `Note: --ui projects are pnpm workspaces — using pnpm instead of <manager>.` and pnpm is used.
104
105
  - **Interactive wizard**: when you don't pass a name, you're in a terminal (TTY), and you didn't use `--yes`, you get the guided wizard — an intro, a **starting-point preset** (SaaS starter / API only / Full stack / Minimal / Custom), an arrow-key **feature multiselect** on the custom path, package-manager select, and a **summary + confirm** step before anything is written. Ctrl+C (or declining the final confirm) ends cleanly with "Cancelled." (exit code 130).
106
+ - **Latest dependency versions**: before writing files, the CLI asks the npm registry (`npm_config_registry` when set by your package manager, else `registry.npmjs.org`) for the `latest` version of every dependency the project will contain and writes `^<latest>`. `@basaltkit/*` packages always take the latest release. Third-party packages (TypeScript, Vitest, React, Vite, Tailwind, …) take the latest only on the major the templates are written for — a newer major keeps the bundled range and prints a `Note:`. If the registry can't be reached (offline, timeout), the bundled ranges are used with one `Warning:` line; the scaffold never fails because of the registry. `--offline` skips the lookup entirely.
107
+ - **Release-age window (pnpm `minimumReleaseAge`)**: a third-party `latest` not provably older than the window (default 1440 min = pnpm 11's default; follows `pnpm_config_minimum_release_age` / `npm_config_minimum_release_age`) keeps the bundled range, so the first `pnpm install` is never blocked by a just-published version — pnpm then picks the newest *mature* version inside that range. The age comes from one `HEAD <registry>/<name>` per third-party package (the packument's `last-modified`, measured against the registry's `Date`); a missing header or failed probe counts as "not provably mature" and prints a `Note:`. `@basaltkit/*` is never probed (the generated `pnpm-workspace.yaml` excludes the scope).
105
108
  - **Occupied folder**: if the destination folder exists and isn't empty, the command refuses with `Target directory "<dir>" already exists and is not empty.` and exits with code 1.
106
109
  - At the end, it prints the created files and the "Next steps" appropriate to your choices.
107
110
 
@@ -132,10 +135,10 @@ Always:
132
135
  my-app/
133
136
  ├── package.json # scripts: dev, start, test, typecheck (+ basalt with --cli)
134
137
  ├── tsconfig.json # strict TypeScript, ESM
135
- ├── .env.example # PORT, HOST, LOG_LEVEL, NODE_ENV (+ APP_SECRET with auth)
138
+ ├── .env.example # PORT, HOST, LOG_LEVEL, NODE_ENV (+ APP_SECRET with auth) + the --env-file precedence warning
136
139
  ├── .gitignore
137
140
  ├── README.md # instructions adapted to the chosen options
138
- ├── pnpm-workspace.yaml # esbuild allowBuilds (+ "web" member with --ui)
141
+ ├── pnpm-workspace.yaml # esbuild allowBuilds, @basaltkit/* release-age exclusion (+ "web" member with --ui) + pnpm 11 notes
139
142
  ├── src/
140
143
  │ ├── env.ts # environment variables validated with Zod (@basaltkit/env)
141
144
  │ ├── app.ts # buildApp() with the chosen plugins
@@ -144,7 +147,7 @@ my-app/
144
147
  └── tests/app.test.ts # smoke test adapted to the options
145
148
  ```
146
149
 
147
- With `--cli`, adds `bin/basalt.ts` and the `"basalt": "tsx bin/basalt.ts"` script. With `--ui`, adds the `web/` folder (Vite + React + Tailwind + shadcn, with `web/src/api.ts` built on top of `@basaltkit/sdk`; with auth on it includes a login/register screen).
150
+ 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).
148
151
 
149
152
  ### With `--ui`: running the API and frontend
150
153
 
@@ -164,6 +167,17 @@ pnpm basalt routes # registered HTTP routes
164
167
  pnpm basalt make:resource Project # generates schema → repository → service → plugin → routes → test
165
168
  ```
166
169
 
170
+ 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.
171
+
172
+ ### `pnpm-workspace.yaml` and pnpm 11
173
+
174
+ - `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.
175
+ - `verifyDepsBeforeRun: warn` is offered commented out; the scaffold keeps pnpm's secure default (`install`).
176
+
177
+ ### Environment variables and `--env-file`
178
+
179
+ Nothing loads `.env` for you. `node --env-file=.env` / `tsx --env-file=.env` **never override a variable already exported in the shell** — in a terminal where another project exported `DATABASE_URL` or `PORT`, the app boots against that value and only fails on the first request that touches it. The generated `.env.example` and README say so and suggest an app-specific prefix derived from the project name (`my-saas` → `MY_SAAS_DATABASE_URL`).
180
+
167
181
  ### Programmatic usage (Advanced)
168
182
 
169
183
  The package also exports the API used by the executable, for your own scripts:
@@ -185,7 +199,7 @@ console.log(result.files) // relative paths, sorted
185
199
  console.log(detectPackageManager()) // 'pnpm' | 'npm' | 'yarn' | 'bun'
186
200
  ```
187
201
 
188
- Note: `createProject` **only writes files** — it doesn't install dependencies or initialize git (that's the executable's job, with `--install`/`--git`).
202
+ Note: `createProject` **only writes files** — it doesn't install dependencies or initialize git (that's the executable's job, with `--install`/`--git`). It also uses the bundled dependency ranges unless you pass `resolveLatest: true` (what the executable does), so a script never touches the network by surprise.
189
203
 
190
204
  ## API reference
191
205
 
@@ -205,6 +219,8 @@ Exported from `create-basalt` (in addition to the `create-basalt` executable):
205
219
  | `ui` | `boolean` | No | `false` | Generate the `web/` frontend |
206
220
  | `cli` | `boolean` | No | `false` | Generate the `basalt` CLI |
207
221
  | `mcp` | `boolean` | No | `false` | Expose read-only routes as MCP tools at `/mcp` |
222
+ | `resolveLatest` | `boolean` | No | `false` | Resolve every dependency to `^<latest>` from the npm registry before writing (falls back to the bundled ranges on any registry failure) |
223
+ | `registry` | `ResolveLatestOptions` | No | — | `{ fetch?, registry?, timeoutMs?, overallTimeoutMs?, concurrency?, minimumReleaseAge?, now? }` — injectable fetch (tests), registry URL (default `npm_config_registry` or `https://registry.npmjs.org`), timeouts (5 s per request, 15 s overall), release-age window in minutes (default `pnpm_config_minimum_release_age` / `npm_config_minimum_release_age`, else 1440; `0` disables the probe), clock (tests) |
208
224
 
209
225
  `CreateProjectResult`:
210
226
 
@@ -213,6 +229,7 @@ Exported from `create-basalt` (in addition to the `create-basalt` executable):
213
229
  | `dir` | `string` | Absolute path of the created folder |
214
230
  | `files` | `string[]` | Files written (relative, sorted) |
215
231
  | `options` | `ProjectOptions` | The options actually applied (with defaults resolved) |
232
+ | `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' }`) |
216
233
 
217
234
  Throws `TargetNotEmptyError` if the destination folder exists and isn't empty.
218
235
 
@@ -252,6 +269,12 @@ Automatic installation failed (network, Node version, etc.). Go into the folder
252
269
  **I started the app and `GET /auth/login` gives a secret error.**
253
270
  With auth on, `src/env.ts` requires `APP_SECRET` with at least 16 characters (there's a development default `change-me-in-production--`). Copy `.env.example` to `.env` and set your own secret before going to production.
254
271
 
272
+ **My app connects to the wrong database / port.**
273
+ A variable exported in your shell beats `--env-file`. Check `env | grep DATABASE_URL`, start with `env -u DATABASE_URL pnpm dev`, and prefer app-prefixed names (see *Environment variables and `--env-file`*).
274
+
275
+ **`pnpm basalt …` starts by running `pnpm install` (or fails offline).**
276
+ pnpm 11's `verifyDepsBeforeRun` (default `install`). Run `node_modules/.bin/tsx bin/basalt.ts …` instead, or set `verifyDepsBeforeRun: warn` in `pnpm-workspace.yaml`.
277
+
255
278
  **I want to change my mind after generating (e.g. add billing).**
256
279
  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).
257
280
 
package/dist/args.d.ts ADDED
@@ -0,0 +1,21 @@
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 --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";
3
+ export interface Flags {
4
+ name?: string;
5
+ dir?: string;
6
+ tenancy: boolean;
7
+ auth: boolean;
8
+ billing: boolean;
9
+ ui: boolean;
10
+ cli: boolean;
11
+ mcp: boolean;
12
+ /** Tri-state: undefined = decide from the environment (TTY yes, CI no). */
13
+ install?: boolean;
14
+ git?: boolean;
15
+ yes: boolean;
16
+ offline: boolean;
17
+ pm?: PackageManager;
18
+ }
19
+ export declare function parseArgs(argv: string[]): Flags;
20
+ /** Whether the scaffold should query the registry for the latest versions. */
21
+ export declare const resolvesLatest: (flags: Pick<Flags, 'offline'>) => boolean;
package/dist/args.js ADDED
@@ -0,0 +1,76 @@
1
+ import { stdout } from 'node:process';
2
+ export const USAGE = `Usage: npm create basalt <name> [options]
3
+
4
+ Options:
5
+ --dir=<path> Target directory (default: ./<name>)
6
+ --no-tenancy Skip multi-tenancy
7
+ --no-auth Skip authentication
8
+ --billing Include subscriptions/billing
9
+ --ui Scaffold a web/ frontend (React + shadcn + SDK)
10
+ --cli Scaffold the 'basalt' CLI (code generators + commands)
11
+ --mcp Expose read-only routes as MCP tools at /mcp
12
+ --install Install dependencies (default: yes in an interactive
13
+ terminal, skipped in CI/non-TTY; --no-install to opt out)
14
+ --no-install Never install dependencies
15
+ --git Initialize a git repository with a first commit
16
+ (same default rule as --install; --no-git to opt out)
17
+ --no-git Never initialize a git repository
18
+ --offline Don't query the npm registry for the latest versions;
19
+ use the ranges bundled with this create-basalt release
20
+ --pm=<manager> Package manager: pnpm | npm | yarn | bun (default: auto-detect)
21
+ -y, --yes Skip prompts and accept defaults
22
+ -h, --help Show this help
23
+
24
+ Run with no name in a terminal to be prompted interactively.
25
+ `;
26
+ export function parseArgs(argv) {
27
+ const flags = {
28
+ tenancy: true,
29
+ auth: true,
30
+ billing: false,
31
+ ui: false,
32
+ cli: false,
33
+ mcp: false,
34
+ yes: false,
35
+ offline: false,
36
+ };
37
+ for (const token of argv) {
38
+ if (token === '--no-tenancy')
39
+ flags.tenancy = false;
40
+ else if (token === '--no-auth')
41
+ flags.auth = false;
42
+ else if (token === '--billing')
43
+ flags.billing = true;
44
+ else if (token === '--ui')
45
+ flags.ui = true;
46
+ else if (token === '--cli')
47
+ flags.cli = true;
48
+ else if (token === '--mcp')
49
+ flags.mcp = true;
50
+ else if (token === '--install')
51
+ flags.install = true;
52
+ else if (token === '--no-install')
53
+ flags.install = false;
54
+ else if (token === '--git')
55
+ flags.git = true;
56
+ else if (token === '--no-git')
57
+ flags.git = false;
58
+ else if (token === '-y' || token === '--yes')
59
+ flags.yes = true;
60
+ else if (token === '--offline')
61
+ flags.offline = true;
62
+ else if (token.startsWith('--dir='))
63
+ flags.dir = token.slice('--dir='.length);
64
+ else if (token.startsWith('--pm='))
65
+ flags.pm = token.slice('--pm='.length);
66
+ else if (token === '--help' || token === '-h') {
67
+ stdout.write(USAGE);
68
+ process.exit(0);
69
+ }
70
+ else if (!token.startsWith('--') && flags.name === undefined)
71
+ flags.name = token;
72
+ }
73
+ return flags;
74
+ }
75
+ /** Whether the scaffold should query the registry for the latest versions. */
76
+ export const resolvesLatest = (flags) => !flags.offline;
package/dist/cli.js CHANGED
@@ -1,75 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
3
  import { stdin, stdout } from 'node:process';
4
- import { createProject, detectPackageManager, resolveRunDefaults, runWizard, ttyPrompter, TargetNotEmptyError, WizardCancelledError, } from './index.js';
5
- const USAGE = `Usage: npm create basalt <name> [options]
6
-
7
- Options:
8
- --dir=<path> Target directory (default: ./<name>)
9
- --no-tenancy Skip multi-tenancy
10
- --no-auth Skip authentication
11
- --billing Include subscriptions/billing
12
- --ui Scaffold a web/ frontend (React + shadcn + SDK)
13
- --cli Scaffold the 'basalt' CLI (code generators + commands)
14
- --mcp Expose read-only routes as MCP tools at /mcp
15
- --install Install dependencies (default: yes in an interactive
16
- terminal, skipped in CI/non-TTY; --no-install to opt out)
17
- --no-install Never install dependencies
18
- --git Initialize a git repository with a first commit
19
- (same default rule as --install; --no-git to opt out)
20
- --no-git Never initialize a git repository
21
- --pm=<manager> Package manager: pnpm | npm | yarn | bun (default: auto-detect)
22
- -y, --yes Skip prompts and accept defaults
23
- -h, --help Show this help
24
-
25
- Run with no name in a terminal to be prompted interactively.
26
- `;
27
- function parseArgs(argv) {
28
- const flags = {
29
- tenancy: true,
30
- auth: true,
31
- billing: false,
32
- ui: false,
33
- cli: false,
34
- mcp: false,
35
- yes: false,
36
- };
37
- for (const token of argv) {
38
- if (token === '--no-tenancy')
39
- flags.tenancy = false;
40
- else if (token === '--no-auth')
41
- flags.auth = false;
42
- else if (token === '--billing')
43
- flags.billing = true;
44
- else if (token === '--ui')
45
- flags.ui = true;
46
- else if (token === '--cli')
47
- flags.cli = true;
48
- else if (token === '--mcp')
49
- flags.mcp = true;
50
- else if (token === '--install')
51
- flags.install = true;
52
- else if (token === '--no-install')
53
- flags.install = false;
54
- else if (token === '--git')
55
- flags.git = true;
56
- else if (token === '--no-git')
57
- flags.git = false;
58
- else if (token === '-y' || token === '--yes')
59
- flags.yes = true;
60
- else if (token.startsWith('--dir='))
61
- flags.dir = token.slice('--dir='.length);
62
- else if (token.startsWith('--pm='))
63
- flags.pm = token.slice('--pm='.length);
64
- else if (token === '--help' || token === '-h') {
65
- stdout.write(USAGE);
66
- process.exit(0);
67
- }
68
- else if (!token.startsWith('--') && flags.name === undefined)
69
- flags.name = token;
70
- }
71
- return flags;
72
- }
4
+ import { createProject, describeResolution, detectPackageManager, resolveRunDefaults, runWizard, ttyPrompter, TargetNotEmptyError, WizardCancelledError, } from './index.js';
5
+ import { parseArgs, resolvesLatest, USAGE } from './args.js';
73
6
  /** Runs a command inheriting stdio; resolves false on non-zero exit (never throws). */
74
7
  function run(command, args, cwd) {
75
8
  return new Promise((resolve) => {
@@ -124,6 +57,8 @@ if (flags.ui && pm !== 'pnpm') {
124
57
  pm = 'pnpm';
125
58
  }
126
59
  try {
60
+ if (resolvesLatest(flags))
61
+ console.log('Resolving the latest published dependency versions…');
127
62
  const result = await createProject({
128
63
  name: flags.name,
129
64
  ...(flags.dir ? { dir: flags.dir } : {}),
@@ -133,10 +68,21 @@ try {
133
68
  ui: flags.ui,
134
69
  cli: flags.cli,
135
70
  mcp: flags.mcp,
71
+ // New apps get the latest published version of every dependency (with the
72
+ // bundled ranges as an offline fallback); --offline skips the registry.
73
+ resolveLatest: resolvesLatest(flags),
136
74
  });
137
75
  console.log(`\nCreated ${result.options.name} in ${result.dir}\n`);
138
76
  for (const file of result.files)
139
77
  console.log(` ${file}`);
78
+ if (result.versions) {
79
+ const lines = describeResolution(result.versions);
80
+ if (lines.length > 0)
81
+ console.log(`\n${lines.join('\n')}`);
82
+ }
83
+ else {
84
+ console.log('\n--offline: used the dependency ranges bundled with this create-basalt release.');
85
+ }
140
86
  const run_ = resolveRunDefaults({
141
87
  install: flags.install,
142
88
  git: flags.git,
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type ProjectOptions } from './templates.js';
2
+ import { type ResolveLatestOptions, type VersionResolution } from './latest-versions.js';
2
3
  export type { ProjectOptions } from './templates.js';
3
4
  export interface CreateProjectInput {
4
5
  name: string;
@@ -13,11 +14,22 @@ export interface CreateProjectInput {
13
14
  cli?: boolean;
14
15
  /** Expose opted-in routes as MCP tools over HTTP at `/mcp`. Default: false. */
15
16
  mcp?: boolean;
17
+ /**
18
+ * Resolve every dependency to its latest published version (npm registry)
19
+ * before writing files. Default: false — the embedded fallback ranges are
20
+ * used and no network is touched. The `create-basalt` CLI turns it on
21
+ * (unless `--offline`). Registry failures never fail the scaffold.
22
+ */
23
+ resolveLatest?: boolean;
24
+ /** Registry options for `resolveLatest` (injectable fetch, registry, timeouts). */
25
+ registry?: ResolveLatestOptions;
16
26
  }
17
27
  export interface CreateProjectResult {
18
28
  dir: string;
19
29
  files: string[];
20
30
  options: ProjectOptions;
31
+ /** How dependency versions were resolved — present only with `resolveLatest`. */
32
+ versions?: VersionResolution;
21
33
  }
22
34
  export declare class TargetNotEmptyError extends Error {
23
35
  constructor(dir: string);
@@ -48,5 +60,6 @@ export declare function resolveRunDefaults(input: {
48
60
  };
49
61
  /** Generates a ready-to-run Basalt app. Does not install dependencies. */
50
62
  export declare function createProject(input: CreateProjectInput): Promise<CreateProjectResult>;
63
+ 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';
51
64
  export { runWizard, validateProjectName, PRESETS, FEATURES, type WizardResult, type WizardOptions, type FeatureKey } from './wizard.js';
52
65
  export { ttyPrompter, scriptedPrompter, WizardCancelledError, type Prompter, type Choice } from './prompt.js';
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { mkdir, readdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join, resolve } from 'node:path';
3
- import { appTest, appTs, envExample, envTs, gitignore, mcpJson, basaltBin, packageJson, pnpmWorkspaceYaml, readme, routesTs, serverTs, tsconfigJson, } from './templates.js';
3
+ import { appTest, appTs, devTs, dockerignore, envExample, envTs, gitignore, mcpJson, basaltBin, packageJson, pnpmWorkspaceYaml, 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
6
  export class TargetNotEmptyError extends Error {
6
7
  constructor(dir) {
7
8
  super(`Target directory "${dir}" already exists and is not empty.`);
@@ -54,23 +55,33 @@ export async function createProject(input) {
54
55
  'tsconfig.json': tsconfigJson(),
55
56
  '.env.example': envExample(options),
56
57
  '.gitignore': gitignore(),
58
+ '.dockerignore': dockerignore(),
57
59
  'README.md': readme(options),
58
60
  'pnpm-workspace.yaml': pnpmWorkspaceYaml(options),
59
61
  'src/env.ts': envTs(options),
60
62
  'src/app.ts': appTs(options),
61
63
  'src/routes.ts': routesTs(options),
62
64
  'src/server.ts': serverTs(),
65
+ 'src/dev.ts': devTs(),
63
66
  'tests/app.test.ts': appTest(options),
64
67
  ...(options.cli ? { 'bin/basalt.ts': basaltBin() } : {}),
65
68
  ...(options.mcp ? { '.mcp.json': mcpJson(options) } : {}),
66
69
  ...(options.ui ? uiFiles(options) : {}),
67
70
  };
71
+ let versions;
72
+ if (input.resolveLatest) {
73
+ const manifests = Object.keys(files).filter((path) => path === 'package.json' || path.endsWith('/package.json'));
74
+ versions = await resolveLatestVersions(collectDependencies(manifests.map((path) => files[path])), input.registry);
75
+ for (const path of manifests)
76
+ files[path] = applyVersions(files[path], versions.versions);
77
+ }
68
78
  for (const [path, content] of Object.entries(files)) {
69
79
  const target = join(dir, path);
70
80
  await mkdir(dirname(target), { recursive: true });
71
81
  await writeFile(target, content);
72
82
  }
73
- return { dir, files: Object.keys(files).sort(), options };
83
+ return { dir, files: Object.keys(files).sort(), options, ...(versions ? { versions } : {}) };
74
84
  }
85
+ export { THIRD_PARTY_VERSIONS, DEFAULT_REGISTRY, DEFAULT_MINIMUM_RELEASE_AGE_MINUTES, describeResolution, minimumReleaseAgeMinutes, resolveLatestVersions, registryUrl, } from './latest-versions.js';
75
86
  export { runWizard, validateProjectName, PRESETS, FEATURES } from './wizard.js';
76
87
  export { ttyPrompter, scriptedPrompter, WizardCancelledError } from './prompt.js';
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Scaffold-time dependency resolution: a freshly generated app should depend on
3
+ * the LATEST PUBLISHED version of every package, not on the ranges that were
4
+ * current the day this create-basalt release was built.
5
+ *
6
+ * - `@basaltkit/*` packages always take the registry's `latest` dist-tag — the
7
+ * framework's own release line is the whole point of the scaffold.
8
+ * - Third-party packages take `latest` only while it stays on the major the
9
+ * templates are written for (the major of the fallback range in
10
+ * {@link THIRD_PARTY_VERSIONS}). A new breaking major keeps the fallback range
11
+ * and is reported as a notice, so it can never silently produce a broken app.
12
+ * - Third-party packages also respect pnpm's `minimumReleaseAge` (pnpm 11
13
+ * defaults it to one day): `^<latest>` for a version published minutes ago is
14
+ * UNSATISFIABLE under that policy, so the first `pnpm install` would fail. A
15
+ * cheap `HEAD <registry>/<name>` reads the packument's `last-modified` (which
16
+ * is never older than the latest publish): older than the window proves the
17
+ * latest mature and `^<latest>` is written; newer — or no header / a failed
18
+ * probe — keeps the fallback range, from which pnpm picks the newest MATURE
19
+ * version itself. The full packument (the only per-version `time` source) is
20
+ * megabytes for packages like `@types/node`, so it is never fetched; a
21
+ * false "fresh" only costs a lower range floor. `@basaltkit/*` is never
22
+ * probed: the scaffolded pnpm-workspace.yaml excludes that scope.
23
+ * - Any registry failure (offline, timeout, non-2xx, malformed JSON, a bogus
24
+ * version string) keeps the embedded fallback range. The registry never fails
25
+ * a scaffold.
26
+ */
27
+ /**
28
+ * Fallback ranges for every third-party package the templates emit (API +
29
+ * web UI). The major of each range is also the TEMPLATE-COMPATIBLE major: the
30
+ * generated code is written for it, and `latest` is accepted only on that
31
+ * major. Bump a range to a new major only together with the templates.
32
+ */
33
+ export declare const THIRD_PARTY_VERSIONS: Readonly<Record<string, string>>;
34
+ export declare const DEFAULT_REGISTRY = "https://registry.npmjs.org";
35
+ /** pnpm 11's default `minimumReleaseAge`, in minutes (one day). */
36
+ export declare const DEFAULT_MINIMUM_RELEASE_AGE_MINUTES = 1440;
37
+ /** Packages whose latest is always taken, regardless of major. */
38
+ export declare const alwaysLatest: (name: string) => boolean;
39
+ /** The major the templates are written for, derived from the fallback range. */
40
+ export declare function compatibleMajor(fallbackRange: string): number | undefined;
41
+ export interface HeldBackVersion {
42
+ name: string;
43
+ /** The registry's latest version (a new major the templates don't target). */
44
+ latest: string;
45
+ /** The range kept instead. */
46
+ range: string;
47
+ }
48
+ export interface FreshVersion {
49
+ name: string;
50
+ /** The registry's latest version (same major, but possibly inside the release-age window). */
51
+ latest: string;
52
+ /** The range kept instead. */
53
+ range: string;
54
+ /** `recent`: published inside the window; `unknown`: its age could not be proven. */
55
+ reason: 'recent' | 'unknown';
56
+ }
57
+ export interface VersionResolution {
58
+ /** Final range per package name (resolved or fallback). */
59
+ versions: Record<string, string>;
60
+ /** Packages set to `^<latest>` from the registry. */
61
+ resolved: string[];
62
+ /** Packages that kept their fallback range because the registry failed. */
63
+ failed: string[];
64
+ /** Third-party packages whose latest is a new major — fallback kept. */
65
+ heldBack: HeldBackVersion[];
66
+ /**
67
+ * Third-party packages whose latest may be younger than the release-age
68
+ * window (pnpm `minimumReleaseAge`) — fallback kept so installs stay possible.
69
+ */
70
+ tooFresh: FreshVersion[];
71
+ /** Registry actually queried. */
72
+ registry: string;
73
+ }
74
+ export interface ResolveLatestOptions {
75
+ /** Injectable fetch (tests). Default: the global fetch. */
76
+ fetch?: typeof globalThis.fetch;
77
+ /** Registry base URL. Default: `npm_config_registry`, else the public npm registry. */
78
+ registry?: string;
79
+ /** Per-request timeout in ms. Default 5000. */
80
+ timeoutMs?: number;
81
+ /** Cap on the whole resolution in ms. Default 15000. */
82
+ overallTimeoutMs?: number;
83
+ /** Requests in flight at once. Default 8. */
84
+ concurrency?: number;
85
+ /**
86
+ * Release-age window in minutes (pnpm `minimumReleaseAge`): a third-party
87
+ * latest not provably older than this keeps its fallback range. Default:
88
+ * `pnpm_config_minimum_release_age` / `npm_config_minimum_release_age`, else
89
+ * 1440 (pnpm 11's default). `0` disables the check (no probe requests).
90
+ */
91
+ minimumReleaseAge?: number;
92
+ /** Clock used when the registry sends no `Date` header (tests). Default: Date.now. */
93
+ now?: () => number;
94
+ }
95
+ /**
96
+ * The release-age window in minutes: an explicit option, else the pnpm/npm
97
+ * config env var (a `pnpm create` exports its config), else pnpm 11's default.
98
+ */
99
+ export declare function minimumReleaseAgeMinutes(explicit?: number, env?: NodeJS.ProcessEnv): number;
100
+ /**
101
+ * The registry to query: an explicit option, else `npm_config_registry` (npm
102
+ * and pnpm export it to `create` binaries, so a private mirror is honored),
103
+ * else the public registry. Trailing slashes are normalized; anything that is
104
+ * not an http(s) URL falls back to the default.
105
+ */
106
+ export declare function registryUrl(explicit?: string, env?: NodeJS.ProcessEnv): string;
107
+ /** `<registry>/<name>/latest`, with a scoped name's `/` encoded as `%2f`. */
108
+ export declare const latestUrl: (registry: string, name: string) => string;
109
+ /** `<registry>/<name>` — the packument (probed with HEAD for its `last-modified`). */
110
+ export declare const packumentUrl: (registry: string, name: string) => string;
111
+ /**
112
+ * Resolves the latest published version of every package in `fallbacks`
113
+ * (name → fallback range) through a small pool of parallel requests. Never throws: a package the registry
114
+ * cannot answer for keeps its fallback range and is listed in `failed`.
115
+ */
116
+ export declare function resolveLatestVersions(fallbacks: Readonly<Record<string, string>>, options?: ResolveLatestOptions): Promise<VersionResolution>;
117
+ /**
118
+ * Rewrites the dependency ranges of a generated package.json with the
119
+ * resolved versions (keys, order and every other field untouched).
120
+ */
121
+ export declare function applyVersions(packageJsonText: string, versions: Readonly<Record<string, string>>): string;
122
+ /** Every dependency/devDependency name → range declared in the given package.json texts. */
123
+ export declare function collectDependencies(packageJsonTexts: readonly string[]): Record<string, string>;
124
+ /** Human-readable lines summarizing a resolution (for the CLI). */
125
+ export declare function describeResolution(resolution: VersionResolution): string[];