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 +22 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/latest-versions.d.ts +43 -0
- package/dist/latest-versions.js +93 -9
- package/dist/templates.d.ts +12 -0
- package/dist/templates.js +70 -2
- package/dist/versions.js +4 -4
- package/package.json +13 -13
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,
|
|
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
|
package/dist/latest-versions.js
CHANGED
|
@@ -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
|
-
|
|
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 (!
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
}
|
package/dist/templates.d.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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.
|
|
14
|
-
'@basaltkit/fastify': '^2.
|
|
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.
|
|
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.
|
|
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.
|
|
24
|
+
"@types/node": "^26.6.1",
|
|
25
25
|
"typescript": "^7.0.2",
|
|
26
|
-
"vitest": "^5.0.
|
|
27
|
-
"zod": "^4.5
|
|
28
|
-
"@basaltkit/auth": "^3.
|
|
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.
|
|
30
|
+
"@basaltkit/core": "^1.3.2",
|
|
32
31
|
"@basaltkit/env": "^3.0.0",
|
|
33
|
-
"@basaltkit/
|
|
34
|
-
"@basaltkit/
|
|
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.
|
|
39
|
-
"@basaltkit/
|
|
40
|
-
"@basaltkit/
|
|
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.
|
|
41
|
+
"@basaltkit/testing": "^2.0.2",
|
|
42
|
+
"@basaltkit/subscriptions": "^4.0.0",
|
|
43
43
|
"@basaltkit/tsconfig": "^0.24.0"
|
|
44
44
|
},
|
|
45
45
|
"publishConfig": {
|