create-basalt 1.9.0 → 1.10.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,302 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { binStatus, MANUAL_BIN_SNIPPET, patchBasaltBin } from '../bin-template.js';
4
+ import { alwaysLatest, lookupLatestVersions, minimumReleaseAgeMinutes, } from '../latest-versions.js';
5
+ import { BASALT_PROJECT_SCRIPT } from '../templates.js';
6
+ import { extendManifest, MANIFEST_PATH, serializeManifest } from './manifest.js';
7
+ import { DEPENDENCY_SECTIONS, mergePackageJson, setDependencyRange, } from './package-json.js';
8
+ import { table } from './term.js';
9
+ import { compareVersions, parseSimpleRange, parseVersion, satisfies, updateKind } from './semver.js';
10
+ /**
11
+ * `create-basalt update`: move an existing app's dependencies to the latest
12
+ * published versions under the SAME registry policy as a new scaffold
13
+ * (`lookupLatestVersions`, shared with latest-versions.ts):
14
+ *
15
+ * - `@basaltkit/*` and `create-basalt` → the registry's `latest`, across majors
16
+ * (each framework major is flagged with a pointer to its changelog);
17
+ * - third-party → the newest release on the app's CURRENT major; a newer major
18
+ * is reported and only taken with `--major`;
19
+ * - a third-party version not provably older than the release-age window
20
+ * (pnpm `minimumReleaseAge`) keeps the current range — the install would
21
+ * otherwise refuse it.
22
+ *
23
+ * Planning is pure (no writes); {@link UpdatePlan.files} holds the full
24
+ * before/after of every file the plan would write.
25
+ */
26
+ export const DOCS_UPGRADING_URL = 'https://basaltkit-docs.pages.dev/guide/whats-new#upgrading';
27
+ const REPO_URL = 'https://github.com/basaltkit/basalt';
28
+ /** The CHANGELOG of a framework package in the Basalt repository. */
29
+ export function changelogUrl(name) {
30
+ const dir = name === 'create-basalt' ? 'create-app' : name.replace(/^@basaltkit\//, '');
31
+ return `${REPO_URL}/blob/main/packages/${dir}/CHANGELOG.md`;
32
+ }
33
+ export class RegistryUnavailableError extends Error {
34
+ constructor(registry) {
35
+ super(`Could not reach ${registry} for any package — update needs the npm registry. Check your connection (or npm_config_registry) and retry.`);
36
+ this.name = 'RegistryUnavailableError';
37
+ }
38
+ }
39
+ const isFramework = (name) => alwaysLatest(name);
40
+ /** `minimumReleaseAge: <n>` from pnpm-workspace.yaml, when set. */
41
+ export function workspaceReleaseAge(yaml) {
42
+ const match = yaml ? /^minimumReleaseAge:\s*(\d+)\s*$/m.exec(yaml) : null;
43
+ return match ? Number(match[1]) : undefined;
44
+ }
45
+ /** Whether pnpm-workspace.yaml's minimumReleaseAgeExclude lists `entry`. */
46
+ export function workspaceExcludes(yaml, entry) {
47
+ if (!yaml)
48
+ return false;
49
+ const block = /^minimumReleaseAgeExclude:\s*\n((?:[ \t]+-.*\n?|[ \t]*#.*\n?)*)/m.exec(yaml);
50
+ if (!block)
51
+ return false;
52
+ return (block[1] ?? '')
53
+ .split('\n')
54
+ .map((line) => /^\s*-\s*['"]?([^'"#\s]+)['"]?/.exec(line)?.[1])
55
+ .includes(entry);
56
+ }
57
+ /** Adds `entry` to minimumReleaseAgeExclude right after the `@basaltkit/*` line; undefined when that line is absent. */
58
+ export function addWorkspaceExclude(yaml, entry) {
59
+ const line = /^([ \t]+)-[ \t]*['"]?@basaltkit\/\*['"]?[ \t]*$/m.exec(yaml);
60
+ if (!line)
61
+ return undefined;
62
+ const end = line.index + line[0].length;
63
+ return `${yaml.slice(0, end)}\n${line[1]}- ${entry}${yaml.slice(end)}`;
64
+ }
65
+ async function readOptional(path) {
66
+ try {
67
+ return await readFile(path, 'utf8');
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ }
73
+ export async function planUpdate(ctx, options = {}) {
74
+ const manifests = [{ file: 'package.json', text: ctx.packageJsonText, json: ctx.packageJson }];
75
+ if (ctx.web)
76
+ manifests.push({ file: 'web/package.json', text: ctx.web.text, json: ctx.web.json });
77
+ const workspaceYaml = await readOptional(join(ctx.dir, 'pnpm-workspace.yaml'));
78
+ const tooling = options.tooling !== false;
79
+ // Every declared dependency, per file and section.
80
+ const entries = [];
81
+ for (const manifest of manifests) {
82
+ for (const section of DEPENDENCY_SECTIONS) {
83
+ for (const [name, current] of Object.entries(manifest.json[section] ?? {})) {
84
+ const framework = isFramework(name);
85
+ const status = options.only === 'basaltkit' && !framework ? 'skipped' : parseSimpleRange(current) ? 'update' : 'unmanaged';
86
+ entries.push({ file: manifest.file, section, name, current, status, framework });
87
+ }
88
+ }
89
+ }
90
+ const rootHasCreateBasalt = entries.some((entry) => entry.file === 'package.json' && entry.name === 'create-basalt');
91
+ // Tooling: an app without create-basalt as a devDependency gets one, so
92
+ // `pnpm basalt update` resolves locally next time.
93
+ const addCreateBasalt = tooling && !rootHasCreateBasalt;
94
+ const managed = entries.filter((entry) => entry.status === 'update');
95
+ const names = [...new Set([...managed.map((entry) => entry.name), ...(addCreateBasalt ? ['create-basalt'] : [])])].sort();
96
+ // The release-age window and who is exempt from it: the project's own pnpm
97
+ // config decides (non-pnpm managers have no exclude list → nobody exempt).
98
+ const window = minimumReleaseAgeMinutes(options.registry?.minimumReleaseAge ?? workspaceReleaseAge(workspaceYaml));
99
+ const exempt = (name) => ctx.pm === 'pnpm' &&
100
+ ((name.startsWith('@basaltkit/') && workspaceExcludes(workspaceYaml, '@basaltkit/*')) ||
101
+ (name === 'create-basalt' &&
102
+ (workspaceExcludes(workspaceYaml, 'create-basalt') ||
103
+ (tooling && workspaceYaml !== undefined && addWorkspaceExclude(workspaceYaml, 'create-basalt') !== undefined))));
104
+ const majorsOf = new Map();
105
+ for (const entry of managed) {
106
+ const range = parseSimpleRange(entry.current);
107
+ if (!range)
108
+ continue;
109
+ const set = majorsOf.get(entry.name) ?? new Set();
110
+ set.add(range.parsed[0]);
111
+ majorsOf.set(entry.name, set);
112
+ }
113
+ /** Whether `latest` would be taken for `name` from a range on `major`. */
114
+ const acceptsMajor = (name, major, latest) => isFramework(name) || options.major === true || Number(latest.split('.')[0]) === major;
115
+ 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 });
116
+ if (names.length > 0 && names.every((name) => results.get(name)?.latest === undefined)) {
117
+ throw new RegistryUnavailableError(registry);
118
+ }
119
+ for (const entry of managed) {
120
+ const info = results.get(entry.name);
121
+ const range = parseSimpleRange(entry.current);
122
+ if (!range)
123
+ continue;
124
+ if (info.latest === undefined) {
125
+ entry.status = 'failed';
126
+ continue;
127
+ }
128
+ entry.latest = info.latest;
129
+ const latest = parseVersion(info.latest);
130
+ if (!latest) {
131
+ entry.status = 'failed';
132
+ continue;
133
+ }
134
+ const order = compareVersions(latest, range.parsed);
135
+ if (order === 0) {
136
+ entry.status = 'current';
137
+ continue;
138
+ }
139
+ if (order < 0) {
140
+ entry.status = 'ahead';
141
+ continue;
142
+ }
143
+ entry.kind = updateKind(range.parsed, latest);
144
+ if (!acceptsMajor(entry.name, range.parsed[0], info.latest)) {
145
+ entry.status = 'held-major';
146
+ continue;
147
+ }
148
+ if (info.age !== 'mature') {
149
+ entry.status = 'too-fresh';
150
+ entry.freshness = info.age;
151
+ continue;
152
+ }
153
+ entry.status = 'update';
154
+ entry.target = `${range.style}${info.latest}`;
155
+ }
156
+ const updates = entries.filter((entry) => entry.status === 'update' && entry.target !== undefined);
157
+ const files = {};
158
+ for (const manifest of manifests) {
159
+ let text = manifest.text;
160
+ for (const entry of updates.filter((candidate) => candidate.file === manifest.file)) {
161
+ text = setDependencyRange(text, entry.section, entry.name, entry.target);
162
+ }
163
+ if (text !== manifest.text)
164
+ files[manifest.file] = { before: manifest.text, after: text };
165
+ }
166
+ const toolingNotes = [];
167
+ const manual = [];
168
+ const warnings = [];
169
+ if (tooling) {
170
+ // create-basalt devDependency + the `basalt` script that reaches it.
171
+ const createBasaltLatest = results.get('create-basalt');
172
+ const rootText = files['package.json']?.after ?? ctx.packageJsonText;
173
+ if (addCreateBasalt && createBasaltLatest?.latest !== undefined) {
174
+ const fresh = createBasaltLatest.age !== 'mature';
175
+ if (fresh) {
176
+ warnings.push(`create-basalt ${createBasaltLatest.latest} is inside the release-age window — not added as a devDependency this time.`);
177
+ }
178
+ else {
179
+ const hasBin = ctx.options.cli;
180
+ const merged = mergePackageJson(rootText, {
181
+ devDependencies: { 'create-basalt': `^${createBasaltLatest.latest}` },
182
+ scripts: { basalt: hasBin ? 'tsx bin/basalt.ts' : BASALT_PROJECT_SCRIPT },
183
+ });
184
+ if (merged.changes.length > 0) {
185
+ files['package.json'] = { before: ctx.packageJsonText, after: merged.text };
186
+ 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`);
187
+ }
188
+ if (workspaceYaml !== undefined && !workspaceExcludes(workspaceYaml, 'create-basalt')) {
189
+ const patched = addWorkspaceExclude(workspaceYaml, 'create-basalt');
190
+ if (patched !== undefined) {
191
+ files['pnpm-workspace.yaml'] = { before: workspaceYaml, after: patched };
192
+ toolingNotes.push('pnpm-workspace.yaml: create-basalt joins minimumReleaseAgeExclude (like @basaltkit/*)');
193
+ }
194
+ }
195
+ }
196
+ }
197
+ // bin/basalt.ts from an earlier template: make it delegate the project commands.
198
+ const bin = await readOptional(join(ctx.dir, 'bin', 'basalt.ts'));
199
+ const status = binStatus(bin, ctx.manifest?.files['bin/basalt.ts']);
200
+ if (status === 'patchable' && bin !== undefined) {
201
+ const after = patchBasaltBin(bin);
202
+ files['bin/basalt.ts'] = { before: bin, after };
203
+ toolingNotes.push('bin/basalt.ts: unmodified template from an earlier release — gains update/add/doctor/info');
204
+ if (ctx.manifest) {
205
+ files[MANIFEST_PATH] = {
206
+ before: serializeManifest(ctx.manifest),
207
+ after: serializeManifest(extendManifest(ctx.manifest, ctx.options, { 'bin/basalt.ts': after })),
208
+ };
209
+ }
210
+ }
211
+ else if (status === 'modified') {
212
+ manual.push(`bin/basalt.ts was customised, so it was not patched. To give it the project commands:\n${MANUAL_BIN_SNIPPET}`);
213
+ }
214
+ }
215
+ // Peer ranges of the framework versions we end up on vs the app's resulting ranges.
216
+ const resulting = new Map();
217
+ for (const entry of entries) {
218
+ if (entry.file !== 'package.json' && resulting.has(entry.name))
219
+ continue;
220
+ resulting.set(entry.name, entry.target ?? entry.current);
221
+ }
222
+ for (const entry of entries) {
223
+ if (!entry.framework || (entry.status !== 'update' && entry.status !== 'current') || entry.file !== 'package.json')
224
+ continue;
225
+ const info = results.get(entry.name);
226
+ for (const [peer, wanted] of Object.entries(info?.peers ?? {})) {
227
+ const range = resulting.get(peer);
228
+ const floor = range ? parseSimpleRange(range) : undefined;
229
+ if (!floor)
230
+ continue;
231
+ if (satisfies(floor.version, wanted) === false) {
232
+ const peerEntry = entries.find((candidate) => candidate.name === peer);
233
+ const hint = peerEntry?.status === 'held-major' ? ' — re-run with --major' : '';
234
+ warnings.push(`${entry.name}@${info?.latest} expects ${peer} ${wanted}, but the app will have ${range}${hint}`);
235
+ }
236
+ }
237
+ }
238
+ const failed = entries.filter((entry) => entry.status === 'failed').map((entry) => entry.name);
239
+ if (failed.length > 0)
240
+ warnings.push(`The registry did not answer for ${[...new Set(failed)].join(', ')} — left unchanged.`);
241
+ const majors = updates
242
+ .filter((entry) => entry.framework && entry.kind === 'major')
243
+ .map((entry) => ({
244
+ name: entry.name,
245
+ from: parseSimpleRange(entry.current)?.version ?? entry.current,
246
+ to: entry.latest,
247
+ changelog: changelogUrl(entry.name),
248
+ }))
249
+ .filter((major, i, all) => all.findIndex((other) => other.name === major.name) === i);
250
+ return { entries, updates, files, tooling: toolingNotes, manual, warnings, majors, registry };
251
+ }
252
+ /** The plan as terminal lines: the update table, then notes, tooling, warnings and majors. */
253
+ export function renderUpdatePlan(plan, colors) {
254
+ const lines = [];
255
+ const kindLabel = (kind) => kind === 'major' ? colors.red(colors.bold('MAJOR')) : kind === 'minor' ? colors.yellow('minor') : colors.green('patch');
256
+ const multiFile = new Set(plan.entries.map((entry) => entry.file)).size > 1;
257
+ if (plan.updates.length === 0) {
258
+ lines.push(colors.green('Every dependency is already on its latest allowed version.'));
259
+ }
260
+ else {
261
+ lines.push(colors.bold(`${plan.updates.length} update(s):`), '');
262
+ lines.push(...table(['package', ...(multiFile ? ['in'] : []), 'current', '', 'target', 'kind'], plan.updates.map((entry) => [
263
+ entry.framework ? colors.cyan(entry.name) : entry.name,
264
+ ...(multiFile ? [entry.file === 'package.json' ? '.' : entry.file.replace(/\/package\.json$/, '/')] : []),
265
+ entry.current,
266
+ '→',
267
+ colors.bold(entry.target),
268
+ kindLabel(entry.kind),
269
+ ]), colors).map((line) => ` ${line}`));
270
+ }
271
+ const held = plan.entries.filter((entry) => entry.status === 'held-major');
272
+ const fresh = plan.entries.filter((entry) => entry.status === 'too-fresh');
273
+ if (held.length > 0 || fresh.length > 0)
274
+ lines.push('');
275
+ for (const entry of dedupe(held)) {
276
+ lines.push(colors.dim(` kept ${entry.name} ${entry.current}: ${entry.latest} is a new major — pass --major to take it`));
277
+ }
278
+ for (const entry of dedupe(fresh)) {
279
+ lines.push(colors.dim(entry.freshness === 'recent'
280
+ ? ` kept ${entry.name} ${entry.current}: ${entry.latest} is inside the release-age window (minimumReleaseAge) — retry later`
281
+ : ` kept ${entry.name} ${entry.current}: could not prove ${entry.latest} is older than the release-age window`));
282
+ }
283
+ if (plan.majors.length > 0) {
284
+ lines.push('', colors.bold('Framework majors — read before applying:'));
285
+ for (const major of plan.majors)
286
+ lines.push(` ${major.name} ${major.from} → ${major.to}: ${major.changelog}`);
287
+ lines.push(` Upgrade notes: ${DOCS_UPGRADING_URL}`);
288
+ }
289
+ if (plan.tooling.length > 0) {
290
+ lines.push('', colors.bold('Project tooling:'));
291
+ for (const note of plan.tooling)
292
+ lines.push(` ${note}`);
293
+ }
294
+ for (const warning of plan.warnings)
295
+ lines.push('', colors.yellow(`Warning: ${warning}`));
296
+ for (const step of plan.manual)
297
+ lines.push('', colors.yellow('Manual step:'), step);
298
+ return lines;
299
+ }
300
+ function dedupe(entries) {
301
+ return entries.filter((entry, i) => entries.findIndex((other) => other.name === entry.name && other.current === entry.current) === i);
302
+ }
@@ -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;
@@ -56,7 +61,6 @@ export declare function serverTs(): string;
56
61
  * NODE_ENV counts as production and secrets are required (fail-closed).
57
62
  */
58
63
  export declare function devTs(): string;
59
- export declare function basaltBin(): string;
60
64
  /**
61
65
  * `prisma/schema.prisma` — the datasource, the client generator, the models of
62
66
  * every Basalt domain this app enables, and the app's own first model.
@@ -87,6 +91,12 @@ export declare function dbTs(options: ProjectOptions): string;
87
91
  export declare function prismaSeedTs(options: ProjectOptions): string;
88
92
  export declare function appTest(options: ProjectOptions): string;
89
93
  export declare function readme(options: ProjectOptions): string;
94
+ /** README section for the \`basalt\` CLI (also appended by \`create-basalt add cli\`). */
95
+ export declare function readmeCliSection(_options: ProjectOptions): string;
96
+ /** README section for the web/ frontend (also appended by \`create-basalt add ui\`). */
97
+ export declare function readmeUiSection(options: ProjectOptions): string;
98
+ /** README section for the MCP server (also appended by \`create-basalt add mcp\`). */
99
+ export declare function readmeMcpSection(_options: ProjectOptions): string;
90
100
  /**
91
101
  * A Claude Code / generic MCP client config that registers the dev-only
92
102
  * `basalt-ai-mcp` bridge for this project. Claude Code reads `.mcp.json` at the
package/dist/templates.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { THIRD_PARTY_VERSIONS } from './latest-versions.js';
2
2
  import { SCAFFOLD_VERSIONS } from './versions.js';
3
+ export { basaltBin } from './bin-template.js';
3
4
  export { SCAFFOLD_VERSIONS } from './versions.js';
4
5
  export { THIRD_PARTY_VERSIONS } from './latest-versions.js';
5
6
  /**
@@ -28,6 +29,10 @@ export const thirdPartyVersionOf = (pkg) => {
28
29
  }
29
30
  return range;
30
31
  };
32
+ /** The root `basalt` script of an app without `--cli`: create-basalt's project commands. */
33
+ export const BASALT_PROJECT_SCRIPT = 'create-basalt --project';
34
+ /** The root `dev:web` script of a `--ui` app (web/ is a pnpm workspace member). */
35
+ export const webDevScript = (options) => `pnpm --filter ${options.name}-web dev`;
31
36
  /** True when the scaffold binds users to the tenant they act on (tenancy + auth). */
32
37
  export const enforcesMembership = (options) => options.tenancy && options.auth;
33
38
  export function packageJson(options) {
@@ -76,6 +81,9 @@ export function packageJson(options) {
76
81
  }
77
82
  const devDependencies = {
78
83
  '@basaltkit/testing': versionOf('@basaltkit/testing'),
84
+ // The project commands (\`pnpm basalt update | add | doctor | info\`) — dev
85
+ // tooling with no dependencies of its own, never imported by the app.
86
+ 'create-basalt': versionOf('create-basalt'),
79
87
  };
80
88
  for (const pkg of ['@types/node', 'pino-pretty', 'tsx', 'typescript', 'vitest']) {
81
89
  devDependencies[pkg] = thirdPartyVersionOf(pkg);
@@ -107,7 +115,11 @@ export function packageJson(options) {
107
115
  start: 'tsx src/server.ts',
108
116
  test: 'vitest run',
109
117
  typecheck: 'tsc --noEmit',
110
- ...(options.cli ? { basalt: 'tsx bin/basalt.ts' } : {}),
118
+ // With --cli, bin/basalt.ts serves the generators AND hands the project
119
+ // commands to create-basalt; without it, \`pnpm basalt update\` goes to
120
+ // create-basalt directly (\`create-basalt add cli\` swaps the script).
121
+ basalt: options.cli ? 'tsx bin/basalt.ts' : BASALT_PROJECT_SCRIPT,
122
+ ...(options.ui ? { 'dev:web': webDevScript(options) } : {}),
111
123
  ...(options.prisma
112
124
  ? {
113
125
  // Generate right after install so `pnpm typecheck` has the client
@@ -526,33 +538,6 @@ process.env['NODE_ENV'] ??= 'development'
526
538
  await import('./server.js')
527
539
  `;
528
540
  }
529
- export function basaltBin() {
530
- return `#!/usr/bin/env node
531
- import { runCli } from '@basaltkit/cli'
532
- import { generatorCommands } from '@basaltkit/generator'
533
- import { prismaSyncCommand } from '@basaltkit/prisma'
534
-
535
- // Dev tooling: opt into development defaults unless NODE_ENV is already set
536
- // (imported dynamically below so env.ts is evaluated after this line).
537
- process.env['NODE_ENV'] ??= 'development'
538
- const { buildApp } = await import('../src/app.js')
539
-
540
- // The 'basalt' CLI: boots the app WITH the dev/CLI commands, runs one, shuts down.
541
- // The dev tools (@basaltkit/generator; add @basaltkit/ai for ai:*) are imported
542
- // ONLY here — the runtime server (src/server.ts) never loads them, so the SaaS
543
- // runs without the codegen/AI layer.
544
- // pnpm basalt dev — dev server: route table + watch (--worker for a queue worker)
545
- // pnpm basalt list — show available commands
546
- // pnpm basalt routes — list registered HTTP routes
547
- // pnpm basalt make:resource Project — generate a full resource vertical
548
- // pnpm basalt make:service Project — generate a single artifact (schema/service/…)
549
- const app = buildApp({
550
- logLevel: 'silent',
551
- commands: [...generatorCommands(), prismaSyncCommand()],
552
- })
553
- process.exit(await runCli({ app }))
554
- `;
555
- }
556
541
  /**
557
542
  * The models each `@basaltkit/<domain>-prisma` package needs, copied VERBATIM
558
543
  * from that package's reference `prisma/schema.prisma` — the same blocks
@@ -1044,8 +1029,24 @@ database and \`DATABASE_URL\` is read as \`${envPrefix(options.name)}_DATABASE_U
1044
1029
  To drop the fallback entirely (prefixed names only), write
1045
1030
  \`prefix: { value: '${envPrefix(options.name)}', fallback: false }\`. When in doubt about what
1046
1031
  your shell exports: \`env | grep DATABASE_URL\`.
1047
- ${options.cli
1048
- ? `
1032
+
1033
+ ## Keeping it up to date
1034
+
1035
+ \`\`\`bash
1036
+ pnpm basalt update # dependencies → latest (framework always; third-party within its major)
1037
+ pnpm basalt update --dry # preview the table, write nothing
1038
+ pnpm basalt add ui # add a feature later: ui, cli, mcp
1039
+ pnpm basalt doctor # Node, package manager, versions, env and Prisma checks
1040
+ \`\`\`
1041
+
1042
+ \`.basalt/project.json\` records what create-basalt generated (options and file
1043
+ hashes) — commit it: \`add\` and \`update\` use it to tell untouched template
1044
+ files from the ones you edited.
1045
+ ${options.cli ? readmeCliSection(options) : ''}${options.ui ? readmeUiSection(options) : ''}${options.mcp ? readmeMcpSection(options) : ''}`;
1046
+ }
1047
+ /** README section for the \`basalt\` CLI (also appended by \`create-basalt add cli\`). */
1048
+ export function readmeCliSection(_options) {
1049
+ return `
1049
1050
  ## The \`basalt\` CLI
1050
1051
 
1051
1052
  \`\`\`bash
@@ -1069,21 +1070,25 @@ node_modules/.bin/tsx bin/basalt.ts make:resource Project
1069
1070
 
1070
1071
  or set \`verifyDepsBeforeRun: warn\` in \`pnpm-workspace.yaml\` (a conscious
1071
1072
  choice: you then run \`pnpm install\` yourself after dependency changes).
1072
- `
1073
- : ''}${options.ui
1074
- ? `
1073
+ `;
1074
+ }
1075
+ /** README section for the web/ frontend (also appended by \`create-basalt add ui\`). */
1076
+ export function readmeUiSection(options) {
1077
+ return `
1075
1078
  ## Web UI
1076
1079
 
1077
1080
  \`\`\`bash
1078
- pnpm dev # terminal 1 — API on :3000
1079
- pnpm --filter ${options.name}-web dev # terminal 2 — UI on http://localhost:5180
1081
+ pnpm dev # terminal 1 — API on :3000
1082
+ pnpm dev:web # terminal 2 — UI on http://localhost:5180 (pnpm --filter ${options.name}-web dev)
1080
1083
  \`\`\`
1081
1084
 
1082
1085
  Open <http://localhost:5180>. The Vite dev server proxies \`/api\` to the API,
1083
1086
  so there is no CORS to configure${options.auth ? '. Register, then sign in' : ''}.
1084
- `
1085
- : ''}${options.mcp
1086
- ? `
1087
+ `;
1088
+ }
1089
+ /** README section for the MCP server (also appended by \`create-basalt add mcp\`). */
1090
+ export function readmeMcpSection(_options) {
1091
+ return `
1087
1092
  ## MCP server
1088
1093
 
1089
1094
  Read-only routes marked with \`meta.mcp\` in \`src/routes.ts\` (the overview and
@@ -1109,8 +1114,7 @@ app's runtime) with MCP clients such as Claude Code and Claude Desktop. It expos
1109
1114
  \`basalt_make\` (preview by default, writes confined to this project) plus workflow
1110
1115
  prompts. Claude Code picks up \`.mcp.json\` automatically; for Claude Desktop add the
1111
1116
  same server to its config with \`--cwd=<absolute project path>\`.
1112
- `
1113
- : ''}`;
1117
+ `;
1114
1118
  }
1115
1119
  /**
1116
1120
  * A Claude Code / generic MCP client config that registers the dev-only
@@ -1183,10 +1187,12 @@ export function pnpmWorkspaceYaml(options) {
1183
1187
  : ''}allowBuilds:
1184
1188
  esbuild: true
1185
1189
  msgpackr-extract: false
1186
- # @basaltkit/* releases in lockstep, often within hours — exclude the scope from
1187
- # pnpm's minimumReleaseAge policy so \`pnpm up\` is never blocked on a fresh release.
1190
+ # @basaltkit/* releases in lockstep, often within hours — exclude the scope (and
1191
+ # create-basalt, which serves \`pnpm basalt update\`) from pnpm's minimumReleaseAge
1192
+ # policy so an update is never blocked on a fresh release.
1188
1193
  minimumReleaseAgeExclude:
1189
1194
  - '@basaltkit/*'
1195
+ - create-basalt
1190
1196
  # To let specific versions of ONE package bypass the policy, write ONE entry
1191
1197
  # with a \`||\` union. pnpm evaluates this list first match wins by package
1192
1198
  # name, so a second '<name>@<version>' entry for the same package is ignored: