create-basalt 1.9.0 → 1.11.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.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Output for the project commands: an injectable sink (tests capture it) and
3
+ * ANSI colors that switch off for `NO_COLOR`, `TERM=dumb` and non-TTY output
4
+ * (`FORCE_COLOR` forces them on).
5
+ */
6
+ export function colorsEnabled(env, isTTY) {
7
+ if (env['NO_COLOR'] !== undefined && env['NO_COLOR'] !== '')
8
+ return false;
9
+ const force = env['FORCE_COLOR'];
10
+ if (force !== undefined)
11
+ return force !== '0' && force !== 'false';
12
+ if (env['TERM'] === 'dumb')
13
+ return false;
14
+ return isTTY;
15
+ }
16
+ export function makeColors(enabled) {
17
+ const wrap = (open, close) => (text) => enabled ? `\x1b[${open}m${text}\x1b[${close}m` : text;
18
+ return {
19
+ enabled,
20
+ bold: wrap(1, 22),
21
+ dim: wrap(2, 22),
22
+ red: wrap(31, 39),
23
+ green: wrap(32, 39),
24
+ yellow: wrap(33, 39),
25
+ cyan: wrap(36, 39),
26
+ };
27
+ }
28
+ /** Visible width of a string (ANSI escapes excluded). */
29
+ // eslint-disable-next-line no-control-regex
30
+ const visible = (text) => text.replace(/\x1b\[\d+m/g, '').length;
31
+ /** A plain left-aligned table (header row + rows), two spaces between columns. */
32
+ export function table(header, rows, colors) {
33
+ const widths = header.map((cell, i) => Math.max(visible(cell), ...rows.map((row) => visible(row[i] ?? ''))));
34
+ const line = (cells) => cells
35
+ .map((cell, i) => (i === cells.length - 1 ? cell : cell + ' '.repeat((widths[i] ?? 0) - visible(cell))))
36
+ .join(' ')
37
+ .trimEnd();
38
+ return [colors.dim(line(header)), ...rows.map(line)];
39
+ }
@@ -0,0 +1,101 @@
1
+ import { type ResolveLatestOptions } from '../latest-versions.js';
2
+ import type { ProjectContext } from './context.js';
3
+ import { type DependencySection } from './package-json.js';
4
+ import { type Colors } from './term.js';
5
+ import { type UpdateKind } from './semver.js';
6
+ /**
7
+ * `create-basalt update`: move an existing app's dependencies to the latest
8
+ * published versions under the SAME registry policy as a new scaffold
9
+ * (`lookupLatestVersions`, shared with latest-versions.ts):
10
+ *
11
+ * - `@basaltkit/*` and `create-basalt` → the registry's `latest`, across majors
12
+ * (each framework major is flagged with a pointer to its changelog);
13
+ * - third-party → the newest release on the app's CURRENT major; a newer major
14
+ * is reported and only taken with `--major`;
15
+ * - a third-party version not provably older than the release-age window
16
+ * (pnpm `minimumReleaseAge`) keeps the current range — the install would
17
+ * otherwise refuse it.
18
+ *
19
+ * Planning is pure (no writes); {@link UpdatePlan.files} holds the full
20
+ * before/after of every file the plan would write.
21
+ */
22
+ export declare const DOCS_UPGRADING_URL = "https://basaltkit-docs.pages.dev/guide/whats-new#upgrading";
23
+ /** The CHANGELOG of a framework package in the Basalt repository. */
24
+ export declare function changelogUrl(name: string): string;
25
+ export type EntryStatus =
26
+ /** Will be written. */
27
+ 'update'
28
+ /** Already at latest. */
29
+ | 'current'
30
+ /** Third-party latest is a new major (needs --major). */
31
+ | 'held-major'
32
+ /** Third-party latest is inside the release-age window (or its age is unknown). */
33
+ | 'too-fresh'
34
+ /** The registry did not answer. */
35
+ | 'failed'
36
+ /** A range `update` does not rewrite (workspace:, tags, git, >=, …). */
37
+ | 'unmanaged'
38
+ /** The app is already ahead of `latest` (a pre-release, a local build). */
39
+ | 'ahead'
40
+ /** Filtered out by --only. */
41
+ | 'skipped';
42
+ export interface UpdateEntry {
43
+ file: string;
44
+ section: DependencySection;
45
+ name: string;
46
+ current: string;
47
+ latest?: string;
48
+ /** New range (same style as `current`) when status is `update`. */
49
+ target?: string;
50
+ kind?: UpdateKind;
51
+ status: EntryStatus;
52
+ framework: boolean;
53
+ /** For `too-fresh`: proven recent, or age unknown. */
54
+ freshness?: 'recent' | 'unknown';
55
+ }
56
+ export interface FileWrite {
57
+ before: string | undefined;
58
+ after: string;
59
+ }
60
+ export interface UpdatePlan {
61
+ entries: UpdateEntry[];
62
+ /** Entries that change (status `update`). */
63
+ updates: UpdateEntry[];
64
+ /** Every file this plan writes, keyed by project-relative path. */
65
+ files: Record<string, FileWrite>;
66
+ /** Project tooling the plan also upgrades (bin/basalt.ts, create-basalt devDependency, …). */
67
+ tooling: string[];
68
+ /** Steps the user has to do by hand (printed, never applied). */
69
+ manual: string[];
70
+ /** Peer-range conflicts and registry failures worth reading before applying. */
71
+ warnings: string[];
72
+ /** Framework packages crossing a major: name → changelog URL. */
73
+ majors: {
74
+ name: string;
75
+ from: string;
76
+ to: string;
77
+ changelog: string;
78
+ }[];
79
+ registry: string;
80
+ }
81
+ export interface UpdateOptions {
82
+ /** Allow third-party packages to cross a major. */
83
+ major?: boolean;
84
+ /** `@basaltkit`: only the framework packages (and create-basalt). */
85
+ only?: 'basaltkit';
86
+ /** Also upgrade project tooling (default true). */
87
+ tooling?: boolean;
88
+ registry?: ResolveLatestOptions;
89
+ }
90
+ export declare class RegistryUnavailableError extends Error {
91
+ constructor(registry: string);
92
+ }
93
+ /** `minimumReleaseAge: <n>` from pnpm-workspace.yaml, when set. */
94
+ export declare function workspaceReleaseAge(yaml: string | undefined): number | undefined;
95
+ /** Whether pnpm-workspace.yaml's minimumReleaseAgeExclude lists `entry`. */
96
+ export declare function workspaceExcludes(yaml: string | undefined, entry: string): boolean;
97
+ /** Adds `entry` to minimumReleaseAgeExclude right after the `@basaltkit/*` line; undefined when that line is absent. */
98
+ export declare function addWorkspaceExclude(yaml: string, entry: string): string | undefined;
99
+ export declare function planUpdate(ctx: ProjectContext, options?: UpdateOptions): Promise<UpdatePlan>;
100
+ /** The plan as terminal lines: the update table, then notes, tooling, warnings and majors. */
101
+ export declare function renderUpdatePlan(plan: UpdatePlan, colors: Colors): string[];
@@ -0,0 +1,325 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { binStatus, hasProjectCommands, manualBinSnippet, patchBasaltBin } from '../bin-template.js';
4
+ import { devEntryStatus, MANUAL_DEV_SNIPPET, patchDevEntry, patchEnvExample } from '../dev-entry.js';
5
+ import { alwaysLatest, lookupLatestVersions, minimumReleaseAgeMinutes, } from '../latest-versions.js';
6
+ import { BASALT_PROJECT_SCRIPT } from '../templates.js';
7
+ import { extendManifest, MANIFEST_PATH, serializeManifest } from './manifest.js';
8
+ import { DEPENDENCY_SECTIONS, mergePackageJson, setDependencyRange, } from './package-json.js';
9
+ import { table } from './term.js';
10
+ import { compareVersions, parseSimpleRange, parseVersion, satisfies, updateKind } from './semver.js';
11
+ /**
12
+ * `create-basalt update`: move an existing app's dependencies to the latest
13
+ * published versions under the SAME registry policy as a new scaffold
14
+ * (`lookupLatestVersions`, shared with latest-versions.ts):
15
+ *
16
+ * - `@basaltkit/*` and `create-basalt` → the registry's `latest`, across majors
17
+ * (each framework major is flagged with a pointer to its changelog);
18
+ * - third-party → the newest release on the app's CURRENT major; a newer major
19
+ * is reported and only taken with `--major`;
20
+ * - a third-party version not provably older than the release-age window
21
+ * (pnpm `minimumReleaseAge`) keeps the current range — the install would
22
+ * otherwise refuse it.
23
+ *
24
+ * Planning is pure (no writes); {@link UpdatePlan.files} holds the full
25
+ * before/after of every file the plan would write.
26
+ */
27
+ export const DOCS_UPGRADING_URL = 'https://basaltkit-docs.pages.dev/guide/whats-new#upgrading';
28
+ const REPO_URL = 'https://github.com/basaltkit/basalt';
29
+ /** The CHANGELOG of a framework package in the Basalt repository. */
30
+ export function changelogUrl(name) {
31
+ const dir = name === 'create-basalt' ? 'create-app' : name.replace(/^@basaltkit\//, '');
32
+ return `${REPO_URL}/blob/main/packages/${dir}/CHANGELOG.md`;
33
+ }
34
+ export class RegistryUnavailableError extends Error {
35
+ constructor(registry) {
36
+ super(`Could not reach ${registry} for any package — update needs the npm registry. Check your connection (or npm_config_registry) and retry.`);
37
+ this.name = 'RegistryUnavailableError';
38
+ }
39
+ }
40
+ const isFramework = (name) => alwaysLatest(name);
41
+ /** `minimumReleaseAge: <n>` from pnpm-workspace.yaml, when set. */
42
+ export function workspaceReleaseAge(yaml) {
43
+ const match = yaml ? /^minimumReleaseAge:\s*(\d+)\s*$/m.exec(yaml) : null;
44
+ return match ? Number(match[1]) : undefined;
45
+ }
46
+ /** Whether pnpm-workspace.yaml's minimumReleaseAgeExclude lists `entry`. */
47
+ export function workspaceExcludes(yaml, entry) {
48
+ if (!yaml)
49
+ return false;
50
+ const block = /^minimumReleaseAgeExclude:\s*\n((?:[ \t]+-.*\n?|[ \t]*#.*\n?)*)/m.exec(yaml);
51
+ if (!block)
52
+ return false;
53
+ return (block[1] ?? '')
54
+ .split('\n')
55
+ .map((line) => /^\s*-\s*['"]?([^'"#\s]+)['"]?/.exec(line)?.[1])
56
+ .includes(entry);
57
+ }
58
+ /** Adds `entry` to minimumReleaseAgeExclude right after the `@basaltkit/*` line; undefined when that line is absent. */
59
+ export function addWorkspaceExclude(yaml, entry) {
60
+ const line = /^([ \t]+)-[ \t]*['"]?@basaltkit\/\*['"]?[ \t]*$/m.exec(yaml);
61
+ if (!line)
62
+ return undefined;
63
+ const end = line.index + line[0].length;
64
+ return `${yaml.slice(0, end)}\n${line[1]}- ${entry}${yaml.slice(end)}`;
65
+ }
66
+ async function readOptional(path) {
67
+ try {
68
+ return await readFile(path, 'utf8');
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ }
74
+ export async function planUpdate(ctx, options = {}) {
75
+ const manifests = [{ file: 'package.json', text: ctx.packageJsonText, json: ctx.packageJson }];
76
+ if (ctx.web)
77
+ manifests.push({ file: 'web/package.json', text: ctx.web.text, json: ctx.web.json });
78
+ const workspaceYaml = await readOptional(join(ctx.dir, 'pnpm-workspace.yaml'));
79
+ const tooling = options.tooling !== false;
80
+ // Every declared dependency, per file and section.
81
+ const entries = [];
82
+ for (const manifest of manifests) {
83
+ for (const section of DEPENDENCY_SECTIONS) {
84
+ for (const [name, current] of Object.entries(manifest.json[section] ?? {})) {
85
+ const framework = isFramework(name);
86
+ const status = options.only === 'basaltkit' && !framework ? 'skipped' : parseSimpleRange(current) ? 'update' : 'unmanaged';
87
+ entries.push({ file: manifest.file, section, name, current, status, framework });
88
+ }
89
+ }
90
+ }
91
+ const rootHasCreateBasalt = entries.some((entry) => entry.file === 'package.json' && entry.name === 'create-basalt');
92
+ // Tooling: an app without create-basalt as a devDependency gets one, so
93
+ // `pnpm basalt update` resolves locally next time.
94
+ const addCreateBasalt = tooling && !rootHasCreateBasalt;
95
+ const managed = entries.filter((entry) => entry.status === 'update');
96
+ const names = [...new Set([...managed.map((entry) => entry.name), ...(addCreateBasalt ? ['create-basalt'] : [])])].sort();
97
+ // The release-age window and who is exempt from it: the project's own pnpm
98
+ // config decides (non-pnpm managers have no exclude list → nobody exempt).
99
+ const window = minimumReleaseAgeMinutes(options.registry?.minimumReleaseAge ?? workspaceReleaseAge(workspaceYaml));
100
+ const exempt = (name) => ctx.pm === 'pnpm' &&
101
+ ((name.startsWith('@basaltkit/') && workspaceExcludes(workspaceYaml, '@basaltkit/*')) ||
102
+ (name === 'create-basalt' &&
103
+ (workspaceExcludes(workspaceYaml, 'create-basalt') ||
104
+ (tooling && workspaceYaml !== undefined && addWorkspaceExclude(workspaceYaml, 'create-basalt') !== undefined))));
105
+ const majorsOf = new Map();
106
+ for (const entry of managed) {
107
+ const range = parseSimpleRange(entry.current);
108
+ if (!range)
109
+ continue;
110
+ const set = majorsOf.get(entry.name) ?? new Set();
111
+ set.add(range.parsed[0]);
112
+ majorsOf.set(entry.name, set);
113
+ }
114
+ /** Whether `latest` would be taken for `name` from a range on `major`. */
115
+ const acceptsMajor = (name, major, latest) => isFramework(name) || options.major === true || Number(latest.split('.')[0]) === major;
116
+ const { results, registry } = await lookupLatestVersions(names, (name, latest) => !exempt(name) && (majorsOf.get(name) === undefined || [...(majorsOf.get(name) ?? [])].some((major) => acceptsMajor(name, major, latest))), { ...options.registry, minimumReleaseAge: window });
117
+ if (names.length > 0 && names.every((name) => results.get(name)?.latest === undefined)) {
118
+ throw new RegistryUnavailableError(registry);
119
+ }
120
+ for (const entry of managed) {
121
+ const info = results.get(entry.name);
122
+ const range = parseSimpleRange(entry.current);
123
+ if (!range)
124
+ continue;
125
+ if (info.latest === undefined) {
126
+ entry.status = 'failed';
127
+ continue;
128
+ }
129
+ entry.latest = info.latest;
130
+ const latest = parseVersion(info.latest);
131
+ if (!latest) {
132
+ entry.status = 'failed';
133
+ continue;
134
+ }
135
+ const order = compareVersions(latest, range.parsed);
136
+ if (order === 0) {
137
+ entry.status = 'current';
138
+ continue;
139
+ }
140
+ if (order < 0) {
141
+ entry.status = 'ahead';
142
+ continue;
143
+ }
144
+ entry.kind = updateKind(range.parsed, latest);
145
+ if (!acceptsMajor(entry.name, range.parsed[0], info.latest)) {
146
+ entry.status = 'held-major';
147
+ continue;
148
+ }
149
+ if (info.age !== 'mature') {
150
+ entry.status = 'too-fresh';
151
+ entry.freshness = info.age;
152
+ continue;
153
+ }
154
+ entry.status = 'update';
155
+ entry.target = `${range.style}${info.latest}`;
156
+ }
157
+ const updates = entries.filter((entry) => entry.status === 'update' && entry.target !== undefined);
158
+ const files = {};
159
+ for (const manifest of manifests) {
160
+ let text = manifest.text;
161
+ for (const entry of updates.filter((candidate) => candidate.file === manifest.file)) {
162
+ text = setDependencyRange(text, entry.section, entry.name, entry.target);
163
+ }
164
+ if (text !== manifest.text)
165
+ files[manifest.file] = { before: manifest.text, after: text };
166
+ }
167
+ const toolingNotes = [];
168
+ const manual = [];
169
+ const warnings = [];
170
+ if (tooling) {
171
+ // create-basalt devDependency + the `basalt` script that reaches it.
172
+ const createBasaltLatest = results.get('create-basalt');
173
+ const rootText = files['package.json']?.after ?? ctx.packageJsonText;
174
+ if (addCreateBasalt && createBasaltLatest?.latest !== undefined) {
175
+ const fresh = createBasaltLatest.age !== 'mature';
176
+ if (fresh) {
177
+ warnings.push(`create-basalt ${createBasaltLatest.latest} is inside the release-age window — not added as a devDependency this time.`);
178
+ }
179
+ else {
180
+ const hasBin = ctx.options.cli;
181
+ const merged = mergePackageJson(rootText, {
182
+ devDependencies: { 'create-basalt': `^${createBasaltLatest.latest}` },
183
+ scripts: { basalt: hasBin ? 'tsx bin/basalt.ts' : BASALT_PROJECT_SCRIPT },
184
+ });
185
+ if (merged.changes.length > 0) {
186
+ files['package.json'] = { before: ctx.packageJsonText, after: merged.text };
187
+ toolingNotes.push(`package.json: create-basalt ^${createBasaltLatest.latest} as a devDependency${merged.changes.some((change) => change.startsWith('scripts:')) ? ' + a "basalt" script' : ''} — so \`${ctx.pm === 'npm' ? 'npm run' : ctx.pm} basalt update\` works from now on`);
188
+ }
189
+ if (workspaceYaml !== undefined && !workspaceExcludes(workspaceYaml, 'create-basalt')) {
190
+ const patched = addWorkspaceExclude(workspaceYaml, 'create-basalt');
191
+ if (patched !== undefined) {
192
+ files['pnpm-workspace.yaml'] = { before: workspaceYaml, after: patched };
193
+ toolingNotes.push('pnpm-workspace.yaml: create-basalt joins minimumReleaseAgeExclude (like @basaltkit/*)');
194
+ }
195
+ }
196
+ }
197
+ }
198
+ // Template files from an earlier release: bring them to the current
199
+ // templates when untouched (known hash or the manifest's), else print the
200
+ // snippet. Patched files get their manifest hash refreshed.
201
+ const patched = {};
202
+ const bin = await readOptional(join(ctx.dir, 'bin', 'basalt.ts'));
203
+ const status = binStatus(bin, ctx.manifest?.files['bin/basalt.ts']);
204
+ if (status === 'patchable' && bin !== undefined) {
205
+ const after = patchBasaltBin(bin);
206
+ files['bin/basalt.ts'] = { before: bin, after };
207
+ patched['bin/basalt.ts'] = after;
208
+ toolingNotes.push(`bin/basalt.ts: unmodified template from an earlier release — ${hasProjectCommands(bin) ? '' : 'gains update/add/doctor/info; '}loads .env for development, runs \`upgrade\` without booting the app, explains env errors`);
209
+ }
210
+ else if (status === 'modified' && bin !== undefined) {
211
+ manual.push(`bin/basalt.ts was customised, so it was not patched. To bring it up to date:\n${manualBinSnippet(bin)}`);
212
+ }
213
+ const dev = await readOptional(join(ctx.dir, 'src', 'dev.ts'));
214
+ const devStatus = devEntryStatus(dev, ctx.manifest?.files['src/dev.ts']);
215
+ if (devStatus === 'patchable' && dev !== undefined) {
216
+ const after = patchDevEntry();
217
+ files['src/dev.ts'] = { before: dev, after };
218
+ patched['src/dev.ts'] = after;
219
+ toolingNotes.push('src/dev.ts: unmodified template from an earlier release — `dev` loads .env (exported variables still win)');
220
+ }
221
+ else if (devStatus === 'modified') {
222
+ manual.push(`src/dev.ts was customised, so it was not patched. To load .env in development:\n${MANUAL_DEV_SNIPPET}`);
223
+ }
224
+ const example = await readOptional(join(ctx.dir, '.env.example'));
225
+ const exampleAfter = example === undefined ? undefined : patchEnvExample(example);
226
+ if (example !== undefined && exampleAfter !== undefined) {
227
+ files['.env.example'] = { before: example, after: exampleAfter };
228
+ patched['.env.example'] = exampleAfter;
229
+ toolingNotes.push('.env.example: header now says dev/CLI load .env automatically and `start` does not');
230
+ }
231
+ if (ctx.manifest && Object.keys(patched).length > 0) {
232
+ files[MANIFEST_PATH] = {
233
+ before: serializeManifest(ctx.manifest),
234
+ after: serializeManifest(extendManifest(ctx.manifest, ctx.options, patched)),
235
+ };
236
+ }
237
+ }
238
+ // Peer ranges of the framework versions we end up on vs the app's resulting ranges.
239
+ const resulting = new Map();
240
+ for (const entry of entries) {
241
+ if (entry.file !== 'package.json' && resulting.has(entry.name))
242
+ continue;
243
+ resulting.set(entry.name, entry.target ?? entry.current);
244
+ }
245
+ for (const entry of entries) {
246
+ if (!entry.framework || (entry.status !== 'update' && entry.status !== 'current') || entry.file !== 'package.json')
247
+ continue;
248
+ const info = results.get(entry.name);
249
+ for (const [peer, wanted] of Object.entries(info?.peers ?? {})) {
250
+ const range = resulting.get(peer);
251
+ const floor = range ? parseSimpleRange(range) : undefined;
252
+ if (!floor)
253
+ continue;
254
+ if (satisfies(floor.version, wanted) === false) {
255
+ const peerEntry = entries.find((candidate) => candidate.name === peer);
256
+ const hint = peerEntry?.status === 'held-major' ? ' — re-run with --major' : '';
257
+ warnings.push(`${entry.name}@${info?.latest} expects ${peer} ${wanted}, but the app will have ${range}${hint}`);
258
+ }
259
+ }
260
+ }
261
+ const failed = entries.filter((entry) => entry.status === 'failed').map((entry) => entry.name);
262
+ if (failed.length > 0)
263
+ warnings.push(`The registry did not answer for ${[...new Set(failed)].join(', ')} — left unchanged.`);
264
+ const majors = updates
265
+ .filter((entry) => entry.framework && entry.kind === 'major')
266
+ .map((entry) => ({
267
+ name: entry.name,
268
+ from: parseSimpleRange(entry.current)?.version ?? entry.current,
269
+ to: entry.latest,
270
+ changelog: changelogUrl(entry.name),
271
+ }))
272
+ .filter((major, i, all) => all.findIndex((other) => other.name === major.name) === i);
273
+ return { entries, updates, files, tooling: toolingNotes, manual, warnings, majors, registry };
274
+ }
275
+ /** The plan as terminal lines: the update table, then notes, tooling, warnings and majors. */
276
+ export function renderUpdatePlan(plan, colors) {
277
+ const lines = [];
278
+ const kindLabel = (kind) => kind === 'major' ? colors.red(colors.bold('MAJOR')) : kind === 'minor' ? colors.yellow('minor') : colors.green('patch');
279
+ const multiFile = new Set(plan.entries.map((entry) => entry.file)).size > 1;
280
+ if (plan.updates.length === 0) {
281
+ lines.push(colors.green('Every dependency is already on its latest allowed version.'));
282
+ }
283
+ else {
284
+ lines.push(colors.bold(`${plan.updates.length} update(s):`), '');
285
+ lines.push(...table(['package', ...(multiFile ? ['in'] : []), 'current', '', 'target', 'kind'], plan.updates.map((entry) => [
286
+ entry.framework ? colors.cyan(entry.name) : entry.name,
287
+ ...(multiFile ? [entry.file === 'package.json' ? '.' : entry.file.replace(/\/package\.json$/, '/')] : []),
288
+ entry.current,
289
+ '→',
290
+ colors.bold(entry.target),
291
+ kindLabel(entry.kind),
292
+ ]), colors).map((line) => ` ${line}`));
293
+ }
294
+ const held = plan.entries.filter((entry) => entry.status === 'held-major');
295
+ const fresh = plan.entries.filter((entry) => entry.status === 'too-fresh');
296
+ if (held.length > 0 || fresh.length > 0)
297
+ lines.push('');
298
+ for (const entry of dedupe(held)) {
299
+ lines.push(colors.dim(` kept ${entry.name} ${entry.current}: ${entry.latest} is a new major — pass --major to take it`));
300
+ }
301
+ for (const entry of dedupe(fresh)) {
302
+ lines.push(colors.dim(entry.freshness === 'recent'
303
+ ? ` kept ${entry.name} ${entry.current}: ${entry.latest} is inside the release-age window (minimumReleaseAge) — retry later`
304
+ : ` kept ${entry.name} ${entry.current}: could not prove ${entry.latest} is older than the release-age window`));
305
+ }
306
+ if (plan.majors.length > 0) {
307
+ lines.push('', colors.bold('Framework majors — read before applying:'));
308
+ for (const major of plan.majors)
309
+ lines.push(` ${major.name} ${major.from} → ${major.to}: ${major.changelog}`);
310
+ lines.push(` Upgrade notes: ${DOCS_UPGRADING_URL}`);
311
+ }
312
+ if (plan.tooling.length > 0) {
313
+ lines.push('', colors.bold('Project tooling:'));
314
+ for (const note of plan.tooling)
315
+ lines.push(` ${note}`);
316
+ }
317
+ for (const warning of plan.warnings)
318
+ lines.push('', colors.yellow(`Warning: ${warning}`));
319
+ for (const step of plan.manual)
320
+ lines.push('', colors.yellow('Manual step:'), step);
321
+ return lines;
322
+ }
323
+ function dedupe(entries) {
324
+ return entries.filter((entry, i) => entries.findIndex((other) => other.name === entry.name && other.current === entry.current) === i);
325
+ }
@@ -16,6 +16,7 @@ export interface ProjectOptions {
16
16
  */
17
17
  prisma: boolean;
18
18
  }
19
+ export { basaltBin } from './bin-template.js';
19
20
  export { SCAFFOLD_VERSIONS } from './versions.js';
20
21
  export { THIRD_PARTY_VERSIONS } from './latest-versions.js';
21
22
  /**
@@ -32,6 +33,10 @@ export declare const versionOf: (pkg: string) => string;
32
33
  * unknown package so every emitted range lives in that single table.
33
34
  */
34
35
  export declare const thirdPartyVersionOf: (pkg: string) => string;
36
+ /** The root `basalt` script of an app without `--cli`: create-basalt's project commands. */
37
+ export declare const BASALT_PROJECT_SCRIPT = "create-basalt --project";
38
+ /** The root `dev:web` script of a `--ui` app (web/ is a pnpm workspace member). */
39
+ export declare const webDevScript: (options: Pick<ProjectOptions, 'name'>) => string;
35
40
  /** True when the scaffold binds users to the tenant they act on (tenancy + auth). */
36
41
  export declare const enforcesMembership: (options: ProjectOptions) => boolean;
37
42
  export declare function packageJson(options: ProjectOptions): string;
@@ -43,20 +48,35 @@ export declare function envTs(options: ProjectOptions): string;
43
48
  * (`DATABASE_URL`, …) other projects export too.
44
49
  */
45
50
  export declare function envPrefix(name: string): string;
51
+ /**
52
+ * The opening lines of `.env.example`. Up to create-basalt 1.10 they read
53
+ * {@link LEGACY_ENV_EXAMPLE_LEAD} ("nothing loads this file"); `update`
54
+ * swaps that for this one in existing apps.
55
+ */
56
+ export declare const ENV_EXAMPLE_LEAD = "# Development: copy this file to .env \u2014 `pnpm dev` and `pnpm basalt` load it\n# automatically (a new app already has one). `pnpm start` (production) does\n# NOT: there the variables come from the environment (or `node --env-file`).";
57
+ export declare const LEGACY_ENV_EXAMPLE_LEAD = "# Nothing loads this file for you: copy it to .env and start with\n# `node --env-file=.env` / `tsx --env-file=.env`, or export the variables.";
46
58
  export declare function envExample(options: ProjectOptions): string;
59
+ /**
60
+ * The `.env` a new app starts with (git-ignored, never in the manifest): the
61
+ * values of `.env.example` for local development plus — with auth — a freshly
62
+ * generated APP_SECRET, so `pnpm dev` works without a copy step.
63
+ */
64
+ export declare function devEnvFile(options: ProjectOptions, appSecret: string): string;
47
65
  /** A PostgreSQL-safe database name derived from the project name. */
48
66
  export declare function databaseName(name: string): string;
49
67
  export declare function appTs(options: ProjectOptions): string;
50
68
  export declare function routesTs(options: ProjectOptions): string;
51
69
  export declare function serverTs(): string;
52
70
  /**
53
- * `pnpm dev` entrypoint. Opts into NODE_ENV=development (dev secret defaults,
54
- * pretty logs) unless NODE_ENV is already set, then starts the server. Kept out
55
- * of server.ts on purpose: `pnpm start` runs server.ts directly, where an unset
56
- * NODE_ENV counts as production and secrets are required (fail-closed).
71
+ * `pnpm dev` entrypoint. Loads `.env` (when present) with `node --env-file`
72
+ * semantics — an exported variable wins, the file only fills in what is
73
+ * missing — and opts into NODE_ENV=development (dev secret defaults, pretty
74
+ * logs) unless NODE_ENV is already set, then starts the server. Kept out of
75
+ * server.ts on purpose: `pnpm start` runs server.ts directly, where nothing
76
+ * loads `.env` (production configuration comes from the real environment) and
77
+ * an unset NODE_ENV counts as production, so secrets are required (fail-closed).
57
78
  */
58
79
  export declare function devTs(): string;
59
- export declare function basaltBin(): string;
60
80
  /**
61
81
  * `prisma/schema.prisma` — the datasource, the client generator, the models of
62
82
  * every Basalt domain this app enables, and the app's own first model.
@@ -87,6 +107,12 @@ export declare function dbTs(options: ProjectOptions): string;
87
107
  export declare function prismaSeedTs(options: ProjectOptions): string;
88
108
  export declare function appTest(options: ProjectOptions): string;
89
109
  export declare function readme(options: ProjectOptions): string;
110
+ /** README section for the \`basalt\` CLI (also appended by \`create-basalt add cli\`). */
111
+ export declare function readmeCliSection(_options: ProjectOptions): string;
112
+ /** README section for the web/ frontend (also appended by \`create-basalt add ui\`). */
113
+ export declare function readmeUiSection(options: ProjectOptions): string;
114
+ /** README section for the MCP server (also appended by \`create-basalt add mcp\`). */
115
+ export declare function readmeMcpSection(_options: ProjectOptions): string;
90
116
  /**
91
117
  * A Claude Code / generic MCP client config that registers the dev-only
92
118
  * `basalt-ai-mcp` bridge for this project. Claude Code reads `.mcp.json` at the