diff --git a/.bumpy/merge-migrate-into-init.md b/.bumpy/merge-migrate-into-init.md new file mode 100644 index 0000000..97a9679 --- /dev/null +++ b/.bumpy/merge-migrate-into-init.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': minor +--- + +Merge migrate command into init — `bumpy init` now auto-detects `.changeset/` and handles migration. Added 🐸 emoji to success messages across all commands. diff --git a/README.md b/README.md index d8bede5..a9c81e6 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ Bumpy is built as a successor to [@changesets/changesets](https://github.com/cha - **Custom publish commands** — changesets is hardcoded to `npm publish`. Bumpy supports per-package custom publish for VSCode extensions, Docker images, JSR, etc. - **Flexible package management** — changesets treats all private packages the same. Bumpy lets you include/exclude any package individually. - **CI without a separate action** — just `bunx @varlock/bumpy ci check` in any workflow, no bot or action to install. -- **`bumpy migrate`** — converts `.changeset/` config and pending changeset files to `.bumpy/`. +- **Automatic migration** — `bumpy init` detects `.changeset/`, renames it to `.bumpy/`, migrates config, keeps pending files, and offers to uninstall `@changesets/cli`. ## Development diff --git a/docs/cli.md b/docs/cli.md index 19a04df..2255199 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -4,12 +4,17 @@ All commands can be run via `bunx @varlock/bumpy ` or, (or just `bunx b ## `bumpy init` -Create the `.bumpy/` config directory with default settings. +Initialize the `.bumpy/` config directory. If `.changeset/` is detected, it automatically migrates — renaming the directory to `.bumpy/`, converting config, keeping pending bump files, and offering to uninstall `@changesets/cli`. Also ensures `@varlock/bumpy` is installed as a dev dependency and warns about changeset references in GitHub workflows. ```bash bumpy init +bumpy init --force # skip interactive prompts ``` +| Flag | Description | +| --------- | ------------------------ | +| `--force` | Skip interactive prompts | + ## `bumpy add` Create a bump file interactively or non-interactively. @@ -172,19 +177,6 @@ Interactive guide to set up `BUMPY_GH_TOKEN` for CI. Walks through creating a fi bumpy ci setup ``` -## `bumpy migrate` - -Convert from changesets (`.changeset/`) to bumpy (`.bumpy/`). Migrates config and pending changeset files. - -```bash -bumpy migrate -bumpy migrate --force -``` - -| Flag | Description | -| --------- | ------------------------- | -| `--force` | Skip cleanup confirmation | - ## `bumpy ai setup` Install an AI skill for creating bump files in supported coding tools. diff --git a/docs/differences-from-changesets.md b/docs/differences-from-changesets.md index 2ff7e78..ab9589d 100644 --- a/docs/differences-from-changesets.md +++ b/docs/differences-from-changesets.md @@ -121,7 +121,7 @@ Bumpy includes the release date in every changelog heading by default. ### Migration tool -`bumpy migrate` converts `.changeset/` config and pending bump files to `.bumpy/`. +`bumpy init` detects `.changeset/` and automatically migrates — renaming the directory to `.bumpy/`, converting config, and keeping pending bump files. - (Previously listed under Planned) diff --git a/llms.md b/llms.md index 1124ce6..00b3d3b 100644 --- a/llms.md +++ b/llms.md @@ -254,7 +254,11 @@ The critical difference: changesets bumps dependents to **major** when a peer de ### `bumpy init` -Creates `.bumpy/` directory with default `_config.json` and a README. +Initialize `.bumpy/` directory. Automatically detects and migrates from `.changeset/` if present. Ensures `@varlock/bumpy` is installed as a dev dependency. + +| Flag | Description | +| --------- | ------------------------ | +| `--force` | Skip interactive prompts | ### `bumpy add` @@ -361,16 +365,6 @@ Default mode (`version-pr`): creates a branch, runs `bumpy version`, commits, an Auto-publish mode: runs `bumpy version`, commits, pushes, then `bumpy publish` in one step. -### `bumpy migrate` - -Migrate from `.changeset/` to `.bumpy/`. - -| Flag | Description | -| --------- | ---------------------------------------------------------- | -| `--force` | Skip interactive prompts (don't ask to delete .changeset/) | - -Migrates `.changeset/config.json` fields to `.bumpy/_config.json`, copies pending bump files, and prints key differences from changesets. - ## Changelog Customization The `changelog` config controls how CHANGELOG.md entries are formatted. @@ -663,14 +657,15 @@ Or with a custom title: ### Migrating from changesets ```bash -bumpy migrate +bumpy init ``` -This will: +If `.changeset/` is detected, `bumpy init` will automatically: -1. Create `.bumpy/` and migrate settings to `_config.json` -2. Copy pending bump `.md` files -3. Optionally remove `.changeset/` directory +1. Rename `.changeset/` to `.bumpy/` (keeping pending bump files) +2. Convert `config.json` to `_config.json` (migrating compatible fields) +3. Offer to uninstall `@changesets/cli` and install `@varlock/bumpy` +4. Warn about changeset references in GitHub workflows Key behavioral differences after migration: diff --git a/packages/bumpy/src/cli.ts b/packages/bumpy/src/cli.ts index c26a191..b439c66 100644 --- a/packages/bumpy/src/cli.ts +++ b/packages/bumpy/src/cli.ts @@ -32,7 +32,9 @@ async function main() { case 'init': { const rootDir = await findRoot(); const { initCommand } = await import('./commands/init.ts'); - await initCommand(rootDir); + await initCommand(rootDir, { + force: flags.force === true, + }); break; } @@ -81,15 +83,6 @@ async function main() { break; } - case 'migrate': { - const rootDir = await findRoot(); - const { migrateCommand } = await import('./commands/migrate.ts'); - await migrateCommand(rootDir, { - force: flags.force === true, - }); - break; - } - case 'check': { const rootDir = await findRoot(); const { checkCommand } = await import('./commands/check.ts'); @@ -187,7 +180,7 @@ function printHelp() { Usage: bumpy [options] Commands: - init Initialize .bumpy/ directory + init [--force] Initialize .bumpy/ (migrates from .changeset/ if found) add Create a new bump file generate Generate bump file from branch commits status Show pending releases @@ -197,7 +190,6 @@ function printHelp() { ci check PR check — report pending releases, comment on PR ci release Release — create version PR or auto-publish ci setup Set up a token for triggering CI on version PRs - migrate Migrate from .changeset/ to .bumpy/ ai setup Install AI skill for creating bump files Add options: diff --git a/packages/bumpy/src/commands/add.ts b/packages/bumpy/src/commands/add.ts index 2654cef..0ce621a 100644 --- a/packages/bumpy/src/commands/add.ts +++ b/packages/bumpy/src/commands/add.ts @@ -38,7 +38,7 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise `${pc.cyan(r.name)} ${pc.dim('→')} ${pc.bold(r.type)}${formatCascade(r)}`).join('\n'), 'Bump file', ); - p.outro(pc.green(`Created .bumpy/${filename}.md`)); + p.outro(pc.green(`🐸 Created .bumpy/${filename}.md`)); } } diff --git a/packages/bumpy/src/commands/check.ts b/packages/bumpy/src/commands/check.ts index 2da3037..cb90dc4 100644 --- a/packages/bumpy/src/commands/check.ts +++ b/packages/bumpy/src/commands/check.ts @@ -55,7 +55,7 @@ export async function checkCommand(rootDir: string): Promise { const missing = changedPackages.filter((name) => !coveredPackages.has(name)); if (missing.length === 0) { - log.success(`All ${changedPackages.length} changed package(s) have bump files.`); + log.success(`🐸 All ${changedPackages.length} changed package(s) have bump files.`); return; } diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index 2fe06c9..60ca460 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -380,7 +380,7 @@ async function createVersionPr( input: prBody, }), ); - log.success(`Updated PR #${validPr}`); + log.success(`🐸 Updated PR #${validPr}`); } else { log.step('Creating version PR...'); const prTitle = config.versionPr.title; @@ -390,7 +390,7 @@ async function createVersionPr( { cwd: rootDir, input: prBody }, ), ); - log.success(`Created PR: ${result}`); + log.success(`🐸 Created PR: ${result}`); if (!patPr) { // Push again with the custom token now that the PR exists, so that a // `pull_request: synchronize` event is generated and CI workflows trigger. diff --git a/packages/bumpy/src/commands/generate.ts b/packages/bumpy/src/commands/generate.ts index 4cb6477..773619b 100644 --- a/packages/bumpy/src/commands/generate.ts +++ b/packages/bumpy/src/commands/generate.ts @@ -166,7 +166,7 @@ export async function generateCommand(rootDir: string, opts: GenerateOptions): P const summary = summaryLines.join('\n'); await writeBumpFile(rootDir, filename, releases, summary); - log.success(`Created bump file: .bumpy/${filename}.md`); + log.success(`🐸 Created bump file: .bumpy/${filename}.md`); for (const r of releases) { log.dim(` ${r.name}: ${r.type}`); } diff --git a/packages/bumpy/src/commands/init.ts b/packages/bumpy/src/commands/init.ts index 913fa11..80a066d 100644 --- a/packages/bumpy/src/commands/init.ts +++ b/packages/bumpy/src/commands/init.ts @@ -1,7 +1,11 @@ import { resolve } from 'node:path'; -import { ensureDir, writeJson, writeText, exists } from '../utils/fs.ts'; +import { rename, readdir, rm } from 'node:fs/promises'; +import pc from 'picocolors'; +import { ensureDir, writeJson, writeText, readJson, readText, exists, listFiles } from '../utils/fs.ts'; import { log } from '../utils/logger.ts'; import { detectPackageManager } from '../utils/package-manager.ts'; +import { p, unwrap } from '../utils/clack.ts'; +import { run } from '../utils/shell.ts'; import readmeTemplate from '../../../../.bumpy/README.md'; const PM_RUNNER: Record = { @@ -11,30 +15,256 @@ const PM_RUNNER: Record = { npm: 'npx bumpy', }; -export async function initCommand(rootDir: string): Promise { +const PM_ADD_DEV: Record = { + bun: 'bun add -d', + pnpm: 'pnpm add -Dw', + yarn: 'yarn add -D -W', + npm: 'npm install -D', +}; + +const PM_REMOVE: Record = { + bun: 'bun remove', + pnpm: 'pnpm remove -w', + yarn: 'yarn remove -W', + npm: 'npm uninstall', +}; + +interface InitOptions { + force?: boolean; +} + +export async function initCommand(rootDir: string, opts: InitOptions = {}): Promise { const bumpyDir = resolve(rootDir, '.bumpy'); + const changesetDir = resolve(rootDir, '.changeset'); + const hasChangeset = await exists(changesetDir); + const hasBumpy = await exists(resolve(bumpyDir, '_config.json')); - if (await exists(resolve(bumpyDir, '_config.json'))) { - log.warn('.bumpy/_config.json already exists'); + if (hasBumpy) { + log.info("🐸 Detected .bumpy/ directory - looks like we're ready to go!"); return; } - await ensureDir(bumpyDir); + const pm = await detectPackageManager(rootDir); + + if (!opts.force) { + p.intro(pc.bgCyan(pc.black(' bumpy init '))); + } + + // ── Migrate from changesets ────────────────────────────────────────── + if (hasChangeset) { + log.step('🦋 Detected .changeset/ directory — migrating to .bumpy/ 🐸'); + + // Rename .changeset → .bumpy + await rename(changesetDir, bumpyDir); + log.dim(' Renamed .changeset/ → .bumpy/'); + + // Migrate config.json → _config.json + const oldConfigPath = resolve(bumpyDir, 'config.json'); + if (await exists(oldConfigPath)) { + const csConfig = await readJson>(oldConfigPath); + const bumpyConfig = migrateChangesetConfig(csConfig); + await writeJson(resolve(bumpyDir, '_config.json'), bumpyConfig); + await rm(oldConfigPath); + log.dim(' Migrated config.json → _config.json'); + + const migratedFields = Object.keys(bumpyConfig).filter((k) => k !== '$schema'); + if (migratedFields.length > 0) { + log.dim(' Migrated fields: ' + migratedFields.join(', ')); + } + } else { + // No changeset config, write defaults + await writeJson(resolve(bumpyDir, '_config.json'), makeDefaultConfig()); + } + + // Replace changeset README with bumpy README + const readmeContent = readmeTemplate.replaceAll('bunx bumpy', PM_RUNNER[pm] || 'npx bumpy'); + await writeText(resolve(bumpyDir, 'README.md'), readmeContent); + log.dim(' Replaced README.md'); - // Write a minimal config (only non-default values would go here) - const config: Record = { + // Count pending changeset files (they're already compatible) + const files = await readdir(bumpyDir); + const pendingFiles = files.filter((f) => f.endsWith('.md') && f !== 'README.md'); + if (pendingFiles.length > 0) { + log.dim(` Kept ${pendingFiles.length} pending bump file(s)`); + } + + // Check for changesets/cli and offer to uninstall + const hasChangesetsCli = await isPackageInstalled(rootDir, '@changesets/cli'); + if (hasChangesetsCli) { + if (opts.force) { + await uninstallPackage(pm, '@changesets/cli', rootDir); + } else { + const shouldUninstall = unwrap( + await p.confirm({ + message: 'Uninstall @changesets/cli?', + initialValue: true, + }), + ); + if (shouldUninstall) { + await uninstallPackage(pm, '@changesets/cli', rootDir); + } + } + } + + // Scan GitHub workflows for changeset references + await warnChangesetWorkflows(rootDir, pm); + } else { + // ── Fresh init ─────────────────────────────────────────────────────── + await ensureDir(bumpyDir); + await writeJson(resolve(bumpyDir, '_config.json'), makeDefaultConfig()); + + const readmeContent = readmeTemplate.replaceAll('bunx bumpy', PM_RUNNER[pm] || 'npx bumpy'); + await writeText(resolve(bumpyDir, 'README.md'), readmeContent); + + log.success('Initialized .bumpy/ directory'); + log.dim(' Created .bumpy/_config.json'); + log.dim(' Created .bumpy/README.md'); + } + + // ── Ensure @varlock/bumpy is installed ───────────────────────────── + const hasBumpyPkg = await isPackageInstalled(rootDir, '@varlock/bumpy'); + if (!hasBumpyPkg) { + if (opts.force) { + await installPackage(pm, '@varlock/bumpy', rootDir); + } else { + const shouldInstall = unwrap( + await p.confirm({ + message: 'Install @varlock/bumpy as a dev dependency?', + initialValue: true, + }), + ); + if (shouldInstall) { + await installPackage(pm, '@varlock/bumpy', rootDir); + } + } + } + + if (!opts.force) { + p.outro(pc.green('bumpy is ready!')); + } else if (hasChangeset) { + log.success('Migration complete!'); + } +} + +// ── Helpers ──────────────────────────────────────────────────────────── + +function makeDefaultConfig(): Record { + return { $schema: '../node_modules/@varlock/bumpy/config-schema.json', baseBranch: 'main', changelog: 'default', }; - await writeJson(resolve(bumpyDir, '_config.json'), config); +} - // Write a README with commands tailored to the detected package manager - const pm = await detectPackageManager(rootDir); - const readmeContent = readmeTemplate.replaceAll('bunx bumpy', PM_RUNNER[pm] || 'npx bumpy'); - await writeText(resolve(bumpyDir, 'README.md'), readmeContent); +function migrateChangesetConfig(csConfig: Record): Record { + const bumpyConfig: Record = makeDefaultConfig(); + + // Fields that map directly from changesets → bumpy + const migrateableFields = [ + 'baseBranch', + 'access', + 'fixed', + 'linked', + 'ignore', + 'updateInternalDependencies', + 'privatePackages', + ] as const; + + for (const field of migrateableFields) { + if (csConfig[field] !== undefined) { + bumpyConfig[field] = csConfig[field]; + } + } + + // Fields intentionally NOT migrated (changesets-only): + // - commit, changelog, ___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH, etc. + + return bumpyConfig; +} + +async function isPackageInstalled(rootDir: string, pkgName: string): Promise { + try { + const pkg = await readJson>>(resolve(rootDir, 'package.json')); + return !!(pkg.devDependencies?.[pkgName] || pkg.dependencies?.[pkgName]); + } catch { + return false; + } +} + +async function installPackage(pm: string, pkgName: string, rootDir: string): Promise { + const cmd = `${PM_ADD_DEV[pm] || 'npm install -D'} ${pkgName}`; + log.step(`Installing ${pkgName}...`); + try { + run(cmd, { cwd: rootDir }); + log.dim(` ${cmd}`); + } catch (err) { + log.warn(`Failed to install ${pkgName}: ${err instanceof Error ? err.message : err}`); + log.dim(` Run manually: ${cmd}`); + } +} - log.success('Initialized .bumpy/ directory'); - log.dim(' Created .bumpy/_config.json'); - log.dim(' Created .bumpy/README.md'); +async function uninstallPackage(pm: string, pkgName: string, rootDir: string): Promise { + const cmd = `${PM_REMOVE[pm] || 'npm uninstall'} ${pkgName}`; + log.step(`Uninstalling ${pkgName}...`); + try { + run(cmd, { cwd: rootDir }); + log.dim(` ${cmd}`); + } catch (err) { + log.warn(`Failed to uninstall ${pkgName}: ${err instanceof Error ? err.message : err}`); + log.dim(` Run manually: ${cmd}`); + } +} + +/** Patterns to detect in workflow files, with suggested replacements */ +const CHANGESET_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [ + { pattern: /changesets\/action/, replacement: 'see https://bumpy.varlock.dev/ci for bumpy CI setup' }, + { pattern: /changeset publish/, replacement: 'bumpy publish' }, + { pattern: /changeset version/, replacement: 'bumpy version' }, + { pattern: /changeset status/, replacement: 'bumpy status' }, + { pattern: /@changesets\//, replacement: 'replace with @varlock/bumpy' }, +]; + +async function warnChangesetWorkflows(rootDir: string, pm: string): Promise { + const workflowDir = resolve(rootDir, '.github', 'workflows'); + if (!(await exists(workflowDir))) return; + + const files = await listFiles(workflowDir); + const yamlFiles = files.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')); + if (yamlFiles.length === 0) return; + + const runner = PM_RUNNER[pm] || 'npx bumpy'; + const hits: Array<{ file: string; matches: Array<{ line: number; found: string; suggestion: string }> }> = []; + + for (const file of yamlFiles) { + const content = await readText(resolve(workflowDir, file)); + const lines = content.split('\n'); + const fileMatches: Array<{ line: number; found: string; suggestion: string }> = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + for (const { pattern, replacement } of CHANGESET_PATTERNS) { + const match = line.match(pattern); + if (match) { + fileMatches.push({ line: i + 1, found: match[0], suggestion: replacement }); + } + } + } + + if (fileMatches.length > 0) { + hits.push({ file, matches: fileMatches }); + } + } + + if (hits.length === 0) return; + + console.log(); + log.warn('Found changeset references in GitHub workflows:'); + for (const { file, matches } of hits) { + log.dim(` .github/workflows/${file}`); + for (const { line, found, suggestion } of matches) { + log.dim(` L${line}: ${pc.red(found)} → ${pc.green(suggestion)}`); + } + } + console.log(); + log.dim(` Run ${pc.cyan(`${runner} ci setup`)} for help configuring CI workflows.`); } diff --git a/packages/bumpy/src/commands/migrate.ts b/packages/bumpy/src/commands/migrate.ts deleted file mode 100644 index ed865cb..0000000 --- a/packages/bumpy/src/commands/migrate.ts +++ /dev/null @@ -1,173 +0,0 @@ -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 { writeBumpFile } from '../core/bump-file.ts'; -import { p, unwrap } from '../utils/clack.ts'; -import { initCommand } from './init.ts'; -import type { BumpFileRelease, BumpTypeWithNone } from '../types.ts'; - -interface MigrateOptions { - force?: boolean; -} - -export async function migrateCommand(rootDir: string, opts: MigrateOptions): Promise { - const changesetDir = resolve(rootDir, '.changeset'); - - if (!(await exists(changesetDir))) { - log.error('No .changeset/ directory found. Nothing to migrate.'); - process.exit(1); - } - - const bumpyDir = getBumpyDir(rootDir); - const bumpyExists = await exists(resolve(bumpyDir, '_config.json')); - - // Step 1: Migrate config - if (!bumpyExists) { - log.step('Initializing .bumpy/ directory...'); - await initCommand(rootDir); - } - - const changesetConfigPath = resolve(changesetDir, 'config.json'); - if (await exists(changesetConfigPath)) { - log.step('Migrating config from .changeset/config.json...'); - await migrateConfig(changesetConfigPath, bumpyDir); - } - - // Step 2: Migrate pending changeset files to bump files - const files = await readdir(changesetDir); - const mdFiles = files.filter((f) => f.endsWith('.md') && f !== 'README.md'); - - if (mdFiles.length > 0) { - log.step(`Migrating ${mdFiles.length} pending changeset(s) to bump files...`); - let migrated = 0; - for (const file of mdFiles) { - const content = await readText(resolve(changesetDir, file)); - const result = parseChangesetFile(content); - if (!result) { - log.warn(` Skipped ${file} (could not parse)`); - continue; - } - - const name = file.replace(/\.md$/, ''); - const targetPath = resolve(bumpyDir, file); - if (await exists(targetPath)) { - log.dim(` Skipped ${file} (already exists in .bumpy/)`); - continue; - } - - // Write in bumpy format (which is the same, but let's go through our writer for consistency) - await writeBumpFile(rootDir, name, result.releases, result.summary); - migrated++; - log.dim(` Migrated ${file}`); - } - log.success(`Migrated ${migrated} bump file(s)`); - } else { - log.info('No pending changesets to migrate.'); - } - - // Step 3: Offer to clean up - if (!opts.force) { - 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 }); - spin.stop('Removed .changeset/ directory'); - } else { - p.log.info('Keeping .changeset/ — you can remove it manually when ready.'); - } - p.outro(pc.green('Cleanup complete')); - } - - console.log(); - log.success('Migration complete!'); - log.dim('Review .bumpy/_config.json and adjust settings as needed.'); - log.dim('Key differences from changesets:'); - log.dim(' - Out-of-range peer dep bumps match the triggering bump level (not always major)'); - log.dim(" - Use 'none' in a bump file to suppress a propagated bump"); - log.dim(' - Per-package config goes in package.json["bumpy"]'); -} - -async function migrateConfig(changesetConfigPath: string, bumpyDir: string): Promise { - const csConfig = await readJson>(changesetConfigPath); - const bumpyConfigPath = resolve(bumpyDir, '_config.json'); - let bumpyConfig: Record = {}; - - if (await exists(bumpyConfigPath)) { - bumpyConfig = await readJson>(bumpyConfigPath); - } - - // Map changesets config fields to bumpy equivalents (changesets values win over defaults) - const migrateableFields = [ - 'baseBranch', - 'access', - 'fixed', - 'linked', - 'ignore', - 'updateInternalDependencies', - 'privatePackages', - ] as const; - - for (const field of migrateableFields) { - if (csConfig[field] !== undefined) { - bumpyConfig[field] = csConfig[field]; - } - } - - // Note: changesets' commit, changelog, ___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH, etc. are not migrated - // The user should configure these manually - - const { writeJson } = await import('../utils/fs.ts'); - await writeJson(bumpyConfigPath, bumpyConfig); - log.dim( - ' Migrated config fields: ' + - Object.keys(bumpyConfig) - .filter((k) => k !== 'baseBranch' || bumpyConfig[k] !== 'main') - .join(', '), - ); -} - -/** Parse a changesets-format markdown file */ -function parseChangesetFile(content: string): { releases: BumpFileRelease[]; summary: string } | null { - const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); - if (!match) return null; - - const frontmatter = match[1]!.trim(); - const summary = match[2]!.trim(); - - if (!frontmatter) return null; - - const releases: BumpFileRelease[] = []; - for (const line of frontmatter.split('\n')) { - const trimmed = line.trim(); - if (!trimmed) continue; - - // Changesets format: "package-name": bump-type OR package-name: bump-type - // The quotes are optional in changesets - const lineMatch = trimmed.match(/^"?([^"]+)"?\s*:\s*(.+)$/); - if (!lineMatch) continue; - - const name = lineMatch[1]!.trim(); - const type = lineMatch[2]!.trim(); - - // Changesets supports "none" which we don't — skip those - if (type === 'none') continue; - - if (['major', 'minor', 'patch'].includes(type)) { - releases.push({ name, type: type as BumpTypeWithNone }); - } - } - - if (releases.length === 0 && !summary) return null; - return { releases, summary }; -} diff --git a/packages/bumpy/src/commands/publish.ts b/packages/bumpy/src/commands/publish.ts index d6ecf44..084a770 100644 --- a/packages/bumpy/src/commands/publish.ts +++ b/packages/bumpy/src/commands/publish.ts @@ -80,7 +80,7 @@ export async function publishCommand(rootDir: string, opts: PublishCommandOption // Summary if (result.published.length > 0) { - log.success(`Published ${result.published.length} package(s)`); + log.success(`🐸 Published ${result.published.length} package(s)`); } if (result.skipped.length > 0) { log.dim(`Skipped ${result.skipped.length}: ${result.skipped.map((s) => s.name).join(', ')}`); diff --git a/packages/bumpy/src/commands/version.ts b/packages/bumpy/src/commands/version.ts index ce9caa4..a52e42d 100644 --- a/packages/bumpy/src/commands/version.ts +++ b/packages/bumpy/src/commands/version.ts @@ -49,7 +49,7 @@ export async function versionCommand(rootDir: string, opts: VersionOptions = {}) // Apply the plan await applyReleasePlan(plan, packages, rootDir, config); - log.success(`Updated ${plan.releases.length} package(s)`); + log.success(`🐸 Updated ${plan.releases.length} package(s)`); log.dim(` Deleted ${bumpFiles.length} bump file(s)`); // Update lockfile so it stays in sync with bumped versions diff --git a/packages/bumpy/test/core/migrate.test.ts b/packages/bumpy/test/core/migrate.test.ts index d52a17a..7a7bd6a 100644 --- a/packages/bumpy/test/core/migrate.test.ts +++ b/packages/bumpy/test/core/migrate.test.ts @@ -3,12 +3,12 @@ import { resolve } from 'node:path'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { writeJson, readJson, writeText, readText, ensureDir, exists } from '../../src/utils/fs.ts'; -import { migrateCommand } from '../../src/commands/migrate.ts'; +import { initCommand } from '../../src/commands/init.ts'; -describe('migrate command', () => { +describe('init command with changeset migration', () => { let tmpDir: string; - test('migrates changeset files to .bumpy/', async () => { + test('migrates .changeset/ to .bumpy/ by renaming', async () => { tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-migrate-')); // Set up a fake monorepo with .changeset/ @@ -27,43 +27,53 @@ describe('migrate command', () => { fixed: [['@test/core', '@test/types']], }); - // Create a changeset file + // Create changeset files await writeText( resolve(tmpDir, '.changeset/cool-feature.md'), `---\n"@test/core": minor\n"@test/utils": patch\n---\n\nAdded cool feature\n`, ); - - // Create another changeset await writeText(resolve(tmpDir, '.changeset/fix-bug.md'), `---\n"@test/utils": patch\n---\n\nFixed a bug\n`); - // Run migration (force to skip interactive cleanup prompt) - await migrateCommand(tmpDir, { force: true }); + // Run init (force to skip interactive prompts) + await initCommand(tmpDir, { force: true }); + + // .changeset/ should be gone (renamed to .bumpy/) + expect(await exists(resolve(tmpDir, '.changeset'))).toBe(false); - // Check .bumpy/ was created + // .bumpy/ should exist with _config.json expect(await exists(resolve(tmpDir, '.bumpy/_config.json'))).toBe(true); - // Check config was migrated + // Old config.json should be removed + expect(await exists(resolve(tmpDir, '.bumpy/config.json'))).toBe(false); + + // Check config was migrated (compatible fields kept, changesets-only fields dropped) const config = await readJson>(resolve(tmpDir, '.bumpy/_config.json')); expect(config.baseBranch).toBe('develop'); expect(config.access).toBe('restricted'); expect(config.ignore).toEqual(['@test/internal']); expect(config.fixed).toEqual([['@test/core', '@test/types']]); + // 'commit' is changesets-only and should NOT be migrated + expect(config.commit).toBeUndefined(); - // Check changeset files were migrated + // Pending changeset files should be kept as-is expect(await exists(resolve(tmpDir, '.bumpy/cool-feature.md'))).toBe(true); expect(await exists(resolve(tmpDir, '.bumpy/fix-bug.md'))).toBe(true); - // Verify the content is parseable + // Verify content is preserved const content = await readText(resolve(tmpDir, '.bumpy/cool-feature.md')); expect(content).toContain('@test/core'); expect(content).toContain('minor'); expect(content).toContain('Added cool feature'); + // README should be bumpy's, not changesets' + const readme = await readText(resolve(tmpDir, '.bumpy/README.md')); + expect(readme).toContain('bumpy'); + await rm(tmpDir, { recursive: true }); }); - test("skips changesets with 'none' bump type", async () => { - tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-migrate-')); + test('fresh init when no .changeset/ exists', async () => { + tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-init-')); await writeJson(resolve(tmpDir, 'package.json'), { name: 'test-monorepo', @@ -71,18 +81,37 @@ describe('migrate command', () => { workspaces: ['packages/*'], }); - await ensureDir(resolve(tmpDir, '.changeset')); - await writeJson(resolve(tmpDir, '.changeset/config.json'), {}); + await initCommand(tmpDir, { force: true }); - // Changeset with "none" type (changesets supports this, we don't) - await writeText(resolve(tmpDir, '.changeset/no-bump.md'), `---\n"@test/docs": none\n---\n\nDocs only change\n`); + expect(await exists(resolve(tmpDir, '.bumpy/_config.json'))).toBe(true); + expect(await exists(resolve(tmpDir, '.bumpy/README.md'))).toBe(true); + + const config = await readJson>(resolve(tmpDir, '.bumpy/_config.json')); + expect(config.baseBranch).toBe('main'); + expect(config.changelog).toBe('default'); - await migrateCommand(tmpDir, { force: true }); + await rm(tmpDir, { recursive: true }); + }); - // The file should still be created but with no releases (or skipped) - // Our parser skips "none" entries, so if the only entry is none, it gets skipped - const bumpyDir = resolve(tmpDir, '.bumpy'); - expect(await exists(bumpyDir)).toBe(true); + test('skips if .bumpy/_config.json already exists', async () => { + tmpDir = await mkdtemp(resolve(tmpdir(), 'bumpy-init-')); + + await writeJson(resolve(tmpDir, 'package.json'), { + name: 'test-monorepo', + private: true, + workspaces: ['packages/*'], + }); + + await ensureDir(resolve(tmpDir, '.bumpy')); + await writeJson(resolve(tmpDir, '.bumpy/_config.json'), { baseBranch: 'main' }); + + // Should not throw, just warn and return + await initCommand(tmpDir, { force: true }); + + // Config should be unchanged + const config = await readJson>(resolve(tmpDir, '.bumpy/_config.json')); + expect(config.baseBranch).toBe('main'); + expect(config.changelog).toBeUndefined(); // wasn't added since we returned early await rm(tmpDir, { recursive: true }); });