create-basalt 1.5.0 → 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
@@ -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,10 +135,10 @@ 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 # PORT, HOST, LOG_LEVEL, NODE_ENV (+ 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
143
  │ ├── env.ts # environment variables validated with Zod (@basaltkit/env)
143
144
  │ ├── app.ts # buildApp() with the chosen plugins
@@ -166,6 +167,17 @@ 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`, 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
+
169
181
  ### Programmatic usage (Advanced)
170
182
 
171
183
  The package also exports the API used by the executable, for your own scripts:
@@ -208,7 +220,7 @@ Exported from `create-basalt` (in addition to the `create-basalt` executable):
208
220
  | `cli` | `boolean` | No | `false` | Generate the `basalt` CLI |
209
221
  | `mcp` | `boolean` | No | `false` | Expose read-only routes as MCP tools at `/mcp` |
210
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) |
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) |
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) |
212
224
 
213
225
  `CreateProjectResult`:
214
226
 
@@ -217,7 +229,7 @@ Exported from `create-basalt` (in addition to the `create-basalt` executable):
217
229
  | `dir` | `string` | Absolute path of the created folder |
218
230
  | `files` | `string[]` | Files written (relative, sorted) |
219
231
  | `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 |
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' }`) |
221
233
 
222
234
  Throws `TargetNotEmptyError` if the destination folder exists and isn't empty.
223
235
 
@@ -257,6 +269,12 @@ Automatic installation failed (network, Node version, etc.). Go into the folder
257
269
  **I started the app and `GET /auth/login` gives a secret error.**
258
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.
259
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
+
260
278
  **I want to change my mind after generating (e.g. add billing).**
261
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).
262
280
 
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') };
@@ -125,8 +126,34 @@ export const env = defineEnv({
125
126
  })
126
127
  `;
127
128
  }
129
+ /**
130
+ * An app-specific env-var prefix derived from the project name
131
+ * (`my-saas` → `MY_SAAS`), suggested for variables whose generic names
132
+ * (`DATABASE_URL`, …) other projects export too.
133
+ */
134
+ export function envPrefix(name) {
135
+ let base = name
136
+ .replace(/^@[^/]+\//, '')
137
+ .toUpperCase()
138
+ .replace(/[^A-Z0-9]+/g, '_');
139
+ // Trim underscores in linear time (an anchored `_+$` regex backtracks).
140
+ let start = 0;
141
+ let end = base.length;
142
+ while (start < end && base[start] === '_')
143
+ start++;
144
+ while (end > start && base[end - 1] === '_')
145
+ end--;
146
+ base = base.slice(start, end);
147
+ return base === '' || /^\d/.test(base) ? `APP_${base}` : base;
148
+ }
128
149
  export function envExample(options) {
129
- return `PORT=3000
150
+ const prefix = envPrefix(options.name);
151
+ return `# Nothing loads this file for you: copy it to .env and start with
152
+ # \`node --env-file=.env\` / \`tsx --env-file=.env\`, or export the variables.
153
+ # --env-file NEVER overrides a variable already exported in your shell: if
154
+ # another project exported DATABASE_URL, the app silently uses THAT one. Give
155
+ # generic names an app-specific prefix, e.g. ${prefix}_DATABASE_URL.
156
+ PORT=3000
130
157
  HOST=0.0.0.0
131
158
  LOG_LEVEL=info
132
159
  NODE_ENV=development
@@ -427,6 +454,18 @@ pnpm install
427
454
  pnpm dev # API on http://localhost:3000
428
455
  pnpm test
429
456
  \`\`\`
457
+
458
+ ## Environment
459
+
460
+ \`src/env.ts\` validates \`process.env\` — nothing loads \`.env\` for you. Copy
461
+ \`.env.example\` to \`.env\` and launch with \`--env-file=.env\` (Node/tsx), or
462
+ export the variables.
463
+
464
+ **\`--env-file\` never overrides a variable that is already exported.** In a shell
465
+ where another project exported \`DATABASE_URL\` (or \`PORT\`), this app boots
466
+ against THAT value and only fails on the first request that touches it. Give
467
+ generic names an app-specific prefix — \`${envPrefix(options.name)}_DATABASE_URL\` rather than
468
+ \`DATABASE_URL\` — and check \`env | grep DATABASE_URL\` when in doubt.
430
469
  ${options.cli
431
470
  ? `
432
471
  ## The \`basalt\` CLI
@@ -440,6 +479,18 @@ pnpm basalt make:service Project # a single artifact (--force to overwrite)
440
479
 
441
480
  Generated resources land in \`src/modules/<name>/\`. Register the generated
442
481
  plugin in \`src/app.ts\` to wire it up.
482
+
483
+ With pnpm 11, \`pnpm basalt …\` first verifies dependencies
484
+ (\`verifyDepsBeforeRun\`, default \`install\`): when any workspace project is out
485
+ of sync it runs \`pnpm install\` — network included — before the command. To
486
+ run the CLI without that check, call it directly:
487
+
488
+ \`\`\`bash
489
+ node_modules/.bin/tsx bin/basalt.ts make:resource Project
490
+ \`\`\`
491
+
492
+ or set \`verifyDepsBeforeRun: warn\` in \`pnpm-workspace.yaml\` (a conscious
493
+ choice: you then run \`pnpm install\` yourself after dependency changes).
443
494
  `
444
495
  : ''}${options.ui
445
496
  ? `
@@ -535,6 +586,12 @@ dist/
535
586
  * msgpackr-extract (optional native accelerator via BullMQ) is declined —
536
587
  * msgpackr falls back to pure JS. With --ui, `web` is a workspace member so
537
588
  * its own dependencies resolve.
589
+ *
590
+ * pnpm 11 notes (BK-002), spelled out in the file because both bite silently:
591
+ * `minimumReleaseAgeExclude` is evaluated first-match-wins BY PACKAGE NAME, so
592
+ * per-version exclusions of one package must be a single `||` union entry; and
593
+ * `verifyDepsBeforeRun` defaults to `install`, so any `pnpm <script>` may run
594
+ * `pnpm install` first — `warn` is offered commented out, never set for you.
538
595
  */
539
596
  export function pnpmWorkspaceYaml(options) {
540
597
  return `${options.ui
@@ -548,5 +605,16 @@ export function pnpmWorkspaceYaml(options) {
548
605
  # pnpm's minimumReleaseAge policy so \`pnpm up\` is never blocked on a fresh release.
549
606
  minimumReleaseAgeExclude:
550
607
  - '@basaltkit/*'
608
+ # To let specific versions of ONE package bypass the policy, write ONE entry
609
+ # with a \`||\` union. pnpm evaluates this list first match wins by package
610
+ # name, so a second '<name>@<version>' entry for the same package is ignored:
611
+ # - '@types/node@22.20.4 || 26.6.2'
612
+
613
+ # pnpm 11 checks dependencies before every \`pnpm <script>\` / \`pnpm exec\`
614
+ # (verifyDepsBeforeRun defaults to \`install\`): when node_modules is out of sync
615
+ # with ANY workspace project's manifest, it runs \`pnpm install\` first — network
616
+ # and supply-chain checks included. To opt out consciously (you then run
617
+ # \`pnpm install\` yourself after dependency changes), uncomment:
618
+ # verifyDepsBeforeRun: warn
551
619
  `;
552
620
  }
package/dist/versions.js CHANGED
@@ -5,17 +5,17 @@ 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
11
  '@basaltkit/core': '^1.3.0',
12
12
  '@basaltkit/env': '^3.0.0',
13
- '@basaltkit/events': '^1.2.0',
14
- '@basaltkit/fastify': '^2.1.0',
13
+ '@basaltkit/events': '^1.3.0',
14
+ '@basaltkit/fastify': '^2.2.0',
15
15
  '@basaltkit/generator': '^1.4.0',
16
16
  '@basaltkit/logger': '^1.3.0',
17
17
  '@basaltkit/mcp': '^3.0.0',
18
- '@basaltkit/prisma': '^2.0.0',
18
+ '@basaltkit/prisma': '^2.1.0',
19
19
  '@basaltkit/sdk': '^2.0.0',
20
20
  '@basaltkit/subscriptions': '^4.0.0',
21
21
  '@basaltkit/teams': '^3.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.6.0",
4
4
  "engines": {
5
5
  "node": ">=22.5.0"
6
6
  },
@@ -21,25 +21,25 @@
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",
28
+ "@basaltkit/auth": "^3.1.0",
30
29
  "@basaltkit/config": "^1.1.2",
31
- "@basaltkit/core": "^1.3.1",
30
+ "@basaltkit/core": "^1.3.2",
32
31
  "@basaltkit/env": "^3.0.0",
33
- "@basaltkit/events": "^1.2.0",
34
- "@basaltkit/fastify": "^2.1.0",
32
+ "@basaltkit/cli": "^1.2.5",
33
+ "@basaltkit/events": "^1.3.0",
35
34
  "@basaltkit/generator": "^1.4.0",
36
35
  "@basaltkit/logger": "^1.3.0",
37
36
  "@basaltkit/mcp": "^3.0.0",
38
- "@basaltkit/prisma": "^2.0.0",
39
- "@basaltkit/subscriptions": "^4.0.0",
40
- "@basaltkit/teams": "^3.0.0",
37
+ "@basaltkit/prisma": "^2.1.0",
38
+ "@basaltkit/teams": "^3.0.1",
39
+ "@basaltkit/fastify": "^2.2.0",
41
40
  "@basaltkit/tenancy": "^2.0.0",
42
- "@basaltkit/testing": "^2.0.1",
41
+ "@basaltkit/testing": "^2.0.2",
42
+ "@basaltkit/subscriptions": "^4.0.0",
43
43
  "@basaltkit/tsconfig": "^0.24.0"
44
44
  },
45
45
  "publishConfig": {