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.
@@ -108,10 +108,34 @@ export declare function registryUrl(explicit?: string, env?: NodeJS.ProcessEnv):
108
108
  export declare const latestUrl: (registry: string, name: string) => string;
109
109
  /** `<registry>/<name>` — the packument (probed with HEAD for its `last-modified`). */
110
110
  export declare const packumentUrl: (registry: string, name: string) => string;
111
+ /** What the registry says about one package (see {@link lookupLatestVersions}). */
112
+ export interface LatestInfo {
113
+ /** The `latest` dist-tag (stable releases only), or undefined when the lookup failed. */
114
+ latest?: string;
115
+ /** `peerDependencies` declared by that latest version. */
116
+ peers: Record<string, string>;
117
+ /**
118
+ * Release age of `latest` against the window: `mature` (provably older, or
119
+ * not probed), `recent` (inside the window) or `unknown` (could not prove it).
120
+ */
121
+ age: 'mature' | 'recent' | 'unknown';
122
+ }
123
+ /**
124
+ * The shared registry primitive behind both the scaffold and `create-basalt
125
+ * update`: looks up `latest` for every name through a small pool of parallel
126
+ * requests (one retry on transient failures, a global time cap) and, when
127
+ * `shouldProbe(name, latest)` says the version would actually be used, probes
128
+ * its release age against the window. Never throws — a package the registry
129
+ * cannot answer for comes back without `latest`.
130
+ */
131
+ export declare function lookupLatestVersions(names: readonly string[], shouldProbe: (name: string, latest: string) => boolean, options?: ResolveLatestOptions): Promise<{
132
+ results: Map<string, LatestInfo>;
133
+ registry: string;
134
+ }>;
111
135
  /**
112
136
  * Resolves the latest published version of every package in `fallbacks`
113
- * (name → fallback range) through a small pool of parallel requests. Never throws: a package the registry
114
- * cannot answer for keeps its fallback range and is listed in `failed`.
137
+ * (name → fallback range). Never throws: a package the registry cannot answer
138
+ * for keeps its fallback range and is listed in `failed`.
115
139
  */
116
140
  export declare function resolveLatestVersions(fallbacks: Readonly<Record<string, string>>, options?: ResolveLatestOptions): Promise<VersionResolution>;
117
141
  /**
@@ -119,6 +143,11 @@ export declare function resolveLatestVersions(fallbacks: Readonly<Record<string,
119
143
  * resolved versions (keys, order and every other field untouched).
120
144
  */
121
145
  export declare function applyVersions(packageJsonText: string, versions: Readonly<Record<string, string>>): string;
146
+ /**
147
+ * Resolves the latest published versions for every package.json in `files`
148
+ * (in place) — the scaffold's registry policy, shared with `create-basalt add`.
149
+ */
150
+ export declare function resolveFileVersions(files: Record<string, string>, registry?: ResolveLatestOptions): Promise<VersionResolution>;
122
151
  /** Every dependency/devDependency name → range declared in the given package.json texts. */
123
152
  export declare function collectDependencies(packageJsonTexts: readonly string[]): Record<string, string>;
124
153
  /** Human-readable lines summarizing a resolution (for the CLI). */
@@ -140,6 +140,18 @@ async function probeAge(fetchImpl, registry, name, windowMs, now, signal) {
140
140
  return 'unknown';
141
141
  }
142
142
  }
143
+ /** The `peerDependencies` of a `/latest` document (string values only). */
144
+ function peersOf(body) {
145
+ const peers = {};
146
+ const raw = body?.peerDependencies;
147
+ if (raw && typeof raw === 'object') {
148
+ for (const [name, range] of Object.entries(raw)) {
149
+ if (typeof range === 'string')
150
+ peers[name] = range;
151
+ }
152
+ }
153
+ return peers;
154
+ }
143
155
  async function lookupLatest(fetchImpl, registry, name, signal) {
144
156
  try {
145
157
  const response = await fetchImpl(latestUrl(registry, name), {
@@ -152,7 +164,7 @@ async function lookupLatest(fetchImpl, registry, name, signal) {
152
164
  return 'miss';
153
165
  const body = (await response.json());
154
166
  const version = body?.version;
155
- return typeof version === 'string' && STABLE_SEMVER.test(version) ? { version } : 'miss';
167
+ return typeof version === 'string' && STABLE_SEMVER.test(version) ? { version, peers: peersOf(body) } : 'miss';
156
168
  }
157
169
  catch {
158
170
  // Network error, per-request timeout, or malformed JSON.
@@ -171,80 +183,97 @@ async function fetchLatest(fetchImpl, registry, name, timeoutMs, overall) {
171
183
  if (result === 'miss')
172
184
  return undefined;
173
185
  if (result !== 'transient')
174
- return result.version;
186
+ return result;
175
187
  }
176
188
  return undefined;
177
189
  }
178
190
  /**
179
- * Resolves the latest published version of every package in `fallbacks`
180
- * (name → fallback range) through a small pool of parallel requests. Never throws: a package the registry
181
- * cannot answer for keeps its fallback range and is listed in `failed`.
191
+ * The shared registry primitive behind both the scaffold and `create-basalt
192
+ * update`: looks up `latest` for every name through a small pool of parallel
193
+ * requests (one retry on transient failures, a global time cap) and, when
194
+ * `shouldProbe(name, latest)` says the version would actually be used, probes
195
+ * its release age against the window. Never throws — a package the registry
196
+ * cannot answer for comes back without `latest`.
182
197
  */
183
- export async function resolveLatestVersions(fallbacks, options = {}) {
198
+ export async function lookupLatestVersions(names, shouldProbe, options = {}) {
184
199
  const fetchImpl = options.fetch ?? globalThis.fetch;
185
200
  const registry = registryUrl(options.registry);
186
- const versions = { ...fallbacks };
187
- const resolved = [];
188
- const failed = [];
189
- const heldBack = [];
190
- const tooFresh = [];
191
- const names = Object.keys(fallbacks).sort();
201
+ const results = new Map();
202
+ if (typeof fetchImpl !== 'function') {
203
+ for (const name of names)
204
+ results.set(name, { peers: {}, age: 'mature' });
205
+ return { results, registry };
206
+ }
192
207
  const windowMs = minimumReleaseAgeMinutes(options.minimumReleaseAge) * 60_000;
193
208
  const now = options.now ?? Date.now;
194
209
  const timeoutMs = options.timeoutMs ?? PER_REQUEST_TIMEOUT_MS;
195
- if (typeof fetchImpl !== 'function') {
196
- return { versions, resolved, failed: names, heldBack, tooFresh, registry };
197
- }
198
- /** A third-party latest on the template-compatible major (else undefined). */
199
- const compatible = (name, version) => {
200
- if (alwaysLatest(name))
201
- return true;
202
- const major = compatibleMajor(fallbacks[name]);
203
- return major !== undefined && Number(version.split('.')[0]) === major;
204
- };
205
210
  const overall = new AbortController();
206
211
  const cap = setTimeout(() => overall.abort(), options.overallTimeoutMs ?? OVERALL_TIMEOUT_MS);
207
212
  try {
208
- const latest = [];
209
- const age = [];
210
213
  let next = 0;
211
214
  const worker = async () => {
212
215
  while (next < names.length) {
213
- const i = next++;
214
- const name = names[i];
215
- const version = await fetchLatest(fetchImpl, registry, name, timeoutMs, overall.signal);
216
- latest[i] = version;
217
- age[i] =
218
- version === undefined || alwaysLatest(name) || windowMs === 0 || !compatible(name, version)
219
- ? 'mature'
220
- : await probeAge(fetchImpl, registry, name, windowMs, now, AbortSignal.any([AbortSignal.timeout(timeoutMs), overall.signal]));
216
+ const name = names[next++];
217
+ const found = await fetchLatest(fetchImpl, registry, name, timeoutMs, overall.signal);
218
+ if (found === undefined) {
219
+ results.set(name, { peers: {}, age: 'mature' });
220
+ continue;
221
+ }
222
+ const age = windowMs === 0 || !shouldProbe(name, found.version)
223
+ ? 'mature'
224
+ : await probeAge(fetchImpl, registry, name, windowMs, now, AbortSignal.any([AbortSignal.timeout(timeoutMs), overall.signal]));
225
+ results.set(name, { latest: found.version, peers: found.peers, age });
221
226
  }
222
227
  };
223
228
  const pool = Math.max(1, Math.min(options.concurrency ?? CONCURRENCY, names.length));
224
229
  await Promise.all(Array.from({ length: pool }, worker));
225
- names.forEach((name, i) => {
226
- const version = latest[i];
227
- const fallback = fallbacks[name];
228
- if (version === undefined) {
229
- failed.push(name);
230
- return;
231
- }
232
- if (!compatible(name, version)) {
233
- heldBack.push({ name, latest: version, range: fallback });
234
- return;
235
- }
236
- const verdict = age[i];
237
- if (verdict === 'recent' || verdict === 'unknown') {
238
- tooFresh.push({ name, latest: version, range: fallback, reason: verdict });
239
- return;
240
- }
241
- versions[name] = `^${version}`;
242
- resolved.push(name);
243
- });
244
230
  }
245
231
  finally {
246
232
  clearTimeout(cap);
247
233
  }
234
+ return { results, registry };
235
+ }
236
+ /**
237
+ * Resolves the latest published version of every package in `fallbacks`
238
+ * (name → fallback range). Never throws: a package the registry cannot answer
239
+ * for keeps its fallback range and is listed in `failed`.
240
+ */
241
+ export async function resolveLatestVersions(fallbacks, options = {}) {
242
+ const versions = { ...fallbacks };
243
+ const resolved = [];
244
+ const failed = [];
245
+ const heldBack = [];
246
+ const tooFresh = [];
247
+ const names = Object.keys(fallbacks).sort();
248
+ /** A third-party latest on the template-compatible major (else undefined). */
249
+ const compatible = (name, version) => {
250
+ if (alwaysLatest(name))
251
+ return true;
252
+ const major = compatibleMajor(fallbacks[name]);
253
+ return major !== undefined && Number(version.split('.')[0]) === major;
254
+ };
255
+ // @basaltkit/* is never probed: the scaffolded pnpm-workspace.yaml excludes
256
+ // that scope from the release-age policy.
257
+ const { results, registry } = await lookupLatestVersions(names, (name, version) => !alwaysLatest(name) && compatible(name, version), options);
258
+ for (const name of names) {
259
+ const info = results.get(name);
260
+ const version = info?.latest;
261
+ const fallback = fallbacks[name];
262
+ if (info === undefined || version === undefined) {
263
+ failed.push(name);
264
+ continue;
265
+ }
266
+ if (!compatible(name, version)) {
267
+ heldBack.push({ name, latest: version, range: fallback });
268
+ continue;
269
+ }
270
+ if (info.age === 'recent' || info.age === 'unknown') {
271
+ tooFresh.push({ name, latest: version, range: fallback, reason: info.age });
272
+ continue;
273
+ }
274
+ versions[name] = `^${version}`;
275
+ resolved.push(name);
276
+ }
248
277
  return { versions, resolved, failed, heldBack, tooFresh, registry };
249
278
  }
250
279
  /**
@@ -265,6 +294,17 @@ export function applyVersions(packageJsonText, versions) {
265
294
  }
266
295
  return `${JSON.stringify(pkg, null, 2)}\n`;
267
296
  }
297
+ /**
298
+ * Resolves the latest published versions for every package.json in `files`
299
+ * (in place) — the scaffold's registry policy, shared with `create-basalt add`.
300
+ */
301
+ export async function resolveFileVersions(files, registry) {
302
+ const manifests = Object.keys(files).filter((path) => path === 'package.json' || path.endsWith('/package.json'));
303
+ const versions = await resolveLatestVersions(collectDependencies(manifests.map((path) => files[path])), registry);
304
+ for (const path of manifests)
305
+ files[path] = applyVersions(files[path], versions.versions);
306
+ return versions;
307
+ }
268
308
  /** Every dependency/devDependency name → range declared in the given package.json texts. */
269
309
  export function collectDependencies(packageJsonTexts) {
270
310
  const all = {};
@@ -0,0 +1,66 @@
1
+ import { type ResolveLatestOptions, type VersionResolution } from '../latest-versions.js';
2
+ import { type ProjectOptions } from '../templates.js';
3
+ import type { ProjectContext } from './context.js';
4
+ /**
5
+ * `create-basalt add <feature>`: give an existing app what the matching
6
+ * scaffold flag would have generated, adapted to the app as it is now.
7
+ *
8
+ * Rules: an existing file is never overwritten (skipped with a notice;
9
+ * `--force` overwrites); package.json, pnpm-workspace.yaml, .gitignore and the
10
+ * README are MERGED; user-owned code (src/app.ts, src/routes.ts) is
11
+ * regenerated only when the manifest proves it untouched, patched only where
12
+ * the template's anchors are still unambiguous, and otherwise left alone with
13
+ * the exact manual steps. Planning writes nothing — {@link applyAddPlan} does.
14
+ */
15
+ export declare const ADDABLE_FEATURES: readonly ['ui', 'cli', 'mcp'];
16
+ export type AddableFeature = (typeof ADDABLE_FEATURES)[number];
17
+ export interface PlannedChange {
18
+ path: string;
19
+ /** create: new file · overwrite: --force over an existing file · update: merged/patched · skip: left alone. */
20
+ action: 'create' | 'overwrite' | 'update' | 'skip';
21
+ /** Content to write (absent for skip). */
22
+ content?: string;
23
+ /** Why (shown next to the path). */
24
+ note?: string;
25
+ }
26
+ export interface AddPlan {
27
+ feature: AddableFeature;
28
+ /** The app's options once the feature is on. */
29
+ options: ProjectOptions;
30
+ changes: PlannedChange[];
31
+ /** Steps printed for the user (code that could not be edited safely). */
32
+ manual: string[];
33
+ /** Informational lines (versions resolved, things kept). */
34
+ notes: string[];
35
+ /** Set when there is nothing to do (the feature is already there). */
36
+ alreadyPresent?: string;
37
+ versions?: VersionResolution;
38
+ }
39
+ export interface AddOptions {
40
+ force?: boolean;
41
+ /** Resolve the latest versions from the registry (default true; false = bundled ranges). */
42
+ resolveLatest?: boolean;
43
+ registry?: ResolveLatestOptions;
44
+ }
45
+ export declare class AddRefusedError extends Error {
46
+ constructor(message: string);
47
+ }
48
+ /** Ensures pnpm-workspace.yaml lists `member` under `packages:`; undefined when the shape is not one we can edit. */
49
+ export declare function addWorkspaceMember(yaml: string, member: string): string | undefined;
50
+ /** Appends missing ignore lines (`node_modules/` also matches an existing `node_modules` or `/node_modules/`). */
51
+ export declare function ensureGitignore(text: string, lines: readonly string[]): string;
52
+ /**
53
+ * Wires `commandsPlugin` into a scaffold-shaped src/app.ts (any adapter):
54
+ * the import, `commands?: CommandDefinition[]` on BuildAppOptions, and the
55
+ * plugin right before the adapter. Undefined when an anchor is missing or
56
+ * ambiguous — the caller prints manual steps instead.
57
+ */
58
+ export declare function patchAppForCli(source: string): string | undefined;
59
+ /**
60
+ * Wires the MCP server into a scaffold-shaped src/app.ts (any adapter):
61
+ * `mcpPlugin({ routes: <the adapter's routes> })` before the adapter, and
62
+ * `...mcpRoutes()` appended to the adapter's routes.
63
+ */
64
+ export declare function patchAppForMcp(source: string, name: string): string | undefined;
65
+ /** Plans `add <feature>` for the app in `ctx`. Writes nothing. */
66
+ export declare function planAdd(ctx: ProjectContext, feature: AddableFeature, options?: AddOptions): Promise<AddPlan>;
@@ -0,0 +1,322 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { basaltBin } from '../bin-template.js';
4
+ import { collectDependencies, resolveFileVersions, resolveLatestVersions, } from '../latest-versions.js';
5
+ import { appTs, BASALT_PROJECT_SCRIPT, gitignore, mcpJson, pnpmWorkspaceYaml, readme, readmeCliSection, readmeMcpSection, readmeUiSection, routesTs, versionOf, webDevScript, } from '../templates.js';
6
+ import { uiFiles } from '../templates-ui.js';
7
+ import { createManifest, extendManifest, isPristine, MANIFEST_PATH, serializeManifest } from './manifest.js';
8
+ import { mergePackageJson } from './package-json.js';
9
+ /**
10
+ * `create-basalt add <feature>`: give an existing app what the matching
11
+ * scaffold flag would have generated, adapted to the app as it is now.
12
+ *
13
+ * Rules: an existing file is never overwritten (skipped with a notice;
14
+ * `--force` overwrites); package.json, pnpm-workspace.yaml, .gitignore and the
15
+ * README are MERGED; user-owned code (src/app.ts, src/routes.ts) is
16
+ * regenerated only when the manifest proves it untouched, patched only where
17
+ * the template's anchors are still unambiguous, and otherwise left alone with
18
+ * the exact manual steps. Planning writes nothing — {@link applyAddPlan} does.
19
+ */
20
+ export const ADDABLE_FEATURES = ['ui', 'cli', 'mcp'];
21
+ export class AddRefusedError extends Error {
22
+ constructor(message) {
23
+ super(message);
24
+ this.name = 'AddRefusedError';
25
+ }
26
+ }
27
+ async function readOptional(dir, path) {
28
+ try {
29
+ return await readFile(join(dir, path), 'utf8');
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // Safe edits of user-owned files
37
+ /** Ensures pnpm-workspace.yaml lists `member` under `packages:`; undefined when the shape is not one we can edit. */
38
+ export function addWorkspaceMember(yaml, member) {
39
+ const packages = /^packages:[ \t]*(.*)$/m.exec(yaml);
40
+ if (!packages)
41
+ return `packages:\n - ${member}\n${yaml}`;
42
+ if ((packages[1] ?? '').trim() !== '')
43
+ return undefined; // flow style (packages: [a, b]) — leave to the user
44
+ const after = yaml.slice(packages.index + packages[0].length);
45
+ const items = /^((?:\n[ \t]+-.*|\n[ \t]*#.*)*)/.exec(after)?.[1] ?? '';
46
+ const listed = items
47
+ .split('\n')
48
+ .map((line) => /^\s*-\s*['"]?([^'"#\s]+)['"]?/.exec(line)?.[1])
49
+ .filter((entry) => entry !== undefined);
50
+ if (listed.includes(member) || listed.includes(`${member}/`))
51
+ return yaml;
52
+ const indent = /\n([ \t]+)-/.exec(items)?.[1] ?? ' ';
53
+ const end = packages.index + packages[0].length + items.length;
54
+ return `${yaml.slice(0, end)}\n${indent}- ${member}${yaml.slice(end)}`;
55
+ }
56
+ /** Appends missing ignore lines (`node_modules/` also matches an existing `node_modules` or `/node_modules/`). */
57
+ export function ensureGitignore(text, lines) {
58
+ const normalize = (line) => line.trim().replace(/^\//, '').replace(/\/$/, '');
59
+ const present = new Set(text.split(/\r?\n/).map(normalize));
60
+ const missing = lines.filter((line) => !present.has(normalize(line)));
61
+ if (missing.length === 0)
62
+ return text;
63
+ const separator = text === '' || text.endsWith('\n') ? '' : '\n';
64
+ return `${text}${separator}${missing.join('\n')}\n`;
65
+ }
66
+ const ADAPTER_LINE = /^([ \t]*)(fastifyPlugin|expressPlugin|honoPlugin)\(\{ routes: (.+) \}\)(,?)[ \t]*$/gm;
67
+ /** The single adapter-plugin line (`fastifyPlugin|expressPlugin|honoPlugin({ routes: … })`), or undefined when absent/ambiguous. */
68
+ function adapterLine(source) {
69
+ const matches = [...source.matchAll(ADAPTER_LINE)];
70
+ return matches.length === 1 ? matches[0] : undefined;
71
+ }
72
+ /** Inserts an import line before the first import of the file. */
73
+ function addImport(source, line) {
74
+ const first = /^import /m.exec(source);
75
+ if (!first)
76
+ return undefined;
77
+ return `${source.slice(0, first.index)}${line}\n${source.slice(first.index)}`;
78
+ }
79
+ /**
80
+ * Wires `commandsPlugin` into a scaffold-shaped src/app.ts (any adapter):
81
+ * the import, `commands?: CommandDefinition[]` on BuildAppOptions, and the
82
+ * plugin right before the adapter. Undefined when an anchor is missing or
83
+ * ambiguous — the caller prints manual steps instead.
84
+ */
85
+ export function patchAppForCli(source) {
86
+ if (source.includes('commandsPlugin('))
87
+ return source;
88
+ if (!/export function buildApp\(options: BuildAppOptions = \{\}\)/.test(source))
89
+ return undefined;
90
+ const options = /export interface BuildAppOptions \{\n([\s\S]*?)\n\}/.exec(source);
91
+ const adapter = adapterLine(source);
92
+ if (!options || !adapter)
93
+ return undefined;
94
+ const indent = adapter[1] ?? '';
95
+ let out = source.slice(0, adapter.index) +
96
+ `${indent}...(options.commands && options.commands.length > 0 ? [commandsPlugin(options.commands)] : []),\n` +
97
+ source.slice(adapter.index);
98
+ out = out.replace(options[0], `export interface BuildAppOptions {\n${options[1]}\n /** Dev/CLI commands (make:*, ai:*, prisma:sync) — passed ONLY by bin/basalt.ts. */\n commands?: CommandDefinition[]\n}`);
99
+ return addImport(out, `import { commandsPlugin, type CommandDefinition } from '@basaltkit/cli'`);
100
+ }
101
+ /** `value` as a single-quoted TS string literal (backslashes, quotes and line breaks escaped). */
102
+ function singleQuoted(value) {
103
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\r/g, '\\r').replace(/\n/g, '\\n')}'`;
104
+ }
105
+ /**
106
+ * Wires the MCP server into a scaffold-shaped src/app.ts (any adapter):
107
+ * `mcpPlugin({ routes: <the adapter's routes> })` before the adapter, and
108
+ * `...mcpRoutes()` appended to the adapter's routes.
109
+ */
110
+ export function patchAppForMcp(source, name) {
111
+ if (source.includes('mcpPlugin('))
112
+ return source;
113
+ const adapter = adapterLine(source);
114
+ if (!adapter)
115
+ return undefined;
116
+ const [line, indent = '', plugin, routes = '', comma = ''] = adapter;
117
+ const expression = routes.trim();
118
+ let withMcp;
119
+ if (/^\[[\s\S]*\]$/.test(expression))
120
+ withMcp = `${expression.slice(0, -1)}, ...mcpRoutes()]`;
121
+ else if (/^[\w$.]+(\(\))?$/.test(expression))
122
+ withMcp = `[...${expression}, ...mcpRoutes()]`;
123
+ else
124
+ return undefined;
125
+ const replacement = `${indent}mcpPlugin({ routes: ${expression}, serverInfo: { name: ${singleQuoted(name)}, version: '0.1.0' } }),\n` +
126
+ `${indent}${plugin}({ routes: ${withMcp} })${comma}`;
127
+ const out = source.slice(0, adapter.index) + replacement + source.slice(adapter.index + line.length);
128
+ return addImport(out, `import { mcpPlugin, mcpRoutes } from '@basaltkit/mcp'`);
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // Planning
132
+ /** Ranges for packages a feature adds to the ROOT package.json (latest, or the bundled ranges). */
133
+ async function rootRanges(wanted, options) {
134
+ if (options.resolveLatest === false)
135
+ return { ranges: wanted };
136
+ const versions = await resolveLatestVersions(collectDependencies([JSON.stringify({ dependencies: wanted })]), options.registry);
137
+ return { ranges: versions.versions, versions };
138
+ }
139
+ /** A whole generated file: created, skipped when present, overwritten with --force. */
140
+ async function plannedFile(b, path, content) {
141
+ const existing = await readOptional(b.ctx.dir, path);
142
+ if (existing === undefined) {
143
+ b.changes.push({ path, action: 'create', content });
144
+ b.generated[path] = content;
145
+ }
146
+ else if (existing === content) {
147
+ b.changes.push({ path, action: 'skip', note: 'already identical' });
148
+ }
149
+ else if (b.force) {
150
+ b.changes.push({ path, action: 'overwrite', content, note: '--force' });
151
+ b.generated[path] = content;
152
+ }
153
+ else {
154
+ b.changes.push({ path, action: 'skip', note: 'exists — kept yours (--force to overwrite)' });
155
+ }
156
+ }
157
+ /** A merged edit of an existing file (or its creation when absent). */
158
+ function plannedEdit(b, path, before, after, note) {
159
+ if (before === after)
160
+ return;
161
+ b.changes.push({ path, action: before === undefined ? 'create' : 'update', content: after, note });
162
+ // A file create-basalt wrote and nobody touched stays "pristine" after our edit.
163
+ if (before === undefined || isPristine(b.ctx.manifest, path, before))
164
+ b.generated[path] = after;
165
+ }
166
+ /**
167
+ * A user-owned source file (src/app.ts, src/routes.ts): regenerated when the
168
+ * manifest proves it untouched, else patched by `patch`, else manual steps.
169
+ */
170
+ async function plannedSource(b, path, regenerate, patch, manual) {
171
+ const existing = await readOptional(b.ctx.dir, path);
172
+ if (existing === undefined) {
173
+ b.manual.push(`${path} does not exist. ${manual}`);
174
+ return;
175
+ }
176
+ if (regenerate && isPristine(b.ctx.manifest, path, existing)) {
177
+ plannedEdit(b, path, existing, regenerate(), 'untouched template — regenerated with the feature on');
178
+ return;
179
+ }
180
+ const patched = patch(existing);
181
+ if (patched === undefined) {
182
+ b.changes.push({ path, action: 'skip', note: 'customised — see the manual steps' });
183
+ b.manual.push(`${path}: ${manual}`);
184
+ }
185
+ else if (patched !== existing) {
186
+ b.changes.push({ path, action: 'update', content: patched, note: 'patched at the template anchors' });
187
+ }
188
+ }
189
+ /** README: regenerated when untouched (features line included), else the feature's section appended. */
190
+ async function plannedReadme(b, regenOptions, heading, section) {
191
+ const current = await readOptional(b.ctx.dir, 'README.md');
192
+ if (current === undefined || current.includes(heading))
193
+ return;
194
+ if (regenOptions && isPristine(b.ctx.manifest, 'README.md', current)) {
195
+ plannedEdit(b, 'README.md', current, readme(regenOptions), 'untouched template — regenerated with the feature on');
196
+ return;
197
+ }
198
+ plannedEdit(b, 'README.md', current, `${current}${current.endsWith('\n') ? '' : '\n'}${section}`, `+ "${heading}" section`);
199
+ }
200
+ async function plannedPackageJson(b, additions) {
201
+ const merged = mergePackageJson(b.ctx.packageJsonText, additions);
202
+ for (const kept of merged.kept)
203
+ b.notes.push(`package.json: ${kept}`);
204
+ if (merged.text !== b.ctx.packageJsonText) {
205
+ b.changes.push({ path: 'package.json', action: 'update', content: merged.text, note: merged.changes.join('; ') });
206
+ }
207
+ }
208
+ /** Plans `add <feature>` for the app in `ctx`. Writes nothing. */
209
+ export async function planAdd(ctx, feature, options = {}) {
210
+ const present = {
211
+ ui: ctx.options.ui,
212
+ cli: ctx.options.cli,
213
+ mcp: ctx.options.mcp,
214
+ };
215
+ const next = { ...ctx.options, [feature]: true };
216
+ const b = { ctx, force: options.force === true, changes: [], manual: [], notes: [], generated: {} };
217
+ if (present[feature] && !b.force) {
218
+ return {
219
+ feature,
220
+ options: next,
221
+ changes: [],
222
+ manual: [],
223
+ notes: [],
224
+ alreadyPresent: {
225
+ ui: 'web/package.json already exists',
226
+ cli: 'bin/basalt.ts already exists',
227
+ mcp: '@basaltkit/mcp is already a dependency',
228
+ }[feature],
229
+ };
230
+ }
231
+ // Regeneration uses the options the pristine file was generated FROM, with the feature flipped on.
232
+ const regenOptions = ctx.manifest ? { ...ctx.manifest.options, [feature]: true } : undefined;
233
+ let versions;
234
+ if (feature === 'ui') {
235
+ if (ctx.pm !== 'pnpm') {
236
+ throw new AddRefusedError(`The web/ frontend is a pnpm workspace member (pnpm-workspace.yaml), and this project uses ${ctx.pm}. Switch the project to pnpm first (or pass --pm=pnpm if it already uses it).`);
237
+ }
238
+ const files = uiFiles(next);
239
+ if (options.resolveLatest !== false)
240
+ versions = await resolveFileVersions(files, options.registry);
241
+ for (const [path, content] of Object.entries(files))
242
+ await plannedFile(b, path, content);
243
+ const yaml = await readOptional(ctx.dir, 'pnpm-workspace.yaml');
244
+ if (yaml === undefined) {
245
+ plannedEdit(b, 'pnpm-workspace.yaml', undefined, pnpmWorkspaceYaml(next), 'web/ as a workspace member');
246
+ }
247
+ else {
248
+ const patched = addWorkspaceMember(yaml, 'web');
249
+ if (patched === undefined)
250
+ b.manual.push('pnpm-workspace.yaml: add `web` to `packages:` (it is written in a style this command does not edit).');
251
+ else
252
+ plannedEdit(b, 'pnpm-workspace.yaml', yaml, patched, 'web/ as a workspace member');
253
+ }
254
+ await plannedPackageJson(b, { scripts: { 'dev:web': webDevScript(next) } });
255
+ const ignore = await readOptional(ctx.dir, '.gitignore');
256
+ plannedEdit(b, '.gitignore', ignore, ignore === undefined ? gitignore(next) : ensureGitignore(ignore, ['node_modules/', 'dist/']), 'ignore web/ build output');
257
+ await plannedReadme(b, regenOptions, '## Web UI', readmeUiSection(next));
258
+ b.notes.push('No CORS change needed: the Vite dev server proxies /api to the API on :3000.');
259
+ }
260
+ if (feature === 'cli') {
261
+ const resolved = await rootRanges({
262
+ '@basaltkit/cli': versionOf('@basaltkit/cli'),
263
+ '@basaltkit/prisma': versionOf('@basaltkit/prisma'),
264
+ '@basaltkit/generator': versionOf('@basaltkit/generator'),
265
+ 'create-basalt': versionOf('create-basalt'),
266
+ }, options);
267
+ versions = resolved.versions;
268
+ await plannedFile(b, 'bin/basalt.ts', basaltBin());
269
+ await plannedPackageJson(b, {
270
+ dependencies: {
271
+ '@basaltkit/cli': resolved.ranges['@basaltkit/cli'],
272
+ '@basaltkit/prisma': resolved.ranges['@basaltkit/prisma'],
273
+ },
274
+ devDependencies: {
275
+ '@basaltkit/generator': resolved.ranges['@basaltkit/generator'],
276
+ 'create-basalt': resolved.ranges['create-basalt'],
277
+ },
278
+ replaceScripts: { basalt: { from: BASALT_PROJECT_SCRIPT, to: 'tsx bin/basalt.ts' } },
279
+ });
280
+ await plannedSource(b, 'src/app.ts', regenOptions ? () => appTs(regenOptions) : undefined, patchAppForCli, [
281
+ 'wire the CLI commands into buildApp by hand:',
282
+ " 1. import { commandsPlugin, type CommandDefinition } from '@basaltkit/cli'",
283
+ ' 2. add `commands?: CommandDefinition[]` to the options buildApp(options) accepts',
284
+ ' 3. add `...(options.commands && options.commands.length > 0 ? [commandsPlugin(options.commands)] : [])` to its plugins, before the HTTP adapter plugin',
285
+ 'bin/basalt.ts calls buildApp({ logLevel, commands }).',
286
+ ].join('\n'));
287
+ await plannedReadme(b, regenOptions, '## The `basalt` CLI', readmeCliSection(next));
288
+ }
289
+ if (feature === 'mcp') {
290
+ const resolved = await rootRanges({ '@basaltkit/mcp': versionOf('@basaltkit/mcp'), '@basaltkit/ai-mcp': versionOf('@basaltkit/ai-mcp') }, options);
291
+ versions = resolved.versions;
292
+ await plannedPackageJson(b, {
293
+ dependencies: { '@basaltkit/mcp': resolved.ranges['@basaltkit/mcp'] },
294
+ // Dev-only AI bridge — never a runtime dependency.
295
+ devDependencies: { '@basaltkit/ai-mcp': resolved.ranges['@basaltkit/ai-mcp'] },
296
+ });
297
+ await plannedFile(b, '.mcp.json', mcpJson(next));
298
+ await plannedSource(b, 'src/app.ts', regenOptions ? () => appTs(regenOptions) : undefined, (source) => patchAppForMcp(source, next.name), [
299
+ 'wire the MCP server by hand:',
300
+ " 1. import { mcpPlugin, mcpRoutes } from '@basaltkit/mcp'",
301
+ ` 2. add \`mcpPlugin({ routes: <your routes>, serverInfo: { name: '${next.name}', version: '0.1.0' } })\` to the plugins`,
302
+ ' 3. append `...mcpRoutes()` to the routes your HTTP adapter plugin serves (POST /mcp)',
303
+ ].join('\n'));
304
+ const routes = await readOptional(ctx.dir, 'src/routes.ts');
305
+ if (routes !== undefined && regenOptions && isPristine(ctx.manifest, 'src/routes.ts', routes)) {
306
+ plannedEdit(b, 'src/routes.ts', routes, routesTs(regenOptions), 'untouched template — overview/health exposed as MCP tools');
307
+ }
308
+ else {
309
+ b.notes.push("Expose routes as MCP tools by adding `meta: { mcp: { name: '…', description: '…' } }` to read-only routes (none are exposed until you do).");
310
+ }
311
+ await plannedReadme(b, regenOptions, '## MCP server', readmeMcpSection(next));
312
+ }
313
+ // The manifest records what create-basalt wrote (old projects get one now).
314
+ const manifest = ctx.manifest ? extendManifest(ctx.manifest, next, b.generated) : createManifest(next, b.generated);
315
+ const writes = b.changes.some((change) => change.action !== 'skip');
316
+ if (writes) {
317
+ const before = await readOptional(ctx.dir, MANIFEST_PATH);
318
+ plannedEdit(b, MANIFEST_PATH, before, serializeManifest(manifest), 'records the generated files');
319
+ delete b.generated[MANIFEST_PATH];
320
+ }
321
+ return { feature, options: next, changes: b.changes, manual: b.manual, notes: b.notes, ...(versions ? { versions } : {}) };
322
+ }