diff --git a/.bumpy/clack-prompts.md b/.bumpy/clack-prompts.md new file mode 100644 index 0000000..264597b --- /dev/null +++ b/.bumpy/clack-prompts.md @@ -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 diff --git a/bun.lock b/bun.lock index 6a1da15..231ae5c 100644 --- a/bun.lock +++ b/bun.lock @@ -14,16 +14,17 @@ }, "packages/bumpy": { "name": "@varlock/bumpy", - "version": "0.0.0", + "version": "0.0.1", "bin": { "bumpy": "./dist/cli.mjs", }, "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:", }, @@ -48,6 +49,10 @@ "@babel/types": ["@babel/types@8.0.0-rc.3", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.3", "@babel/helper-validator-identifier": "^8.0.0-rc.3" } }, "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q=="], + "@clack/core": ["@clack/core@1.2.0", "", { "dependencies": { "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg=="], + + "@clack/prompts": ["@clack/prompts@1.2.0", "", { "dependencies": { "@clack/core": "1.2.0", "fast-string-width": "^1.1.0", "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w=="], + "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], @@ -214,6 +219,12 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "fast-string-truncated-width": ["fast-string-truncated-width@1.2.1", "", {}, "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow=="], + + "fast-string-width": ["fast-string-width@1.1.0", "", { "dependencies": { "fast-string-truncated-width": "^1.2.0" } }, "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ=="], + + "fast-wrap-ansi": ["fast-wrap-ansi@0.1.6", "", { "dependencies": { "fast-string-width": "^1.1.0" } }, "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], @@ -256,6 +267,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], @@ -268,6 +281,8 @@ "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], diff --git a/packages/bumpy/package.json b/packages/bumpy/package.json index d0e88ed..cac1714 100644 --- a/packages/bumpy/package.json +++ b/packages/bumpy/package.json @@ -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:" } diff --git a/packages/bumpy/src/commands/add.ts b/packages/bumpy/src/commands/add.ts index aa19da1..eaedf6c 100644 --- a/packages/bumpy/src/commands/add.ts +++ b/packages/bumpy/src/commands/add.ts @@ -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'; @@ -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 { @@ -33,7 +40,6 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise( - '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({ + 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(`Bump type for ${colorize(name, 'cyan')}:`, BUMP_CHOICES); + const bumpType = unwrap( + await p.select({ + message: `Bump type for ${pc.cyan(name)}`, + options: BUMP_CHOICES, + }), + ); const release: ChangesetRelease = { name, type: bumpType }; @@ -77,9 +98,11 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise 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) { @@ -93,17 +116,21 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise( - 'Which packages should cascade?', - [...allTargets].map((n) => ({ label: n, value: n })), + const cascadeSelected = unwrap( + await p.multiselect({ + message: 'Which packages should cascade?', + options: [...allTargets].map((n) => ({ label: n, value: n })), + required: false, + }), ); if (cascadeSelected.length > 0) { - const cascadeBump = await select('Cascade bump type:', [ - { label: 'patch', value: 'patch' }, - { label: 'minor', value: 'minor' }, - { label: 'major', value: 'major' }, - ]); + const cascadeBump = unwrap( + await p.select({ + message: 'Cascade bump type', + options: CASCADE_CHOICES, + }), + ); const cascade: Record = {}; for (const target of cascadeSelected) { cascade[target] = cascadeBump; @@ -117,20 +144,31 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise { + 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 @@ -139,20 +177,27 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise 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(':'); diff --git a/packages/bumpy/src/commands/migrate.ts b/packages/bumpy/src/commands/migrate.ts index 93305ac..69e1121 100644 --- a/packages/bumpy/src/commands/migrate.ts +++ b/packages/bumpy/src/commands/migrate.ts @@ -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'; @@ -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(); diff --git a/packages/bumpy/src/utils/clack.ts b/packages/bumpy/src/utils/clack.ts new file mode 100644 index 0000000..0b16a53 --- /dev/null +++ b/packages/bumpy/src/utils/clack.ts @@ -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(value: T | symbol): T { + if (p.isCancel(value)) { + p.cancel('Aborted'); + process.exit(0); + } + return value as T; +} diff --git a/packages/bumpy/src/utils/logger.ts b/packages/bumpy/src/utils/logger.ts index 147d5e7..7452dfd 100644 --- a/packages/bumpy/src/utils/logger.ts +++ b/packages/bumpy/src/utils/logger.ts @@ -1,26 +1,26 @@ -import { blue, green, yellow, red, cyan, dim, bold } from 'ansis'; +import pc from 'picocolors'; export const log = { info(msg: string) { - console.log(`${blue`info`} ${msg}`); + console.log(`${pc.blue('info')} ${msg}`); }, success(msg: string) { - console.log(`${green`done`} ${msg}`); + console.log(`${pc.green('done')} ${msg}`); }, warn(msg: string) { - console.log(`${yellow`warn`} ${msg}`); + console.log(`${pc.yellow('warn')} ${msg}`); }, error(msg: string) { - console.error(`${red`error`} ${msg}`); + console.error(`${pc.red('error')} ${msg}`); }, step(msg: string) { - console.log(`${cyan`=>`} ${msg}`); + console.log(`${pc.cyan('=>')} ${msg}`); }, dim(msg: string) { - console.log(dim`${msg}`); + console.log(pc.dim(msg)); }, bold(msg: string) { - console.log(bold`${msg}`); + console.log(pc.bold(msg)); }, table(rows: string[][]) { if (rows.length === 0) return; @@ -32,6 +32,5 @@ export const log = { }; export function colorize(text: string, color: 'red' | 'green' | 'yellow' | 'blue' | 'cyan' | 'dim' | 'bold'): string { - const colors = { red, green, yellow, blue, cyan, dim, bold }; - return colors[color](text); + return pc[color](text); } diff --git a/packages/bumpy/src/utils/prompt.ts b/packages/bumpy/src/utils/prompt.ts deleted file mode 100644 index f36b10b..0000000 --- a/packages/bumpy/src/utils/prompt.ts +++ /dev/null @@ -1,57 +0,0 @@ -import * as readline from 'node:readline'; - -function createInterface(): readline.Interface { - return readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); -} - -export async function ask(question: string, defaultValue?: string): Promise { - const rl = createInterface(); - const suffix = defaultValue ? ` (${defaultValue})` : ''; - return new Promise((resolve) => { - rl.question(`${question}${suffix}: `, (answer) => { - rl.close(); - resolve(answer.trim() || defaultValue || ''); - }); - }); -} - -export async function confirm(question: string, defaultYes = true): Promise { - const hint = defaultYes ? 'Y/n' : 'y/N'; - const answer = await ask(`${question} [${hint}]`); - if (!answer) return defaultYes; - return answer.toLowerCase().startsWith('y'); -} - -export async function select(question: string, options: { label: string; value: T }[]): Promise { - console.log(question); - for (let i = 0; i < options.length; i++) { - console.log(` ${i + 1}) ${options[i]!.label}`); - } - const answer = await ask('Choose'); - const idx = parseInt(answer, 10) - 1; - if (idx >= 0 && idx < options.length) return options[idx]!.value; - // Try matching by value - const match = options.find((o) => o.value === answer || o.label.toLowerCase() === answer.toLowerCase()); - if (match) return match.value; - // Default to first - return options[0]!.value; -} - -export async function multiSelect( - question: string, - options: { label: string; value: T; checked?: boolean }[], -): Promise { - console.log(question); - for (let i = 0; i < options.length; i++) { - const mark = options[i]!.checked ? '[x]' : '[ ]'; - console.log(` ${i + 1}) ${mark} ${options[i]!.label}`); - } - const answer = await ask("Select (comma-separated numbers, or 'all')"); - if (answer.toLowerCase() === 'all') return options.map((o) => o.value); - if (!answer) return options.filter((o) => o.checked).map((o) => o.value); - const indices = answer.split(',').map((s) => parseInt(s.trim(), 10) - 1); - return indices.filter((i) => i >= 0 && i < options.length).map((i) => options[i]!.value); -}