Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .bumpy/clack-prompts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@varlock/bumpy': patch
---

Revamp interactive prompts using `@clack/prompts` for a much nicer CLI UX.

- `bumpy add` now uses arrow-key navigation, validation, grouped intro/outro framing, and a summary note
- `bumpy migrate` cleanup prompt uses a spinner and intro/outro
- Clean Ctrl-C / Esc cancellation on every prompt (no more stack traces)
- Swapped `ansis` → `picocolors` to avoid bundling two color libraries
19 changes: 17 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/bumpy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@
"test": "bun test"
},
"devDependencies": {
"@clack/prompts": "^1.2.0",
"@types/bun": "latest",
"@types/js-yaml": "^4.0.9",
"@types/semver": "^7.7.0",
"ansis": "^4.2.0",
"js-yaml": "^4.1.0",
"picocolors": "^1.1.1",
"semver": "^7.7.2",
"tsdown": "catalog:"
}
Expand Down
147 changes: 96 additions & 51 deletions packages/bumpy/src/commands/add.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { resolve } from 'node:path';
import { log, colorize } from '../utils/logger.ts';
import { ask, select, multiSelect, confirm } from '../utils/prompt.ts';
import pc from 'picocolors';
import { log } from '../utils/logger.ts';
import { p, unwrap } from '../utils/clack.ts';
import { ensureDir, exists } from '../utils/fs.ts';
import { randomName, slugify } from '../utils/names.ts';
import { writeChangeset } from '../core/changeset.ts';
Expand All @@ -17,12 +18,18 @@ interface AddOptions {
empty?: boolean;
}

const BUMP_CHOICES: { label: string; value: BumpTypeWithIsolated }[] = [
const BUMP_CHOICES: { label: string; value: BumpTypeWithIsolated; hint?: string }[] = [
{ label: 'patch', value: 'patch' },
{ label: 'minor', value: 'minor' },
{ label: 'major', value: 'major' },
{ label: 'patch (isolated)', value: 'patch-isolated', hint: 'no cascade' },
{ label: 'minor (isolated)', value: 'minor-isolated', hint: 'no cascade' },
];

const CASCADE_CHOICES: { label: string; value: BumpType }[] = [
{ label: 'patch', value: 'patch' },
{ label: 'minor', value: 'minor' },
{ label: 'major', value: 'major' },
{ label: 'patch (isolated - no cascade)', value: 'patch-isolated' },
{ label: 'minor (isolated - no cascade)', value: 'minor-isolated' },
];

export async function addCommand(rootDir: string, opts: AddOptions): Promise<void> {
Expand All @@ -33,7 +40,6 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise<voi
// Handle --empty flag
if (opts.empty) {
const filename = opts.name ? slugify(opts.name) : randomName();
// Empty changeset - just a placeholder
const filePath = resolve(bumpyDir, `${filename}.md`);
const { writeText } = await import('../utils/fs.ts');
await writeText(filePath, '---\n---\n');
Expand All @@ -43,30 +49,45 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise<voi

let releases: ChangesetRelease[];
let summary: string;
let filename: string;

if (opts.packages) {
// Non-interactive mode
releases = parsePackagesFlag(opts.packages);
summary = opts.message || '';
filename = opts.name ? slugify(opts.name) : randomName();
} else {
// Interactive mode
p.intro(pc.bgCyan(pc.black(' bumpy add ')));

const pkgs = await discoverPackages(rootDir, config);
const depGraph = new DependencyGraph(pkgs);

// Select packages (discoverPackages already filters to managed packages only)
const selected = await multiSelect<string>(
'Which packages should be included in this changeset?',
[...pkgs.values()].map((p) => ({ label: `${p.name} (${p.version})`, value: p.name })),
);

if (selected.length === 0) {
log.warn('No packages selected. Aborting.');
return;
if (pkgs.size === 0) {
p.cancel('No managed packages found in this workspace.');
process.exit(1);
}

const selected = unwrap(
await p.multiselect<string>({
message: 'Which packages should be included in this changeset?',
options: [...pkgs.values()].map((pkg) => ({
label: pkg.name,
value: pkg.name,
hint: pkg.version,
})),
required: true,
}),
);

releases = [];
for (const name of selected) {
const bumpType = await select<BumpTypeWithIsolated>(`Bump type for ${colorize(name, 'cyan')}:`, BUMP_CHOICES);
const bumpType = unwrap(
await p.select<BumpTypeWithIsolated>({
message: `Bump type for ${pc.cyan(name)}`,
options: BUMP_CHOICES,
}),
);

const release: ChangesetRelease = { name, type: bumpType };

Expand All @@ -77,9 +98,11 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise<voi
const cascadeTargets = pkg.bumpy?.cascadeTo;

if (dependents.length > 0 || cascadeTargets) {
const wantCascade = await confirm(
`${name} has ${dependents.length} dependents. Specify explicit cascades?`,
false,
const wantCascade = unwrap(
await p.confirm({
message: `${pc.cyan(name)} has ${pc.bold(String(dependents.length))} dependents. Specify explicit cascades?`,
initialValue: false,
}),
);

if (wantCascade) {
Expand All @@ -93,17 +116,21 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise<voi
}
}

const cascadeSelected = await multiSelect<string>(
'Which packages should cascade?',
[...allTargets].map((n) => ({ label: n, value: n })),
const cascadeSelected = unwrap(
await p.multiselect<string>({
message: 'Which packages should cascade?',
options: [...allTargets].map((n) => ({ label: n, value: n })),
required: false,
}),
);

if (cascadeSelected.length > 0) {
const cascadeBump = await select<BumpType>('Cascade bump type:', [
{ label: 'patch', value: 'patch' },
{ label: 'minor', value: 'minor' },
{ label: 'major', value: 'major' },
]);
const cascadeBump = unwrap(
await p.select<BumpType>({
message: 'Cascade bump type',
options: CASCADE_CHOICES,
}),
);
const cascade: Record<string, BumpType> = {};
for (const target of cascadeSelected) {
cascade[target] = cascadeBump;
Expand All @@ -117,20 +144,31 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise<voi
releases.push(release);
}

// Get summary
summary = await ask('Summary (what changed and why)');
}
summary = unwrap(
await p.text({
message: 'Summary (what changed and why)',
placeholder: 'A short description of the change',
validate: (value) => {
if (!value || !value.trim()) return 'Summary is required';
return undefined;
},
}),
);

// Get filename
let filename: string;
if (opts.name) {
filename = slugify(opts.name);
} else if (opts.packages) {
// Non-interactive, no name specified
filename = randomName();
} else {
const nameInput = await ask('Changeset name', randomName());
filename = slugify(nameInput) || randomName();
const defaultName = randomName();
const nameInput = unwrap(
await p.text({
message: 'Changeset name',
placeholder: defaultName,
defaultValue: defaultName,
validate: (value) => {
if (!value) return undefined; // will use default
if (!slugify(value)) return 'Name must contain at least one alphanumeric character';
return undefined;
},
}),
);
filename = slugify(nameInput) || defaultName;
}

// Check for existing file
Expand All @@ -139,20 +177,27 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise<voi
}

await writeChangeset(rootDir, filename, releases, summary);
log.success(`Created changeset: .bumpy/${filename}.md`);

// Preview
for (const r of releases) {
const cascade =
'cascade' in r && Object.keys(r.cascade).length > 0
? ` (cascade: ${Object.entries(r.cascade)
.map(([k, v]) => `${k}:${v}`)
.join(', ')})`
: '';
log.dim(` ${r.name}: ${r.type}${cascade}`);

if (opts.packages) {
log.success(`Created changeset: .bumpy/${filename}.md`);
for (const r of releases) {
log.dim(` ${r.name}: ${r.type}${formatCascade(r)}`);
}
} else {
p.note(
releases.map((r) => `${pc.cyan(r.name)} ${pc.dim('→')} ${pc.bold(r.type)}${formatCascade(r)}`).join('\n'),
'Changeset',
);
p.outro(pc.green(`Created .bumpy/${filename}.md`));
}
}

function formatCascade(r: ChangesetRelease): string {
if (!('cascade' in r) || Object.keys(r.cascade).length === 0) return '';
const parts = Object.entries(r.cascade).map(([k, v]) => `${k}:${v}`);
return pc.dim(` (cascade: ${parts.join(', ')})`);
}

function parsePackagesFlag(input: string): ChangesetRelease[] {
return input.split(',').map((entry) => {
const [name, type] = entry.trim().split(':');
Expand Down
19 changes: 14 additions & 5 deletions packages/bumpy/src/commands/migrate.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { resolve } from 'node:path';
import { readdir } from 'node:fs/promises';
import pc from 'picocolors';
import { log } from '../utils/logger.ts';
import { readJson, readText, exists } from '../utils/fs.ts';
import { getBumpyDir } from '../core/config.ts';
import { writeChangeset } from '../core/changeset.ts';
import { confirm } from '../utils/prompt.ts';
import { p, unwrap } from '../utils/clack.ts';
import { initCommand } from './init.ts';
import type { ChangesetRelease, BumpTypeWithIsolated } from '../types.ts';

Expand Down Expand Up @@ -69,15 +70,23 @@ export async function migrateCommand(rootDir: string, opts: MigrateOptions): Pro

// Step 3: Offer to clean up
if (!opts.force) {
console.log();
const shouldCleanup = await confirm('Remove .changeset/ directory?', false);
p.intro(pc.bgCyan(pc.black(' bumpy migrate ')));
const shouldCleanup = unwrap(
await p.confirm({
message: 'Remove .changeset/ directory?',
initialValue: false,
}),
);
if (shouldCleanup) {
const spin = p.spinner();
spin.start('Removing .changeset/');
const { rm } = await import('node:fs/promises');
await rm(changesetDir, { recursive: true });
log.success('Removed .changeset/ directory');
spin.stop('Removed .changeset/ directory');
} else {
log.dim('Keeping .changeset/ — you can remove it manually when ready.');
p.log.info('Keeping .changeset/ — you can remove it manually when ready.');
}
p.outro(pc.green('Cleanup complete'));
}

console.log();
Expand Down
16 changes: 16 additions & 0 deletions packages/bumpy/src/utils/clack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as p from '@clack/prompts';

export * from '@clack/prompts';
export { p };

/**
* Unwrap a clack prompt result, exiting cleanly if the user cancelled (Ctrl-C / Esc).
* Every interactive prompt result must flow through this.
*/
export function unwrap<T>(value: T | symbol): T {
if (p.isCancel(value)) {
p.cancel('Aborted');
process.exit(0);
}
return value as T;
}
Loading