create-basalt 1.8.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.
- package/README.md +161 -9
- package/dist/args.d.ts +1 -1
- package/dist/args.js +11 -0
- package/dist/bin-template.d.ts +23 -0
- package/dist/bin-template.js +140 -0
- package/dist/cli.js +44 -5
- package/dist/index.d.ts +9 -1
- package/dist/index.js +32 -26
- package/dist/latest-versions.d.ts +31 -2
- package/dist/latest-versions.js +91 -51
- package/dist/project/add.d.ts +66 -0
- package/dist/project/add.js +322 -0
- package/dist/project/context.d.ts +53 -0
- package/dist/project/context.js +112 -0
- package/dist/project/doctor.d.ts +40 -0
- package/dist/project/doctor.js +350 -0
- package/dist/project/manifest.d.ts +36 -0
- package/dist/project/manifest.js +81 -0
- package/dist/project/package-json.d.ts +57 -0
- package/dist/project/package-json.js +161 -0
- package/dist/project/run.d.ts +55 -0
- package/dist/project/run.js +473 -0
- package/dist/project/semver.d.ts +29 -0
- package/dist/project/semver.js +132 -0
- package/dist/project/term.d.ts +24 -0
- package/dist/project/term.js +39 -0
- package/dist/project/update.d.ts +101 -0
- package/dist/project/update.js +302 -0
- package/dist/templates.d.ts +11 -1
- package/dist/templates.js +78 -42
- package/dist/versions.js +15 -14
- package/package.json +20 -20
|
@@ -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)
|
|
114
|
-
*
|
|
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). */
|
package/dist/latest-versions.js
CHANGED
|
@@ -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
|
|
186
|
+
return result;
|
|
175
187
|
}
|
|
176
188
|
return undefined;
|
|
177
189
|
}
|
|
178
190
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
+
}
|