create-basalt 1.5.0 → 1.7.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
@@ -104,6 +104,7 @@ Behavior notes (faithful to the code):
104
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.
105
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
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).
107
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.
108
109
  - At the end, it prints the created files and the "Next steps" appropriate to your choices.
109
110
 
@@ -134,12 +135,12 @@ Always:
134
135
  my-app/
135
136
  ├── package.json # scripts: dev, start, test, typecheck (+ basalt with --cli)
136
137
  ├── tsconfig.json # strict TypeScript, ESM
137
- ├── .env.example # PORT, HOST, LOG_LEVEL, NODE_ENV (+ APP_SECRET with auth)
138
+ ├── .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
138
139
  ├── .gitignore
139
140
  ├── README.md # instructions adapted to the chosen options
140
- ├── 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
141
142
  ├── src/
142
- │ ├── env.ts # environment variables validated with Zod (@basaltkit/env)
143
+ │ ├── env.ts # environment variables validated with Zod (@basaltkit/env), app-prefixed
143
144
  │ ├── app.ts # buildApp() with the chosen plugins
144
145
  │ ├── routes.ts # GET / (friendly index) and GET /health
145
146
  │ └── server.ts # startup + clean shutdown on SIGINT/SIGTERM
@@ -166,6 +167,38 @@ pnpm basalt routes # registered HTTP routes
166
167
  pnpm basalt make:resource Project # generates schema → repository → service → plugin → routes → test
167
168
  ```
168
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`, an app reading the generic name boots against that value and only fails on the first request that touches it.
180
+
181
+ 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.
182
+
183
+ ```ts
184
+ // src/env.ts (generated)
185
+ export const env = defineEnv(
186
+ { PORT: z.coerce.number().default(3000), /* … */ },
187
+ { prefix: 'MY_SAAS' },
188
+ )
189
+ ```
190
+
191
+ ```bash
192
+ # .env.example (generated)
193
+ MY_SAAS_PORT=3000
194
+ MY_SAAS_HOST=0.0.0.0
195
+ MY_SAAS_LOG_LEVEL=info
196
+ NODE_ENV=development # never prefixed — a Node-wide convention
197
+ # MY_SAAS_APP_SECRET= # with auth
198
+ ```
199
+
200
+ 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).
201
+
169
202
  ### Programmatic usage (Advanced)
170
203
 
171
204
  The package also exports the API used by the executable, for your own scripts:
@@ -208,7 +241,7 @@ Exported from `create-basalt` (in addition to the `create-basalt` executable):
208
241
  | `cli` | `boolean` | No | `false` | Generate the `basalt` CLI |
209
242
  | `mcp` | `boolean` | No | `false` | Expose read-only routes as MCP tools at `/mcp` |
210
243
  | `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) |
211
- | `registry` | `ResolveLatestOptions` | No | — | `{ fetch?, registry?, timeoutMs?, overallTimeoutMs?, concurrency? }` — injectable fetch (tests), registry URL (default `npm_config_registry` or `https://registry.npmjs.org`), timeouts (5 s per request, 15 s overall) |
244
+ | `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) |
212
245
 
213
246
  `CreateProjectResult`:
214
247
 
@@ -217,7 +250,7 @@ Exported from `create-basalt` (in addition to the `create-basalt` executable):
217
250
  | `dir` | `string` | Absolute path of the created folder |
218
251
  | `files` | `string[]` | Files written (relative, sorted) |
219
252
  | `options` | `ProjectOptions` | The options actually applied (with defaults resolved) |
220
- | `versions` | `VersionResolution \| undefined` | With `resolveLatest`: `{ versions, resolved, failed, heldBack, registry }` — the final ranges, which packages got `^<latest>`, which kept the fallback after a registry failure, and third-party packages held back because their latest is a new major |
253
+ | `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' }`) |
221
254
 
222
255
  Throws `TargetNotEmptyError` if the destination folder exists and isn't empty.
223
256
 
@@ -255,7 +288,13 @@ Outside an interactive terminal (no TTY) the questions are skipped. Always pass
255
288
  Automatic installation failed (network, Node version, etc.). Go into the folder and run `pnpm install` (or the indicated manager) to see the real error.
256
289
 
257
290
  **I started the app and `GET /auth/login` gives a secret error.**
258
- 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.
291
+ With auth on, `src/env.ts` requires `APP_SECRET` — read as `MY_SAAS_APP_SECRET`, with at least 32 characters (`secret()` supplies a development default only when `NODE_ENV` is explicitly `development`/`test`). Copy `.env.example` to `.env` and set your own secret before going to production.
292
+
293
+ **My app connects to the wrong database / port.**
294
+ A variable exported in your shell beats `--env-file`. The generated `src/env.ts` already reads app-prefixed names (`MY_SAAS_PORT`), so set those rather than the generic ones — the bare names are only a fallback. Check `env | grep DATABASE_URL`, and see *Environment variables and `--env-file`*.
295
+
296
+ **`pnpm basalt …` starts by running `pnpm install` (or fails offline).**
297
+ pnpm 11's `verifyDepsBeforeRun` (default `install`). Run `node_modules/.bin/tsx bin/basalt.ts …` instead, or set `verifyDepsBeforeRun: warn` in `pnpm-workspace.yaml`.
259
298
 
260
299
  **I want to change my mind after generating (e.g. add billing).**
261
300
  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).
package/dist/index.d.ts CHANGED
@@ -60,6 +60,6 @@ export declare function resolveRunDefaults(input: {
60
60
  };
61
61
  /** Generates a ready-to-run Basalt app. Does not install dependencies. */
62
62
  export declare function createProject(input: CreateProjectInput): Promise<CreateProjectResult>;
63
- export { THIRD_PARTY_VERSIONS, DEFAULT_REGISTRY, describeResolution, resolveLatestVersions, registryUrl, type HeldBackVersion, type ResolveLatestOptions, type VersionResolution, } from './latest-versions.js';
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';
64
64
  export { runWizard, validateProjectName, PRESETS, FEATURES, type WizardResult, type WizardOptions, type FeatureKey } from './wizard.js';
65
65
  export { ttyPrompter, scriptedPrompter, WizardCancelledError, type Prompter, type Choice } from './prompt.js';
package/dist/index.js CHANGED
@@ -82,6 +82,6 @@ export async function createProject(input) {
82
82
  }
83
83
  return { dir, files: Object.keys(files).sort(), options, ...(versions ? { versions } : {}) };
84
84
  }
85
- export { THIRD_PARTY_VERSIONS, DEFAULT_REGISTRY, describeResolution, resolveLatestVersions, registryUrl, } from './latest-versions.js';
85
+ export { THIRD_PARTY_VERSIONS, DEFAULT_REGISTRY, DEFAULT_MINIMUM_RELEASE_AGE_MINUTES, describeResolution, minimumReleaseAgeMinutes, resolveLatestVersions, registryUrl, } from './latest-versions.js';
86
86
  export { runWizard, validateProjectName, PRESETS, FEATURES } from './wizard.js';
87
87
  export { ttyPrompter, scriptedPrompter, WizardCancelledError } from './prompt.js';
@@ -9,6 +9,17 @@
9
9
  * templates are written for (the major of the fallback range in
10
10
  * {@link THIRD_PARTY_VERSIONS}). A new breaking major keeps the fallback range
11
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.
12
23
  * - Any registry failure (offline, timeout, non-2xx, malformed JSON, a bogus
13
24
  * version string) keeps the embedded fallback range. The registry never fails
14
25
  * a scaffold.
@@ -21,6 +32,8 @@
21
32
  */
22
33
  export declare const THIRD_PARTY_VERSIONS: Readonly<Record<string, string>>;
23
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;
24
37
  /** Packages whose latest is always taken, regardless of major. */
25
38
  export declare const alwaysLatest: (name: string) => boolean;
26
39
  /** The major the templates are written for, derived from the fallback range. */
@@ -32,6 +45,15 @@ export interface HeldBackVersion {
32
45
  /** The range kept instead. */
33
46
  range: string;
34
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
+ }
35
57
  export interface VersionResolution {
36
58
  /** Final range per package name (resolved or fallback). */
37
59
  versions: Record<string, string>;
@@ -41,6 +63,11 @@ export interface VersionResolution {
41
63
  failed: string[];
42
64
  /** Third-party packages whose latest is a new major — fallback kept. */
43
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[];
44
71
  /** Registry actually queried. */
45
72
  registry: string;
46
73
  }
@@ -55,7 +82,21 @@ export interface ResolveLatestOptions {
55
82
  overallTimeoutMs?: number;
56
83
  /** Requests in flight at once. Default 8. */
57
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;
58
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;
59
100
  /**
60
101
  * The registry to query: an explicit option, else `npm_config_registry` (npm
61
102
  * and pnpm export it to `create` binaries, so a private mirror is honored),
@@ -65,6 +106,8 @@ export interface ResolveLatestOptions {
65
106
  export declare function registryUrl(explicit?: string, env?: NodeJS.ProcessEnv): string;
66
107
  /** `<registry>/<name>/latest`, with a scoped name's `/` encoded as `%2f`. */
67
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;
68
111
  /**
69
112
  * Resolves the latest published version of every package in `fallbacks`
70
113
  * (name → fallback range) through a small pool of parallel requests. Never throws: a package the registry
@@ -9,6 +9,17 @@
9
9
  * templates are written for (the major of the fallback range in
10
10
  * {@link THIRD_PARTY_VERSIONS}). A new breaking major keeps the fallback range
11
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.
12
23
  * - Any registry failure (offline, timeout, non-2xx, malformed JSON, a bogus
13
24
  * version string) keeps the embedded fallback range. The registry never fails
14
25
  * a scaffold.
@@ -36,6 +47,9 @@ export const THIRD_PARTY_VERSIONS = {
36
47
  zod: '^4.6.5',
37
48
  };
38
49
  export const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
50
+ /** pnpm 11's default `minimumReleaseAge`, in minutes (one day). */
51
+ export const DEFAULT_MINIMUM_RELEASE_AGE_MINUTES = 1440;
52
+ const ABBREVIATED_METADATA = 'application/vnd.npm.install-v1+json';
39
53
  const PER_REQUEST_TIMEOUT_MS = 5_000;
40
54
  const OVERALL_TIMEOUT_MS = 15_000;
41
55
  /**
@@ -54,6 +68,20 @@ export function compatibleMajor(fallbackRange) {
54
68
  const match = CARET_RANGE.exec(fallbackRange);
55
69
  return match ? Number(match[1]) : undefined;
56
70
  }
71
+ /**
72
+ * The release-age window in minutes: an explicit option, else the pnpm/npm
73
+ * config env var (a `pnpm create` exports its config), else pnpm 11's default.
74
+ */
75
+ export function minimumReleaseAgeMinutes(explicit, env = process.env) {
76
+ if (explicit !== undefined && Number.isFinite(explicit) && explicit >= 0)
77
+ return explicit;
78
+ for (const key of ['pnpm_config_minimum_release_age', 'npm_config_minimum_release_age']) {
79
+ const raw = env[key];
80
+ if (raw !== undefined && /^\d+$/.test(raw.trim()))
81
+ return Number(raw.trim());
82
+ }
83
+ return DEFAULT_MINIMUM_RELEASE_AGE_MINUTES;
84
+ }
57
85
  /**
58
86
  * The registry to query: an explicit option, else `npm_config_registry` (npm
59
87
  * and pnpm export it to `create` binaries, so a private mirror is honored),
@@ -79,6 +107,34 @@ export function registryUrl(explicit, env = process.env) {
79
107
  }
80
108
  /** `<registry>/<name>/latest`, with a scoped name's `/` encoded as `%2f`. */
81
109
  export const latestUrl = (registry, name) => `${registry}/${name.replaceAll('/', '%2f')}/latest`;
110
+ /** `<registry>/<name>` — the packument (probed with HEAD for its `last-modified`). */
111
+ export const packumentUrl = (registry, name) => `${registry}/${name.replaceAll('/', '%2f')}`;
112
+ /**
113
+ * Whether `name`'s latest is provably older than `windowMs`. A packument's
114
+ * `last-modified` is never older than its latest publish, so an old header
115
+ * proves maturity. Age is measured against the registry's own `Date` header
116
+ * when present (immune to local clock skew). Never throws.
117
+ */
118
+ async function probeAge(fetchImpl, registry, name, windowMs, now, signal) {
119
+ try {
120
+ const response = await fetchImpl(packumentUrl(registry, name), {
121
+ method: 'HEAD',
122
+ headers: { accept: ABBREVIATED_METADATA },
123
+ signal,
124
+ });
125
+ if (!response.ok)
126
+ return 'unknown';
127
+ const modified = Date.parse(response.headers.get('last-modified') ?? '');
128
+ if (Number.isNaN(modified))
129
+ return 'unknown';
130
+ const serverNow = Date.parse(response.headers.get('date') ?? '');
131
+ const reference = Number.isNaN(serverNow) ? now() : serverNow;
132
+ return reference - modified >= windowMs ? 'mature' : 'recent';
133
+ }
134
+ catch {
135
+ return 'unknown';
136
+ }
137
+ }
82
138
  async function lookupLatest(fetchImpl, registry, name, signal) {
83
139
  try {
84
140
  const response = await fetchImpl(latestUrl(registry, name), {
@@ -126,19 +182,37 @@ export async function resolveLatestVersions(fallbacks, options = {}) {
126
182
  const resolved = [];
127
183
  const failed = [];
128
184
  const heldBack = [];
185
+ const tooFresh = [];
129
186
  const names = Object.keys(fallbacks).sort();
187
+ const windowMs = minimumReleaseAgeMinutes(options.minimumReleaseAge) * 60_000;
188
+ const now = options.now ?? Date.now;
189
+ const timeoutMs = options.timeoutMs ?? PER_REQUEST_TIMEOUT_MS;
130
190
  if (typeof fetchImpl !== 'function') {
131
- return { versions, resolved, failed: names, heldBack, registry };
191
+ return { versions, resolved, failed: names, heldBack, tooFresh, registry };
132
192
  }
193
+ /** A third-party latest on the template-compatible major (else undefined). */
194
+ const compatible = (name, version) => {
195
+ if (alwaysLatest(name))
196
+ return true;
197
+ const major = compatibleMajor(fallbacks[name]);
198
+ return major !== undefined && Number(version.split('.')[0]) === major;
199
+ };
133
200
  const overall = new AbortController();
134
201
  const cap = setTimeout(() => overall.abort(), options.overallTimeoutMs ?? OVERALL_TIMEOUT_MS);
135
202
  try {
136
203
  const latest = [];
204
+ const age = [];
137
205
  let next = 0;
138
206
  const worker = async () => {
139
207
  while (next < names.length) {
140
208
  const i = next++;
141
- latest[i] = await fetchLatest(fetchImpl, registry, names[i], options.timeoutMs ?? PER_REQUEST_TIMEOUT_MS, overall.signal);
209
+ const name = names[i];
210
+ const version = await fetchLatest(fetchImpl, registry, name, timeoutMs, overall.signal);
211
+ latest[i] = version;
212
+ age[i] =
213
+ version === undefined || alwaysLatest(name) || windowMs === 0 || !compatible(name, version)
214
+ ? 'mature'
215
+ : await probeAge(fetchImpl, registry, name, windowMs, now, AbortSignal.any([AbortSignal.timeout(timeoutMs), overall.signal]));
142
216
  }
143
217
  };
144
218
  const pool = Math.max(1, Math.min(options.concurrency ?? CONCURRENCY, names.length));
@@ -150,12 +224,14 @@ export async function resolveLatestVersions(fallbacks, options = {}) {
150
224
  failed.push(name);
151
225
  return;
152
226
  }
153
- if (!alwaysLatest(name)) {
154
- const major = compatibleMajor(fallback);
155
- if (major === undefined || Number(version.split('.')[0]) !== major) {
156
- heldBack.push({ name, latest: version, range: fallback });
157
- return;
158
- }
227
+ if (!compatible(name, version)) {
228
+ heldBack.push({ name, latest: version, range: fallback });
229
+ return;
230
+ }
231
+ const verdict = age[i];
232
+ if (verdict === 'recent' || verdict === 'unknown') {
233
+ tooFresh.push({ name, latest: version, range: fallback, reason: verdict });
234
+ return;
159
235
  }
160
236
  versions[name] = `^${version}`;
161
237
  resolved.push(name);
@@ -164,7 +240,7 @@ export async function resolveLatestVersions(fallbacks, options = {}) {
164
240
  finally {
165
241
  clearTimeout(cap);
166
242
  }
167
- return { versions, resolved, failed, heldBack, registry };
243
+ return { versions, resolved, failed, heldBack, tooFresh, registry };
168
244
  }
169
245
  /**
170
246
  * Rewrites the dependency ranges of a generated package.json with the
@@ -202,6 +278,14 @@ export function describeResolution(resolution) {
202
278
  for (const held of resolution.heldBack) {
203
279
  lines.push(`Note: ${held.name} ${held.latest} is a new major this template does not target yet — kept ${held.range}.`);
204
280
  }
281
+ const recent = resolution.tooFresh.filter((entry) => entry.reason === 'recent');
282
+ const unknown = resolution.tooFresh.filter((entry) => entry.reason === 'unknown');
283
+ for (const fresh of recent) {
284
+ lines.push(`Note: ${fresh.name} ${fresh.latest} was published inside pnpm's minimumReleaseAge window — kept ${fresh.range} (pnpm installs the newest mature version).`);
285
+ }
286
+ if (unknown.length > 0) {
287
+ lines.push(`Note: could not prove the release age of ${unknown.map((entry) => `${entry.name}@${entry.latest}`).join(', ')} — kept the bundled range(s) so minimumReleaseAge cannot block the install.`);
288
+ }
205
289
  if (resolution.failed.length > 0) {
206
290
  lines.push(`Warning: could not reach ${resolution.registry} for ${resolution.failed.length} package(s) — used the bundled fallback ranges for: ${resolution.failed.join(', ')}.`);
207
291
  }
@@ -31,6 +31,12 @@ export declare const enforcesMembership: (options: ProjectOptions) => boolean;
31
31
  export declare function packageJson(options: ProjectOptions): string;
32
32
  export declare function tsconfigJson(): string;
33
33
  export declare function envTs(options: ProjectOptions): string;
34
+ /**
35
+ * An app-specific env-var prefix derived from the project name
36
+ * (`my-saas` → `MY_SAAS`), suggested for variables whose generic names
37
+ * (`DATABASE_URL`, …) other projects export too.
38
+ */
39
+ export declare function envPrefix(name: string): string;
34
40
  export declare function envExample(options: ProjectOptions): string;
35
41
  export declare function appTs(options: ProjectOptions): string;
36
42
  export declare function routesTs(options: ProjectOptions): string;
@@ -65,5 +71,11 @@ export declare function gitignore(): string;
65
71
  * msgpackr-extract (optional native accelerator via BullMQ) is declined —
66
72
  * msgpackr falls back to pure JS. With --ui, `web` is a workspace member so
67
73
  * its own dependencies resolve.
74
+ *
75
+ * pnpm 11 notes (BK-002), spelled out in the file because both bite silently:
76
+ * `minimumReleaseAgeExclude` is evaluated first-match-wins BY PACKAGE NAME, so
77
+ * per-version exclusions of one package must be a single `||` union entry; and
78
+ * `verifyDepsBeforeRun` defaults to `install`, so any `pnpm <script>` may run
79
+ * `pnpm install` first — `warn` is offered commented out, never set for you.
68
80
  */
69
81
  export declare function pnpmWorkspaceYaml(options: ProjectOptions): string;
package/dist/templates.js CHANGED
@@ -46,7 +46,8 @@ export function packageJson(options) {
46
46
  basalt.push('@basaltkit/mcp');
47
47
  if (options.cli) {
48
48
  // Runtime deps: app.ts uses commandsPlugin (@basaltkit/cli); prisma powers
49
- // prismaPlugin + `basalt prisma:sync`. @basaltkit/generator is dev-only (below).
49
+ // `basalt prisma:sync` (and prismaPlugin once the app adds a PrismaClient —
50
+ // the scaffold wires no database itself). @basaltkit/generator is dev-only (below).
50
51
  basalt.push('@basaltkit/cli', '@basaltkit/prisma');
51
52
  }
52
53
  const dependencies = { zod: thirdPartyVersionOf('zod') };
@@ -104,33 +105,84 @@ export function tsconfigJson() {
104
105
  }, null, 2)}\n`;
105
106
  }
106
107
  export function envTs(options) {
108
+ const prefix = envPrefix(options.name);
107
109
  return `import { defineEnv${options.auth ? ', secret' : ''} } from '@basaltkit/env'
108
110
  import { LOG_LEVELS } from '@basaltkit/logger'
109
111
  import { z } from 'zod'
110
112
 
111
- export const env = defineEnv({
112
- PORT: z.coerce.number().default(3000),
113
- HOST: z.string().default('0.0.0.0'),
114
- // Typed against the logger's LogLevel union — a free-form string here fails
115
- // \`pnpm typecheck\` where loggerPlugin({ level }) consumes it.
116
- LOG_LEVEL: z.enum(LOG_LEVELS).default('info'),
117
- // Unset counts as production (fail-closed); \`pnpm dev\` sets development.
118
- NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),${options.auth
113
+ export const env = defineEnv(
114
+ {
115
+ PORT: z.coerce.number().default(3000),
116
+ HOST: z.string().default('0.0.0.0'),
117
+ // Typed against the logger's LogLevel union — a free-form string here fails
118
+ // \`pnpm typecheck\` where loggerPlugin({ level }) consumes it.
119
+ LOG_LEVEL: z.enum(LOG_LEVELS).default('info'),
120
+ // Unset counts as production (fail-closed); \`pnpm dev\` sets development.
121
+ // NODE_ENV is a Node-wide convention and is never prefixed.
122
+ NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),${options.auth
119
123
  ? `
120
- // Signs JWTs and sessions. secret() is fail-closed: required unless NODE_ENV
121
- // is explicitly development/test (no fallback when NODE_ENV is unset),
122
- // rejected if it looks like a placeholder. \`pnpm dev\` uses devDefault.
123
- APP_SECRET: secret({ minLength: 32, devDefault: 'dev-only-insecure-secret-please-change-me' }),`
124
+ // Signs JWTs and sessions. secret() is fail-closed: required unless NODE_ENV
125
+ // is explicitly development/test (no fallback when NODE_ENV is unset),
126
+ // rejected if it looks like a placeholder. \`pnpm dev\` uses devDefault.
127
+ APP_SECRET: secret({ minLength: 32, devDefault: 'dev-only-insecure-secret-please-change-me' }),`
124
128
  : ''}
125
- })
129
+ },
130
+ {
131
+ // Every variable is read as ${prefix}_<NAME> first (${prefix}_PORT before PORT),
132
+ // falling back to the bare name. \`--env-file\` never overrides a variable
133
+ // already exported in your shell, so without the prefix another project's
134
+ // exported PORT / DATABASE_URL would silently win. The keys above stay bare:
135
+ // the rest of the app still reads env.PORT.
136
+ // To require the prefixed names (no bare fallback at all), write:
137
+ // prefix: { value: '${prefix}', fallback: false }
138
+ prefix: '${prefix}',
139
+ },
140
+ )
126
141
  `;
127
142
  }
143
+ /**
144
+ * An app-specific env-var prefix derived from the project name
145
+ * (`my-saas` → `MY_SAAS`), suggested for variables whose generic names
146
+ * (`DATABASE_URL`, …) other projects export too.
147
+ */
148
+ export function envPrefix(name) {
149
+ let base = name
150
+ .replace(/^@[^/]+\//, '')
151
+ .toUpperCase()
152
+ .replace(/[^A-Z0-9]+/g, '_');
153
+ // Trim underscores in linear time (an anchored `_+$` regex backtracks).
154
+ let start = 0;
155
+ let end = base.length;
156
+ while (start < end && base[start] === '_')
157
+ start++;
158
+ while (end > start && base[end - 1] === '_')
159
+ end--;
160
+ base = base.slice(start, end);
161
+ return base === '' || /^\d/.test(base) ? `APP_${base}` : base;
162
+ }
128
163
  export function envExample(options) {
129
- return `PORT=3000
130
- HOST=0.0.0.0
131
- LOG_LEVEL=info
164
+ const prefix = envPrefix(options.name);
165
+ return `# Nothing loads this file for you: copy it to .env and start with
166
+ # \`node --env-file=.env\` / \`tsx --env-file=.env\`, or export the variables.
167
+ # --env-file NEVER overrides a variable already exported in your shell: if
168
+ # another project exported PORT or DATABASE_URL, an app reading the generic
169
+ # name would silently use THAT one. That is why src/env.ts reads app-prefixed
170
+ # names — ${prefix}_PORT wins over a stray PORT. The bare names still work as a
171
+ # fallback, so an existing deployment exporting them keeps booting.
172
+ ${prefix}_PORT=3000
173
+ ${prefix}_HOST=0.0.0.0
174
+ ${prefix}_LOG_LEVEL=info
175
+ # NODE_ENV is a Node-wide convention and is never prefixed.
132
176
  NODE_ENV=development
133
- ${options.auth ? '# Required unless NODE_ENV is development/test — `pnpm start` refuses to boot\n# without it. Generate a strong one: openssl rand -base64 48\n# APP_SECRET=\n' : ''}`;
177
+ ${options.auth
178
+ ? `# Required unless NODE_ENV is development/test — \`pnpm start\` refuses to boot
179
+ # without it. Generate a strong one: openssl rand -base64 48
180
+ # ${prefix}_APP_SECRET=
181
+ `
182
+ : ''}# When you add a database, declare DATABASE_URL in src/env.ts and set it here
183
+ # under the prefixed name:
184
+ # ${prefix}_DATABASE_URL=postgres://user:pass@localhost:5432/${options.name.replace(/^@[^/]+\//, '')}
185
+ `;
134
186
  }
135
187
  export function appTs(options) {
136
188
  const imports = [
@@ -427,6 +479,28 @@ pnpm install
427
479
  pnpm dev # API on http://localhost:3000
428
480
  pnpm test
429
481
  \`\`\`
482
+
483
+ ## Environment
484
+
485
+ \`src/env.ts\` validates \`process.env\` — nothing loads \`.env\` for you. Copy
486
+ \`.env.example\` to \`.env\` and launch with \`--env-file=.env\` (Node/tsx), or
487
+ export the variables.
488
+
489
+ The variables are **app-prefixed**: \`${envPrefix(options.name)}_PORT\`,
490
+ \`${envPrefix(options.name)}_HOST\`, \`${envPrefix(options.name)}_LOG_LEVEL\`${options.auth ? `, \`${envPrefix(options.name)}_APP_SECRET\`` : ''}. \`NODE_ENV\` is never prefixed.
491
+
492
+ Why: **\`--env-file\` never overrides a variable that is already exported.** In a
493
+ shell where another project exported \`DATABASE_URL\` (or \`PORT\`), an app reading
494
+ the generic name boots against THAT value and only fails on the first request
495
+ that touches it. \`src/env.ts\` therefore passes \`prefix: '${envPrefix(options.name)}'\` to
496
+ \`defineEnv\`: each variable is read as \`${envPrefix(options.name)}_<NAME>\` first and
497
+ **falls back** to the bare \`<NAME>\`, so a deployment that already exports the
498
+ generic names keeps booting while a stray one in your shell loses. Add a
499
+ database and \`DATABASE_URL\` is read as \`${envPrefix(options.name)}_DATABASE_URL\`.
500
+
501
+ To drop the fallback entirely (prefixed names only), write
502
+ \`prefix: { value: '${envPrefix(options.name)}', fallback: false }\`. When in doubt about what
503
+ your shell exports: \`env | grep DATABASE_URL\`.
430
504
  ${options.cli
431
505
  ? `
432
506
  ## The \`basalt\` CLI
@@ -440,6 +514,18 @@ pnpm basalt make:service Project # a single artifact (--force to overwrite)
440
514
 
441
515
  Generated resources land in \`src/modules/<name>/\`. Register the generated
442
516
  plugin in \`src/app.ts\` to wire it up.
517
+
518
+ With pnpm 11, \`pnpm basalt …\` first verifies dependencies
519
+ (\`verifyDepsBeforeRun\`, default \`install\`): when any workspace project is out
520
+ of sync it runs \`pnpm install\` — network included — before the command. To
521
+ run the CLI without that check, call it directly:
522
+
523
+ \`\`\`bash
524
+ node_modules/.bin/tsx bin/basalt.ts make:resource Project
525
+ \`\`\`
526
+
527
+ or set \`verifyDepsBeforeRun: warn\` in \`pnpm-workspace.yaml\` (a conscious
528
+ choice: you then run \`pnpm install\` yourself after dependency changes).
443
529
  `
444
530
  : ''}${options.ui
445
531
  ? `
@@ -535,6 +621,12 @@ dist/
535
621
  * msgpackr-extract (optional native accelerator via BullMQ) is declined —
536
622
  * msgpackr falls back to pure JS. With --ui, `web` is a workspace member so
537
623
  * its own dependencies resolve.
624
+ *
625
+ * pnpm 11 notes (BK-002), spelled out in the file because both bite silently:
626
+ * `minimumReleaseAgeExclude` is evaluated first-match-wins BY PACKAGE NAME, so
627
+ * per-version exclusions of one package must be a single `||` union entry; and
628
+ * `verifyDepsBeforeRun` defaults to `install`, so any `pnpm <script>` may run
629
+ * `pnpm install` first — `warn` is offered commented out, never set for you.
538
630
  */
539
631
  export function pnpmWorkspaceYaml(options) {
540
632
  return `${options.ui
@@ -548,5 +640,16 @@ export function pnpmWorkspaceYaml(options) {
548
640
  # pnpm's minimumReleaseAge policy so \`pnpm up\` is never blocked on a fresh release.
549
641
  minimumReleaseAgeExclude:
550
642
  - '@basaltkit/*'
643
+ # To let specific versions of ONE package bypass the policy, write ONE entry
644
+ # with a \`||\` union. pnpm evaluates this list first match wins by package
645
+ # name, so a second '<name>@<version>' entry for the same package is ignored:
646
+ # - '@types/node@22.20.4 || 26.6.2'
647
+
648
+ # pnpm 11 checks dependencies before every \`pnpm <script>\` / \`pnpm exec\`
649
+ # (verifyDepsBeforeRun defaults to \`install\`): when node_modules is out of sync
650
+ # with ANY workspace project's manifest, it runs \`pnpm install\` first — network
651
+ # and supply-chain checks included. To opt out consciously (you then run
652
+ # \`pnpm install\` yourself after dependency changes), uncomment:
653
+ # verifyDepsBeforeRun: warn
551
654
  `;
552
655
  }
package/dist/versions.js CHANGED
@@ -5,18 +5,18 @@ export const SCAFFOLD_VERSIONS = {
5
5
  '@basaltkit/admin': '^2.0.0',
6
6
  '@basaltkit/admin-shadcn': '^1.1.0',
7
7
  '@basaltkit/ai-mcp': '^0.1.0',
8
- '@basaltkit/auth': '^3.0.0',
8
+ '@basaltkit/auth': '^3.1.0',
9
9
  '@basaltkit/cli': '^1.2.0',
10
10
  '@basaltkit/config': '^1.1.0',
11
- '@basaltkit/core': '^1.3.0',
12
- '@basaltkit/env': '^3.0.0',
13
- '@basaltkit/events': '^1.2.0',
14
- '@basaltkit/fastify': '^2.1.0',
15
- '@basaltkit/generator': '^1.4.0',
11
+ '@basaltkit/core': '^1.4.0',
12
+ '@basaltkit/env': '^3.1.0',
13
+ '@basaltkit/events': '^1.3.0',
14
+ '@basaltkit/fastify': '^2.2.0',
15
+ '@basaltkit/generator': '^1.5.0',
16
16
  '@basaltkit/logger': '^1.3.0',
17
17
  '@basaltkit/mcp': '^3.0.0',
18
- '@basaltkit/prisma': '^2.0.0',
19
- '@basaltkit/sdk': '^2.0.0',
18
+ '@basaltkit/prisma': '^2.1.0',
19
+ '@basaltkit/sdk': '^2.1.0',
20
20
  '@basaltkit/subscriptions': '^4.0.0',
21
21
  '@basaltkit/teams': '^3.0.0',
22
22
  '@basaltkit/tenancy': '^2.0.0',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-basalt",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "engines": {
5
5
  "node": ">=22.5.0"
6
6
  },
@@ -21,26 +21,26 @@
21
21
  "dist"
22
22
  ],
23
23
  "devDependencies": {
24
- "@types/node": "^26.5.1",
24
+ "@types/node": "^26.6.1",
25
25
  "typescript": "^7.0.2",
26
- "vitest": "^5.0.0",
27
- "zod": "^4.5.4",
28
- "@basaltkit/auth": "^3.0.0",
29
- "@basaltkit/cli": "^1.2.5",
26
+ "vitest": "^5.0.1",
27
+ "zod": "^4.6.5",
30
28
  "@basaltkit/config": "^1.1.2",
31
- "@basaltkit/core": "^1.3.1",
32
- "@basaltkit/env": "^3.0.0",
33
- "@basaltkit/events": "^1.2.0",
34
- "@basaltkit/fastify": "^2.1.0",
35
- "@basaltkit/generator": "^1.4.0",
29
+ "@basaltkit/auth": "^3.1.0",
30
+ "@basaltkit/cli": "^1.2.5",
31
+ "@basaltkit/env": "^3.1.0",
32
+ "@basaltkit/core": "^1.4.0",
33
+ "@basaltkit/events": "^1.3.0",
34
+ "@basaltkit/fastify": "^2.2.0",
35
+ "@basaltkit/generator": "^1.5.0",
36
36
  "@basaltkit/logger": "^1.3.0",
37
37
  "@basaltkit/mcp": "^3.0.0",
38
- "@basaltkit/prisma": "^2.0.0",
39
38
  "@basaltkit/subscriptions": "^4.0.0",
40
- "@basaltkit/teams": "^3.0.0",
39
+ "@basaltkit/teams": "^3.0.1",
41
40
  "@basaltkit/tenancy": "^2.0.0",
42
- "@basaltkit/testing": "^2.0.1",
43
- "@basaltkit/tsconfig": "^0.24.0"
41
+ "@basaltkit/tsconfig": "^0.24.0",
42
+ "@basaltkit/testing": "^2.0.2",
43
+ "@basaltkit/prisma": "^2.1.0"
44
44
  },
45
45
  "publishConfig": {
46
46
  "access": "public"