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,473 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { ADDABLE_FEATURES, AddRefusedError, planAdd } from './add.js';
6
+ import { loadProject, NotABasaltAppError } from './context.js';
7
+ import { projectInfo, renderFindings, runDoctor } from './doctor.js';
8
+ import { colorsEnabled, makeColors } from './term.js';
9
+ import { planUpdate, RegistryUnavailableError, renderUpdatePlan } from './update.js';
10
+ /**
11
+ * The project commands — `create-basalt update | add | doctor | info` — run
12
+ * inside an EXISTING app (any app, with or without `--cli`). In a scaffolded
13
+ * app they are also reachable as `pnpm basalt <command>`: bin/basalt.ts (with
14
+ * `--cli`) or the `basalt` script (`create-basalt --project`) hands them here.
15
+ *
16
+ * Every side effect goes through {@link ProjectCommandDeps}, so the commands
17
+ * are tested with temp dirs, a fake registry and a fake package manager.
18
+ */
19
+ export const PROJECT_COMMANDS = ['update', 'add', 'doctor', 'info'];
20
+ export const isProjectCommand = (token) => PROJECT_COMMANDS.includes(token ?? '');
21
+ const COMMON_FLAGS = `Common options:
22
+ --cwd=<dir> The app to operate on (default: the current directory)
23
+ --pm=<manager> Package manager: pnpm | npm | yarn | bun (default: packageManager
24
+ field, then the lockfile)
25
+ --no-color Plain output (also: NO_COLOR=1)
26
+ -h, --help Show this help`;
27
+ export const PROJECT_USAGE = `Project commands — run inside an existing Basalt app:
28
+
29
+ create-basalt update [options] Update dependencies to their latest versions
30
+ create-basalt add <feature> Add ui | cli | mcp to the app
31
+ create-basalt doctor Check the project's health (read-only)
32
+ create-basalt info Versions summary for bug reports
33
+
34
+ In a scaffolded app: pnpm basalt <command>. Each command has --help.
35
+ `;
36
+ export const COMMAND_USAGE = {
37
+ update: `Usage: create-basalt update [options] (in an app: pnpm basalt update)
38
+
39
+ Updates the app's dependencies (package.json, and web/package.json when
40
+ present) to their latest published versions, with the scaffold's policy:
41
+ - @basaltkit/* and create-basalt: always the latest, across majors (each
42
+ framework major prints a link to its changelog);
43
+ - third-party packages: the newest release on the app's CURRENT major (a new
44
+ major is reported; take it with --major);
45
+ - versions younger than the release-age window (pnpm minimumReleaseAge) are
46
+ left for later, so the install cannot refuse them.
47
+ Range style (^, ~, exact) and package.json formatting are preserved. Then it
48
+ installs, runs the @basaltkit/cli upgrade codemods, and suggests typecheck+test.
49
+ It also upgrades project tooling: an unmodified old bin/basalt.ts gains the
50
+ project commands; create-basalt becomes a devDependency (pnpm basalt update).
51
+
52
+ Options:
53
+ --dry Show the plan, write nothing
54
+ -y, --yes Apply without asking
55
+ --major Let third-party packages cross a major too
56
+ --only=@basaltkit
57
+ Only the framework packages (and create-basalt)
58
+ --no-install Write package.json, skip the install (and the codemods)
59
+ --no-tooling Leave bin/basalt.ts and the create-basalt devDependency alone
60
+ ${COMMON_FLAGS}
61
+ `,
62
+ add: `Usage: create-basalt add <feature> [options] (in an app: pnpm basalt add <feature>)
63
+
64
+ Adds what the matching scaffold flag generates to an existing app:
65
+ ui the web/ frontend (React + shadcn + SDK), as --ui scaffolds it; adapted
66
+ to the app (name, auth). pnpm projects only (web/ is a workspace member)
67
+ cli bin/basalt.ts (generators, prisma:sync, project commands) + its deps
68
+ mcp @basaltkit/mcp at POST /mcp, the dev-only ai-mcp bridge and .mcp.json
69
+
70
+ Existing files are never overwritten (skipped with a notice) unless --force;
71
+ package.json, pnpm-workspace.yaml, .gitignore and README are merged. Your code
72
+ (src/app.ts, src/routes.ts) is regenerated only when .basalt/project.json
73
+ proves it untouched, patched where the template's anchors are unambiguous, and
74
+ otherwise left alone with the exact manual steps printed.
75
+
76
+ Options:
77
+ --dry Show the plan, write nothing
78
+ -y, --yes Apply without asking
79
+ --force Overwrite existing generated files
80
+ --no-install Skip the dependency install
81
+ --offline Use the dependency ranges bundled with this create-basalt
82
+ ${COMMON_FLAGS}
83
+ `,
84
+ doctor: `Usage: create-basalt doctor [options] (in an app: pnpm basalt doctor)
85
+
86
+ Read-only health check: Node vs engines, package manager and lockfiles,
87
+ installed vs declared versions, duplicated @basaltkit versions and unmet peer
88
+ ranges, framework packages behind latest, the auth secret and DATABASE_URL
89
+ (from the environment or .env), Prisma client/migrations, .mcp.json.
90
+ Exits 1 when an error is found; warnings alone exit 0.
91
+
92
+ Options:
93
+ --offline Skip the registry ("behind latest") check
94
+ ${COMMON_FLAGS}
95
+ `,
96
+ info: `Usage: create-basalt info [options] (in an app: pnpm basalt info)
97
+
98
+ Prints create-basalt, Node, OS, package manager, the app's features and the
99
+ declared/installed versions of the framework and key tools — paste it into a
100
+ bug report.
101
+
102
+ ${COMMON_FLAGS}
103
+ `,
104
+ };
105
+ const KNOWN = {
106
+ update: { switches: ['dry', 'yes', 'major', 'install', 'tooling', 'offline'], values: ['only'] },
107
+ add: { switches: ['dry', 'yes', 'force', 'install', 'offline'], values: [] },
108
+ doctor: { switches: ['offline'], values: [] },
109
+ info: { switches: [], values: [] },
110
+ };
111
+ function parseFlags(argv) {
112
+ const parsed = { positionals: [], values: {}, switches: {} };
113
+ for (const token of argv) {
114
+ if (token === '-y')
115
+ parsed.switches['yes'] = true;
116
+ else if (token === '-h')
117
+ parsed.switches['help'] = true;
118
+ else if (token.startsWith('--')) {
119
+ const body = token.slice(2);
120
+ const eq = body.indexOf('=');
121
+ if (eq >= 0)
122
+ parsed.values[body.slice(0, eq)] = body.slice(eq + 1);
123
+ else if (body.startsWith('no-'))
124
+ parsed.switches[body.slice(3)] = false;
125
+ else
126
+ parsed.switches[body === 'dry-run' ? 'dry' : body] = true;
127
+ }
128
+ else if (token.startsWith('-'))
129
+ return `Unknown option ${token}.`;
130
+ else
131
+ parsed.positionals.push(token);
132
+ }
133
+ return parsed;
134
+ }
135
+ const pmRun = (pm, script) => pm === 'npm' ? `npm run ${script}` : pm === 'bun' ? `bun run ${script}` : `${pm} ${script}`;
136
+ /** `pnpm basalt <args>` in that manager's spelling (npm needs `--` before flags). */
137
+ export const basaltCommand = (pm, args) => pm === 'npm' ? `npm run basalt -- ${args}` : `${pmRun(pm, 'basalt')} ${args}`;
138
+ const pmTest = (pm) => (pm === 'bun' ? 'bun run test' : `${pm} test`);
139
+ async function writeAll(dir, files) {
140
+ for (const [path, content] of Object.entries(files)) {
141
+ const target = join(dir, path);
142
+ await mkdir(dirname(target), { recursive: true });
143
+ await writeFile(target, content);
144
+ }
145
+ }
146
+ /** Runs one project command; returns the process exit code. */
147
+ export async function runProjectCommand(argv, deps) {
148
+ const { out } = deps;
149
+ const parsed = parseFlags(argv);
150
+ const colorFlag = typeof parsed === 'string' ? undefined : parsed.switches['color'];
151
+ const colors = makeColors(colorFlag === false ? false : colorsEnabled(deps.env, deps.isTTY));
152
+ if (typeof parsed === 'string') {
153
+ out.error(colors.red(parsed));
154
+ return 1;
155
+ }
156
+ const [command, ...rest] = parsed.positionals;
157
+ if (!isProjectCommand(command)) {
158
+ out.log(PROJECT_USAGE);
159
+ return command === undefined ? 0 : 1;
160
+ }
161
+ if (parsed.switches['help']) {
162
+ out.log(COMMAND_USAGE[command]);
163
+ return 0;
164
+ }
165
+ const known = KNOWN[command];
166
+ const common = ['help', 'color'];
167
+ for (const name of Object.keys(parsed.switches)) {
168
+ if (!known.switches.includes(name) && !common.includes(name)) {
169
+ out.error(colors.red(`Unknown option --${name} for "${command}". See: create-basalt ${command} --help`));
170
+ return 1;
171
+ }
172
+ }
173
+ for (const name of Object.keys(parsed.values)) {
174
+ if (!known.values.includes(name) && name !== 'cwd' && name !== 'pm') {
175
+ out.error(colors.red(`Unknown option --${name}= for "${command}". See: create-basalt ${command} --help`));
176
+ return 1;
177
+ }
178
+ }
179
+ const pmFlag = parsed.values['pm'];
180
+ if (pmFlag !== undefined && !['pnpm', 'npm', 'yarn', 'bun'].includes(pmFlag)) {
181
+ out.error(colors.red(`--pm must be pnpm, npm, yarn or bun (got "${pmFlag}").`));
182
+ return 1;
183
+ }
184
+ const dir = resolve(deps.cwd, parsed.values['cwd'] ?? '.');
185
+ let ctx;
186
+ try {
187
+ ctx = await loadProject(dir, pmFlag, deps.env['npm_config_user_agent'] ?? '');
188
+ }
189
+ catch (error) {
190
+ if (error instanceof NotABasaltAppError) {
191
+ out.error(colors.red(error.message));
192
+ if (!existsSync(join(dir, 'package.json'))) {
193
+ out.error(`(To scaffold a new project literally named "${command}", use: npm create basalt -- --name=${command})`);
194
+ }
195
+ return 1;
196
+ }
197
+ throw error;
198
+ }
199
+ const s = parsed.switches;
200
+ switch (command) {
201
+ case 'update':
202
+ return update(ctx, deps, colors, {
203
+ dry: s['dry'] === true,
204
+ yes: s['yes'] === true,
205
+ major: s['major'] === true,
206
+ install: s['install'] !== false,
207
+ tooling: s['tooling'] !== false,
208
+ offline: s['offline'] === true,
209
+ only: parsed.values['only'],
210
+ });
211
+ case 'add':
212
+ return add(ctx, deps, colors, rest[0], {
213
+ dry: s['dry'] === true,
214
+ yes: s['yes'] === true,
215
+ force: s['force'] === true,
216
+ install: s['install'] !== false,
217
+ offline: s['offline'] === true,
218
+ });
219
+ case 'doctor': {
220
+ const pmVersion = await deps.capture(ctx.pm, ['--version'], ctx.dir);
221
+ const findings = await runDoctor(ctx, {
222
+ env: deps.env,
223
+ offline: s['offline'] === true,
224
+ ...(deps.registry ? { registry: deps.registry } : {}),
225
+ ...(deps.nodeVersion ? { nodeVersion: deps.nodeVersion } : {}),
226
+ ...(pmVersion ? { pmVersion } : {}),
227
+ });
228
+ out.log(colors.bold(`basalt doctor — ${ctx.options.name}`));
229
+ out.log('');
230
+ for (const line of renderFindings(findings, colors))
231
+ out.log(line);
232
+ return findings.some((finding) => finding.level === 'error') ? 1 : 0;
233
+ }
234
+ case 'info': {
235
+ const pmVersion = await deps.capture(ctx.pm, ['--version'], ctx.dir);
236
+ const lines = await projectInfo(ctx, {
237
+ ...(deps.nodeVersion ? { nodeVersion: deps.nodeVersion } : {}),
238
+ ...(pmVersion ? { pmVersion } : {}),
239
+ });
240
+ for (const line of lines)
241
+ out.log(line);
242
+ return 0;
243
+ }
244
+ }
245
+ }
246
+ /** Asks before writing; false = do not apply. Non-interactive without --yes refuses (prints how). */
247
+ async function approve(deps, colors, yes, again) {
248
+ if (yes)
249
+ return true;
250
+ if (!deps.interactive) {
251
+ deps.out.log('');
252
+ deps.out.log(colors.yellow(`Not a terminal — nothing written. Re-run with --yes to apply: ${again}`));
253
+ return false;
254
+ }
255
+ return deps.confirm('Apply these changes?');
256
+ }
257
+ async function installAndReport(ctx, deps, colors, pm, written) {
258
+ deps.out.log('');
259
+ deps.out.log(colors.bold(`Installing with ${pm}…`));
260
+ const ok = await deps.run(pm, ['install'], ctx.dir);
261
+ if (!ok) {
262
+ deps.out.error(colors.red(`${pm} install failed. The file changes are kept so you can fix the cause and re-run \`${pm} install\`.`));
263
+ deps.out.error(`To undo them instead: git checkout -- ${written.join(' ')} (or git restore …)`);
264
+ }
265
+ return ok;
266
+ }
267
+ async function update(ctx, deps, colors, flags) {
268
+ const { out } = deps;
269
+ if (flags.offline) {
270
+ out.error(colors.red('update resolves the latest versions from the npm registry, so it cannot run with --offline.'));
271
+ out.error('Drop --offline (a private registry is honored through npm_config_registry).');
272
+ return 1;
273
+ }
274
+ let only;
275
+ if (flags.only !== undefined) {
276
+ if (!['@basaltkit', 'basaltkit', '@basaltkit/*', 'framework'].includes(flags.only)) {
277
+ out.error(colors.red(`--only accepts @basaltkit (got "${flags.only}").`));
278
+ return 1;
279
+ }
280
+ only = 'basaltkit';
281
+ }
282
+ out.log(colors.dim(`Resolving the latest versions for ${ctx.options.name} (${ctx.pm})…`));
283
+ let plan;
284
+ try {
285
+ plan = await planUpdate(ctx, {
286
+ major: flags.major,
287
+ tooling: flags.tooling,
288
+ ...(only ? { only } : {}),
289
+ ...(deps.registry ? { registry: deps.registry } : {}),
290
+ });
291
+ }
292
+ catch (error) {
293
+ if (error instanceof RegistryUnavailableError) {
294
+ out.error(colors.red(error.message));
295
+ return 1;
296
+ }
297
+ throw error;
298
+ }
299
+ out.log('');
300
+ for (const line of renderUpdatePlan(plan, colors))
301
+ out.log(line);
302
+ const paths = Object.keys(plan.files);
303
+ if (paths.length === 0)
304
+ return 0;
305
+ out.log('');
306
+ out.log(`${flags.dry ? 'Would write' : 'Writes'}: ${paths.join(', ')}`);
307
+ if (flags.dry) {
308
+ out.log(colors.dim('Dry run — nothing written.'));
309
+ return 0;
310
+ }
311
+ if (!(await approve(deps, colors, flags.yes, basaltCommand(ctx.pm, 'update --yes'))))
312
+ return flags.yes || deps.interactive ? 0 : 1;
313
+ await writeAll(ctx.dir, Object.fromEntries(Object.entries(plan.files).map(([path, write]) => [path, write.after])));
314
+ out.log(colors.green(`✓ Updated ${paths.join(', ')}.`));
315
+ if (!flags.install) {
316
+ out.log('');
317
+ out.log(`Next: ${ctx.pm} install, then ${basaltCommand(ctx.pm, 'upgrade')} (codemods, with --cli) and ${pmRun(ctx.pm, 'typecheck')} && ${pmTest(ctx.pm)}`);
318
+ return 0;
319
+ }
320
+ if (!(await installAndReport(ctx, deps, colors, ctx.pm, paths)))
321
+ return 1;
322
+ if (plan.updates.some((entry) => entry.framework)) {
323
+ out.log('');
324
+ await (deps.codemods ?? runInstalledCodemods)(ctx.dir, out, colors);
325
+ }
326
+ out.log('');
327
+ out.log(colors.bold(`Next: ${pmRun(ctx.pm, 'typecheck')} && ${pmTest(ctx.pm)}`));
328
+ return 0;
329
+ }
330
+ export function renderAddPlan(plan, colors) {
331
+ const lines = [];
332
+ const mark = {
333
+ create: colors.green('+ create '),
334
+ overwrite: colors.red('! overwrite'),
335
+ update: colors.yellow('~ update '),
336
+ skip: colors.dim('- skip '),
337
+ };
338
+ for (const change of plan.changes) {
339
+ lines.push(` ${mark[change.action]} ${change.path}${change.note ? colors.dim(` (${change.note})`) : ''}`);
340
+ }
341
+ for (const note of plan.notes)
342
+ lines.push('', colors.dim(note));
343
+ for (const step of plan.manual)
344
+ lines.push('', colors.yellow('Manual step:'), step);
345
+ return lines;
346
+ }
347
+ async function add(ctx, deps, colors, feature, flags) {
348
+ const { out } = deps;
349
+ if (!ADDABLE_FEATURES.includes(feature ?? '')) {
350
+ out.error(colors.red(feature ? `Unknown feature "${feature}".` : 'Which feature?') + ` Available: ${ADDABLE_FEATURES.join(', ')}.`);
351
+ return 1;
352
+ }
353
+ let plan;
354
+ try {
355
+ plan = await planAdd(ctx, feature, {
356
+ force: flags.force,
357
+ resolveLatest: !flags.offline,
358
+ ...(deps.registry ? { registry: deps.registry } : {}),
359
+ });
360
+ }
361
+ catch (error) {
362
+ if (error instanceof AddRefusedError) {
363
+ out.error(colors.red(error.message));
364
+ return 1;
365
+ }
366
+ throw error;
367
+ }
368
+ if (plan.alreadyPresent) {
369
+ out.log(`${feature} is already part of ${ctx.options.name} (${plan.alreadyPresent}) — nothing to do. Use --force to regenerate its files.`);
370
+ return 0;
371
+ }
372
+ out.log(colors.bold(`add ${feature} → ${ctx.options.name}`));
373
+ out.log('');
374
+ for (const line of renderAddPlan(plan, colors))
375
+ out.log(line);
376
+ const writes = plan.changes.filter((change) => change.action !== 'skip' && change.content !== undefined);
377
+ if (writes.length === 0) {
378
+ out.log('');
379
+ out.log('Nothing to write.');
380
+ return 0;
381
+ }
382
+ if (flags.dry) {
383
+ out.log('');
384
+ out.log(colors.dim('Dry run — nothing written.'));
385
+ return 0;
386
+ }
387
+ out.log('');
388
+ if (!(await approve(deps, colors, flags.yes, basaltCommand(ctx.pm, `add ${feature} --yes`))))
389
+ return flags.yes || deps.interactive ? 0 : 1;
390
+ await writeAll(ctx.dir, Object.fromEntries(writes.map((change) => [change.path, change.content])));
391
+ out.log(colors.green(`✓ Wrote ${writes.length} file(s).`));
392
+ const needsInstall = writes.some((change) => change.path.endsWith('package.json'));
393
+ if (needsInstall && flags.install) {
394
+ if (!(await installAndReport(ctx, deps, colors, ctx.pm, writes.map((change) => change.path))))
395
+ return 1;
396
+ }
397
+ const steps = [];
398
+ if (needsInstall && !flags.install)
399
+ steps.push(`${ctx.pm} install`);
400
+ if (feature === 'ui')
401
+ steps.push(`${pmRun(ctx.pm, 'dev')} # API on :3000`, `${pmRun(ctx.pm, 'dev:web')} # UI on http://localhost:5180`);
402
+ if (feature === 'cli')
403
+ steps.push(basaltCommand(ctx.pm, 'list'));
404
+ if (feature === 'mcp') {
405
+ steps.push(`${pmRun(ctx.pm, 'dev')}`, `curl -s localhost:3000/mcp -H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'`);
406
+ }
407
+ steps.push(`${pmRun(ctx.pm, 'typecheck')} && ${pmTest(ctx.pm)}`);
408
+ out.log('');
409
+ out.log(colors.bold('Next:'));
410
+ for (const step of steps)
411
+ out.log(` ${step}`);
412
+ return 0;
413
+ }
414
+ /**
415
+ * Runs the upgrade codemods of the app's INSTALLED @basaltkit/cli (the version
416
+ * just installed knows the migrations for itself) without booting the app.
417
+ */
418
+ export async function runInstalledCodemods(dir, out, colors) {
419
+ const root = join(dir, 'node_modules', '@basaltkit', 'cli');
420
+ let entry;
421
+ try {
422
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
423
+ entry = pkg.exports?.['.']?.import ?? pkg.main ?? 'dist/index.js';
424
+ }
425
+ catch {
426
+ out.log(colors.dim('No @basaltkit/cli installed — no upgrade codemods to run.'));
427
+ return;
428
+ }
429
+ try {
430
+ const cli = (await import(pathToFileURL(join(root, entry)).href));
431
+ if (typeof cli.runUpgrade !== 'function' || !Array.isArray(cli.MIGRATIONS))
432
+ return;
433
+ const reports = await cli.runUpgrade(cli.MIGRATIONS, projectUpgradeFs(dir), { dir });
434
+ const changed = reports.filter((report) => report.changed.length > 0);
435
+ if (changed.length === 0) {
436
+ out.log(colors.dim('Upgrade codemods: nothing to change.'));
437
+ return;
438
+ }
439
+ for (const report of changed) {
440
+ out.log(`Codemod ${report.migration}: ${report.changed.length} file(s)`);
441
+ for (const path of report.changed)
442
+ out.log(` ${path}`);
443
+ }
444
+ }
445
+ catch (error) {
446
+ out.error(colors.yellow(`Upgrade codemods failed: ${error.message} — run \`basalt upgrade --dry\` to inspect.`));
447
+ }
448
+ }
449
+ /** An @basaltkit/cli UpgradeFs rooted at `dir` (relative paths resolve against it, not process.cwd()). */
450
+ export function projectUpgradeFs(dir) {
451
+ const SKIP = new Set(['node_modules', 'dist', '.git', '.next', 'build', 'coverage', 'generated']);
452
+ const at = (path) => (isAbsolute(path) ? path : join(dir, path));
453
+ return {
454
+ async list(root) {
455
+ const base = at(root);
456
+ const files = [];
457
+ const walk = async (current) => {
458
+ for (const entry of await readdir(current, { withFileTypes: true })) {
459
+ if (entry.isDirectory()) {
460
+ if (!SKIP.has(entry.name))
461
+ await walk(join(current, entry.name));
462
+ }
463
+ else
464
+ files.push(relative(base, join(current, entry.name)));
465
+ }
466
+ };
467
+ await walk(base);
468
+ return files;
469
+ },
470
+ read: (path) => readFile(at(path), 'utf8'),
471
+ write: (path, content) => writeFile(at(path), content, 'utf8'),
472
+ };
473
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The small slice of semver the project commands need — no dependency: parse a
3
+ * stable `x.y.z`, read a simple dependency range, classify an update, and test
4
+ * `satisfies` for the range grammar package.json files actually use (`^`, `~`,
5
+ * comparators, `x` wildcards, hyphen-less `||` unions).
6
+ */
7
+ export type Version = readonly [number, number, number];
8
+ /** Parses `1.2.3` (a leading `v`, pre-release and build metadata are tolerated and ignored). */
9
+ export declare function parseVersion(text: string): Version | undefined;
10
+ export declare function compareVersions(a: Version, b: Version): number;
11
+ /** A dependency range `update` knows how to rewrite: `^1.2.3`, `~1.2.3` or an exact `1.2.3`. */
12
+ export interface SimpleRange {
13
+ style: '^' | '~' | '';
14
+ version: string;
15
+ parsed: Version;
16
+ }
17
+ export declare function parseSimpleRange(range: string): SimpleRange | undefined;
18
+ export type UpdateKind = 'patch' | 'minor' | 'major';
19
+ /**
20
+ * How big a move from `from` to `to` is. Under caret semantics a `0.x` minor
21
+ * is breaking, so `0.2.0 → 0.3.0` counts as a major.
22
+ */
23
+ export declare function updateKind(from: Version, to: Version): UpdateKind;
24
+ /**
25
+ * Whether `version` satisfies `range`. Returns `undefined` for a range this
26
+ * subset cannot read (a `workspace:`/`npm:`/git/file spec, a hyphen range) —
27
+ * callers treat that as "cannot tell", never as a failure.
28
+ */
29
+ export declare function satisfies(version: string, range: string): boolean | undefined;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The small slice of semver the project commands need — no dependency: parse a
3
+ * stable `x.y.z`, read a simple dependency range, classify an update, and test
4
+ * `satisfies` for the range grammar package.json files actually use (`^`, `~`,
5
+ * comparators, `x` wildcards, hyphen-less `||` unions).
6
+ */
7
+ const STABLE = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
8
+ /** Parses `1.2.3` (a leading `v`, pre-release and build metadata are tolerated and ignored). */
9
+ export function parseVersion(text) {
10
+ const match = STABLE.exec(text.trim());
11
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
12
+ }
13
+ export function compareVersions(a, b) {
14
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
15
+ }
16
+ export function parseSimpleRange(range) {
17
+ const trimmed = range.trim();
18
+ const style = trimmed.startsWith('^') ? '^' : trimmed.startsWith('~') ? '~' : '';
19
+ const version = trimmed.slice(style.length);
20
+ if (!/^\d+\.\d+\.\d+$/.test(version))
21
+ return undefined;
22
+ const parsed = parseVersion(version);
23
+ return parsed ? { style, version, parsed } : undefined;
24
+ }
25
+ /**
26
+ * How big a move from `from` to `to` is. Under caret semantics a `0.x` minor
27
+ * is breaking, so `0.2.0 → 0.3.0` counts as a major.
28
+ */
29
+ export function updateKind(from, to) {
30
+ if (from[0] !== to[0])
31
+ return 'major';
32
+ if (from[0] === 0 && from[1] !== to[1])
33
+ return 'major';
34
+ if (from[1] !== to[1])
35
+ return 'minor';
36
+ return 'patch';
37
+ }
38
+ /** Expands one range token (`^1.2.3`, `~1.2`, `>=22.5.0`, `22.x`, `1.2.3`) into comparators. */
39
+ function comparators(token) {
40
+ if (token === '' || token === '*' || token === 'x' || token === 'latest')
41
+ return [];
42
+ const op = /^(>=|<=|>|<|=|\^|~)?v?(.*)$/.exec(token);
43
+ if (!op)
44
+ return undefined;
45
+ const operator = op[1] ?? '';
46
+ const parts = (op[2] ?? '').split('.');
47
+ if (parts.length > 3)
48
+ return undefined;
49
+ const wild = (part) => part === undefined || part === 'x' || part === 'X' || part === '*';
50
+ const nums = [];
51
+ for (const part of parts) {
52
+ if (wild(part))
53
+ break;
54
+ const core = part.split('-')[0] ?? '';
55
+ if (!/^\d+$/.test(core))
56
+ return undefined;
57
+ nums.push(Number(core));
58
+ }
59
+ const [major, minor, patch] = nums;
60
+ if (major === undefined)
61
+ return operator === '<' || operator === '>' ? undefined : [];
62
+ const floor = [major, minor ?? 0, patch ?? 0];
63
+ if (operator === '^') {
64
+ const ceil = major > 0 || minor === undefined
65
+ ? [major + 1, 0, 0]
66
+ : minor > 0 || patch === undefined
67
+ ? [0, minor + 1, 0]
68
+ : [0, 0, patch + 1];
69
+ return [
70
+ { op: '>=', version: floor },
71
+ { op: '<', version: ceil },
72
+ ];
73
+ }
74
+ if (operator === '~' || (operator === '' && patch === undefined)) {
75
+ const ceil = minor === undefined ? [major + 1, 0, 0] : [major, minor + 1, 0];
76
+ return [
77
+ { op: '>=', version: floor },
78
+ { op: '<', version: ceil },
79
+ ];
80
+ }
81
+ if (operator === '' || operator === '=')
82
+ return [{ op: '=', version: floor }];
83
+ return [{ op: operator, version: floor }];
84
+ }
85
+ const holds = (version, { op, version: bound }) => {
86
+ const order = compareVersions(version, bound);
87
+ switch (op) {
88
+ case '>=':
89
+ return order >= 0;
90
+ case '>':
91
+ return order > 0;
92
+ case '<=':
93
+ return order <= 0;
94
+ case '<':
95
+ return order < 0;
96
+ default:
97
+ return order === 0;
98
+ }
99
+ };
100
+ /**
101
+ * Whether `version` satisfies `range`. Returns `undefined` for a range this
102
+ * subset cannot read (a `workspace:`/`npm:`/git/file spec, a hyphen range) —
103
+ * callers treat that as "cannot tell", never as a failure.
104
+ */
105
+ export function satisfies(version, range) {
106
+ const parsed = parseVersion(version);
107
+ if (!parsed)
108
+ return undefined;
109
+ if (/^(workspace|npm|file|link|git|github|http|https):/.test(range) || / - /.test(range))
110
+ return undefined;
111
+ let readable = false;
112
+ for (const alternative of range.split('||')) {
113
+ // `>= 1.2.3` → `>=1.2.3`: glue an operator to its version before splitting.
114
+ const tokens = alternative.trim().replace(/(>=|<=|>|<|=|\^|~)\s+/g, '$1').split(/\s+/);
115
+ const all = [];
116
+ let ok = true;
117
+ for (const token of tokens) {
118
+ const expanded = comparators(token);
119
+ if (!expanded) {
120
+ ok = false;
121
+ break;
122
+ }
123
+ all.push(...expanded);
124
+ }
125
+ if (!ok)
126
+ continue;
127
+ readable = true;
128
+ if (all.every((comparator) => holds(parsed, comparator)))
129
+ return true;
130
+ }
131
+ return readable ? false : undefined;
132
+ }
@@ -0,0 +1,24 @@
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 interface Out {
7
+ /** One line to stdout. */
8
+ log(line?: string): void;
9
+ /** One line to stderr. */
10
+ error(line: string): void;
11
+ }
12
+ export interface Colors {
13
+ enabled: boolean;
14
+ bold(text: string): string;
15
+ dim(text: string): string;
16
+ red(text: string): string;
17
+ green(text: string): string;
18
+ yellow(text: string): string;
19
+ cyan(text: string): string;
20
+ }
21
+ export declare function colorsEnabled(env: NodeJS.ProcessEnv, isTTY: boolean): boolean;
22
+ export declare function makeColors(enabled: boolean): Colors;
23
+ /** A plain left-aligned table (header row + rows), two spaces between columns. */
24
+ export declare function table(header: readonly string[], rows: readonly (readonly string[])[], colors: Colors): string[];