diff --git a/.changeset/all-eels-lie.md b/.changeset/all-eels-lie.md new file mode 100644 index 0000000..fdcd1a2 --- /dev/null +++ b/.changeset/all-eels-lie.md @@ -0,0 +1,5 @@ +--- +"@cadamsdev/lazy-changesets": feat +--- + +Added publish command diff --git a/AGENTS.md b/AGENTS.md index e93f3e3..6b52e7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ - `bun test -t "test name"` - Run tests matching pattern ### Development -- `bun dev` - Run the CLI directly using tsx +- `bun changeset` - Run the CLI directly using tsx ## Code Style Guidelines diff --git a/PUBLISH_FEATURE.md b/PUBLISH_FEATURE.md new file mode 100644 index 0000000..fa5de7e --- /dev/null +++ b/PUBLISH_FEATURE.md @@ -0,0 +1,13 @@ +There should be a new command for "publish". This command should... +1. Go through all the package.json files. +2. Grab the version +3. Create and push git tags in the format package-name@version. Skip this step if the tag already exists on the remote +4. Publish the packages to npm in each package directory run npm install or pnpm install or bun install depending on what package manager the user is using. Skip publishing to npm if the package.json contains "private": true +5. Create GitHub Release - Check the last commit get all the changeset files that were deleted under .changesets/*.md. Create a GitHub release for each package using the tag that was created in step 3. The markdown in the GitHub release should be formatted like this + +# @scope/package-name + +## 0.1.0 + +### šŸš€ feat +- Description diff --git a/package.json b/package.json index 4ef1162..61d5c88 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "changeset": "./dist/changeset" }, "scripts": { - "dev": "bun src/index.ts", + "changeset": "bun src/index.ts", "build": "bun build --compile --outfile=dist/changeset src/index.ts", "test": "vitest" }, diff --git a/src/index.ts b/src/index.ts index f7c8229..9544581 100755 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { humanId } from 'human-id'; import pc from 'picocolors'; import { ChangesetConfig, readConfig } from './config.js'; import { version } from './version.js'; +import { publish } from './publish.js'; async function findPackages(config: ChangesetConfig): Promise> { const packageJsonPaths = globSync({ @@ -243,6 +244,24 @@ async function createChangeset(args: { empty?: boolean }) { process.exit(0); }, }, + publish: { + meta: { + name: 'publish', + description: 'Publish packages to npm and create GitHub releases', + }, + args: { + 'dry-run': { + type: 'boolean', + description: 'Show what would be published without actually publishing', + required: false, + default: false, + }, + }, + run: async ({ args }) => { + await publish({ dryRun: args['dry-run'] }); + process.exit(0); + }, + }, }, args: { empty: { diff --git a/src/publish.test.ts b/src/publish.test.ts new file mode 100644 index 0000000..fb809dd --- /dev/null +++ b/src/publish.test.ts @@ -0,0 +1,568 @@ +import { describe, test, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test'; + +mock.module('./config.js', () => ({ + readConfig: () => ({ + access: 'restricted', + baseBranch: 'main', + updateInternalDependencies: 'patch', + ignore: [], + lazyChangesets: { + types: { + feat: { + displayName: 'New Features', + emoji: 'šŸš€', + sort: 0, + releaseType: 'minor', + promptBreakingChange: true, + }, + }, + }, + }), +})); + +import * as fs from 'node:fs'; +import * as tinyglobby from 'tinyglobby'; +import * as childProcess from 'node:child_process'; +import * as packageManagerDetector from 'package-manager-detector'; +import { + publish, + type PackageInfo, + escapeShell, + generateReleaseNotes +} from './publish.js'; + +describe('escapeShell', () => { + test('should escape single quotes', () => { + const input = "test'string"; + const output = escapeShell(input); + expect(output).toBe("test'\\''string"); + }); + + test('should escape double quotes', () => { + const input = 'test"string'; + const output = escapeShell(input); + expect(output).toBe('test\\"string'); + }); + + test('should escape newlines', () => { + const input = 'test\nstring'; + const output = escapeShell(input); + expect(output).toBe('test\\nstring'); + }); + + test('should escape multiple characters', () => { + const input = "test'string\nwith\"quotes"; + const output = escapeShell(input); + expect(output).toBe("test'\\''string\\nwith\\\"quotes"); + }); +}); + +describe('generateReleaseNotes', () => { + let pkg: PackageInfo; + + beforeEach(() => { + pkg = { + name: '@test/package', + version: '1.0.0', + dir: './', + isPrivate: false, + }; + }); + + test('should generate release notes with feat changes', () => { + const changesetContent = [ + `--- +"@test/package": feat +--- + +Added new feature` + ]; + + const result = generateReleaseNotes(pkg, changesetContent); + + expect(result).toContain('# @test/package'); + expect(result).toContain('## 1.0.0'); + expect(result).toContain('### šŸš€ feat'); + expect(result).toContain('- Added new feature'); + }); + + test('should generate release notes with multiple types', () => { + const changesetContent = [ + `--- +"@test/package": feat +--- + +Added feature`, + `--- +"@test/package": fix +--- + +Fixed bug` + ]; + + const result = generateReleaseNotes(pkg, changesetContent); + + expect(result).toContain('### šŸš€ feat'); + expect(result).toContain('### šŸ› fix'); + expect(result).toContain('- Added feature'); + expect(result).toContain('- Fixed bug'); + }); + + test('should generate release notes with breaking changes', () => { + const changesetContent = [ + `--- +"@test/package": feat! +--- + +Breaking change` + ]; + + const result = generateReleaseNotes(pkg, changesetContent); + + expect(result).toContain('### šŸš€ feat'); + expect(result).toContain('- Breaking change'); + }); + + test('should handle empty changeset content', () => { + const changesetContent: string[] = []; + + const result = generateReleaseNotes(pkg, changesetContent); + + expect(result).toContain('# @test/package'); + expect(result).toContain('## 1.0.0'); + expect(result).toContain('No changes recorded.'); + }); + + test('should skip changesets for other packages', () => { + const changesetContent = [ + `--- +"@other/package": feat +--- + +Other package feature` + ]; + + const result = generateReleaseNotes(pkg, changesetContent); + + expect(result).toContain('No changes recorded.'); + }); + + test('should handle malformed changeset content', () => { + const changesetContent = ['No frontmatter here']; + + const result = generateReleaseNotes(pkg, changesetContent); + + expect(result).toContain('No changes recorded.'); + }); + + test('should handle changeset without message', () => { + const changesetContent = [ + `--- +"@test/package": feat +--- + +` + ]; + + const result = generateReleaseNotes(pkg, changesetContent); + + expect(result).toContain('### šŸš€ feat'); + expect(result).toContain('- '); + }); + + test('should order types correctly', () => { + const changesetContent = [ + `--- +"@test/package": fix +--- + +Fix`, + `--- +"@test/package": feat +--- + +Feature`, + `--- +"@test/package": docs +--- + +Docs` + ]; + + const result = generateReleaseNotes(pkg, changesetContent); + + const featIndex = result.indexOf('### šŸš€ feat'); + const fixIndex = result.indexOf('### šŸ› fix'); + const docsIndex = result.indexOf('### šŸ“š docs'); + + expect(featIndex).toBeLessThan(fixIndex); + expect(fixIndex).toBeLessThan(docsIndex); + }); +}); + +describe('publish command', () => { + let consoleLogSpy: any; + let consoleErrorSpy: any; + let consoleWarnSpy: any; + + beforeEach(() => { + consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + consoleWarnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + spyOn(fs, 'existsSync').mockImplementation((path: any) => { + const pathStr = typeof path === 'string' ? path : path.toString(); + return pathStr.includes('.changeset'); + }); + spyOn(fs, 'readFileSync').mockImplementation((path: any) => { + const pathStr = typeof path === 'string' ? path : path.toString(); + if (pathStr.includes('package.json')) { + return JSON.stringify({ + name: '@test/package', + version: '1.0.0', + }, null, 2); + } + if (pathStr.includes('CHANGELOG.md')) { + return `## 1.0.0\n\n### šŸš€ feat\n- Test changeset`; + } + return ''; + }); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + mock.clearAllMocks(); + }); + + test('should log message when no packages found', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue([]); + + await publish({ dryRun: false }); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('No packages found')); + }); + + test('should publish packages in dry run mode', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + + await publish({ dryRun: true }); + + const dryRunCalls = consoleLogSpy.mock.calls.filter((call: any) => + call.some((arg: any) => typeof arg === 'string' && arg.includes('Dry run')) + ); + expect(dryRunCalls.length).toBeGreaterThan(0); + + const calls = consoleLogSpy.mock.calls.flat(); + const hasDryRun = calls.some((arg: any) => typeof arg === 'string' && arg.includes('[DRY RUN]')); + expect(hasDryRun).toBe(true); + }); + + test('should skip packages if tag exists on remote', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + spyOn(childProcess, 'execSync').mockReturnValue(''); + + await publish({ dryRun: false }); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('already exists on remote')); + }); + + test('should create and push git tags', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + + await publish({ dryRun: false }); + + const calls = consoleLogSpy.mock.calls.flat(); + const hasCreatedTag = calls.some((arg: any) => typeof arg === 'string' && arg.includes('Created tag')); + const hasPushedTag = calls.some((arg: any) => typeof arg === 'string' && arg.includes('Pushed tag')); + expect(hasCreatedTag).toBe(true); + expect(hasPushedTag).toBe(true); + }); + + test('should handle private packages', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({ + name: '@test/package', + version: '1.0.0', + private: true, + }, null, 2)); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + + await publish({ dryRun: false }); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Package is private')); + }); + + test('should publish to npm for public packages', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + if (cmd.includes('publish')) { + return ''; + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'npm', agent: 'npm' }); + + await publish({ dryRun: false }); + + const calls = consoleLogSpy.mock.calls.flat(); + const hasPublished = calls.some((arg: any) => typeof arg === 'string' && arg.includes('Published to npm')); + expect(hasPublished).toBe(true); + }); + + test('should use npm for publishing', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'npm', agent: 'npm' }); + + await publish({ dryRun: false }); + + const calls = (childProcess.execSync as any).mock.calls; + const publishCall = calls.find((call: any) => call[0].includes('npm publish')); + expect(publishCall).toBeDefined(); + }); + + test('should use yarn for publishing', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'yarn', agent: 'yarn' }); + + await publish({ dryRun: false }); + + const calls = (childProcess.execSync as any).mock.calls; + const publishCall = calls.find((call: any) => call[0].includes('yarn publish')); + expect(publishCall).toBeDefined(); + }); + + test('should use pnpm for publishing', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'pnpm', agent: 'pnpm' }); + + await publish({ dryRun: false }); + + const calls = (childProcess.execSync as any).mock.calls; + const publishCall = calls.find((call: any) => call[0].includes('pnpm publish')); + expect(publishCall).toBeDefined(); + }); + + test('should use bun for publishing', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'bun', agent: 'bun' }); + + await publish({ dryRun: false }); + + const calls = (childProcess.execSync as any).mock.calls; + const publishCall = calls.find((call: any) => call[0].includes('bun publish')); + expect(publishCall).toBeDefined(); + }); + + test('should warn for unsupported package manager', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'deno', agent: 'deno' } as any); + + await publish({ dryRun: false }); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Unsupported package manager')); + }); + + test('should skip npm publish when package manager not detected', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue(null); + + await publish({ dryRun: false }); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Could not detect package manager')); + }); + + test('should create GitHub release', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(fs, 'readFileSync').mockImplementation((path: any) => { + const pathStr = typeof path === 'string' ? path : path.toString(); + if (pathStr.includes('package.json')) { + return JSON.stringify({ + name: '@test/package', + version: '1.0.0', + }, null, 2); + } + if (pathStr.includes('CHANGELOG.md')) { + return `## 1.0.0\n\n### šŸš€ feat\n- Test changeset`; + } + return ''; + }); + spyOn(fs, 'existsSync').mockImplementation((path: any) => { + const pathStr = typeof path === 'string' ? path : path.toString(); + return pathStr.includes('CHANGELOG.md'); + }); + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'npm', agent: 'npm' }); + + await publish({ dryRun: false }); + + const calls = consoleLogSpy.mock.calls.flat(); + const hasCreatedRelease = calls.some((arg: any) => typeof arg === 'string' && arg.includes('Created GitHub release')); + expect(hasCreatedRelease).toBe(true); + }); + + test('should skip GitHub release when no changesets found', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(fs, 'readFileSync').mockImplementation((path: any) => { + const pathStr = typeof path === 'string' ? path : path.toString(); + if (pathStr.includes('package.json')) { + return JSON.stringify({ + name: '@test/package', + version: '1.0.0', + }, null, 2); + } + return ''; + }); + spyOn(fs, 'existsSync').mockReturnValue(false); + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'npm', agent: 'npm' }); + + await publish({ dryRun: false }); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('No changelog found')); + }); + + test('should ignore packages in config ignore list', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + spyOn(fs, 'readFileSync').mockImplementation((path: any) => { + return JSON.stringify({ + name: '@ignored/package', + version: '1.0.0', + }, null, 2); + }); + mock.module('./config.js', () => ({ + readConfig: () => ({ + access: 'restricted', + baseBranch: 'main', + updateInternalDependencies: 'patch', + ignore: ['@ignored/package'], + lazyChangesets: { + types: {}, + }, + }), + })); + + await publish({ dryRun: false }); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring package')); + }); + + test('should skip packages with missing name or version', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({ + version: '1.0.0', + }, null, 2)); + + await publish({ dryRun: false }); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping')); + }); + + test('should handle multiple packages', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue([ + 'packages/package1/package.json', + 'packages/package2/package.json', + ]); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'npm', agent: 'npm' }); + + await publish({ dryRun: true }); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Found'), expect.stringContaining('2 package(s)')); + }); +}); diff --git a/src/publish.ts b/src/publish.ts new file mode 100644 index 0000000..a549d34 --- /dev/null +++ b/src/publish.ts @@ -0,0 +1,293 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { globSync } from 'tinyglobby'; +import path from 'node:path'; +import pc from 'picocolors'; +import { execSync } from 'node:child_process'; +import { detect } from 'package-manager-detector'; +import { readConfig } from './config.js'; +import type { ChangesetConfig } from './config.js'; + +export interface PackageInfo { + name: string; + version: string; + dir: string; + isPrivate: boolean; +} + +export async function publish({ dryRun = false } = {}) { + const config = readConfig(); + const packages = await findPackages(config); + + if (packages.length === 0) { + console.log(pc.yellow('No packages found.')); + return; + } + + if (dryRun) { + console.log(pc.yellow('\nDry run - no actual publishing will occur.\n')); + } + + console.log(pc.dim('Found'), pc.cyan(`${packages.length} package(s)`)); + + for (const pkg of packages) { + await publishPackage(pkg, dryRun); + } + + if (dryRun) { + console.log(pc.yellow('\nDry run complete - no changes were made.')); + } else { + console.log(pc.green('\nāœ” Publish complete!')); + } +} + +async function findPackages(config: ChangesetConfig): Promise { + const packageJsonPaths = globSync({ + patterns: ['**/package.json', '!**/node_modules/**', '!**/dist/**'], + }); + + const packages: PackageInfo[] = []; + + for (const packageJsonPath of packageJsonPaths) { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + const packageName = packageJson.name; + const packageVersion = packageJson.version; + + if (!packageName || !packageVersion) { + console.warn(`Skipping ${packageJsonPath} - missing name or version`); + continue; + } + + if (config.ignore.includes(packageName)) { + console.warn(pc.dim(`Ignoring package ${packageName}`)); + continue; + } + + const dirPath = './' + packageJsonPath.replace(/\/?package\.json$/, ''); + packages.push({ + name: packageName, + version: packageVersion, + dir: dirPath, + isPrivate: packageJson.private === true, + }); + } + + return packages; +} + +async function publishPackage(pkg: PackageInfo, dryRun: boolean) { + const tag = `${pkg.name}@${pkg.version}`; + + console.log(pc.dim('\n---')); + console.log(pc.cyan(pkg.name), pc.dim(`v${pkg.version}`)); + + if (dryRun) { + console.log(pc.yellow('[DRY RUN]'), pc.dim('Would create and push tag'), pc.cyan(tag)); + } else if (await tagExistsRemote(tag)) { + console.log(pc.dim(`Tag ${tag} already exists on remote. Skipping.`)); + } else { + try { + execSync(`git tag -a ${tag} -m "${tag}"`, { stdio: 'pipe' }); + console.log(pc.dim('Created tag'), pc.cyan(tag)); + + execSync(`git push origin ${tag}`, { stdio: 'pipe' }); + console.log(pc.dim('Pushed tag'), pc.cyan(tag)); + } catch (error) { + console.error(pc.red('Failed to create or push tag'), pc.cyan(tag)); + throw error; + } + } + + if (pkg.isPrivate) { + console.log(pc.dim('Package is private. Skipping npm publish.')); + } else if (dryRun) { + console.log(pc.yellow('[DRY RUN]'), pc.dim('Would publish to npm')); + } else { + await publishToNpm(pkg); + } + + if (dryRun) { + const changelogContent = getChangelogForVersion(pkg); + const releaseNotes = changelogContent ? changelogContent : ''; + const title = `${pkg.name}@${pkg.version}`; + + console.log(pc.yellow('[DRY RUN]'), pc.dim('Would create GitHub release')); + console.log(pc.dim(' Tag:'), pc.cyan(tag)); + console.log(pc.dim(' Title:'), pc.cyan(title)); + + if (releaseNotes) { + console.log(pc.dim(' Body:\n')); + console.log(releaseNotes); + } else { + console.log(pc.dim(' Body:'), pc.yellow('(No changelog found for this version)')); + } + } else { + await createGitHubRelease(pkg, tag); + } +} + +async function tagExistsRemote(tag: string): Promise { + try { + execSync(`git ls-remote --tags origin refs/tags/${tag}`, { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +async function publishToNpm(pkg: PackageInfo) { + const detected = await detect(); + if (!detected) { + console.warn(pc.yellow('Could not detect package manager. Skipping npm publish.')); + return; + } + + const agent = detected.agent || detected.name; + let publishCmd = ''; + + switch (agent) { + case 'npm': + publishCmd = 'npm publish'; + break; + case 'yarn': + case 'yarn@berry': + publishCmd = 'yarn publish --non-interactive'; + break; + case 'pnpm': + case 'pnpm@6': + publishCmd = 'pnpm publish --no-git-checks'; + break; + case 'bun': + publishCmd = 'bun publish'; + break; + default: + console.warn(pc.yellow(`Unsupported package manager: ${agent}. Skipping npm publish.`)); + return; + } + + console.log(pc.dim('Publishing to npm...')); + + try { + execSync(publishCmd, { cwd: pkg.dir, stdio: 'pipe' }); + console.log(pc.green('āœ”'), 'Published to npm'); + } catch (error) { + console.error(pc.red('āœ—'), 'Failed to publish to npm'); + throw error; + } +} + +async function createGitHubRelease(pkg: PackageInfo, tag: string) { + const changelogContent = getChangelogForVersion(pkg); + + if (!changelogContent) { + console.log(pc.dim(`No changelog found for version ${pkg.version}. Skipping GitHub release.`)); + return; + } + + const releaseNotes = `# ${pkg.name}\n\n${changelogContent}`; + + console.log(pc.dim('Creating GitHub release...')); + + try { + execSync( + `gh release create ${tag} --title "${pkg.name} v${pkg.version}" --notes "${escapeShell(releaseNotes)}"`, + { stdio: 'pipe' } + ); + console.log(pc.green('āœ”'), 'Created GitHub release'); + } catch (error) { + console.error(pc.red('āœ—'), 'Failed to create GitHub release'); + console.warn(pc.yellow('Make sure you have gh CLI installed and authenticated.')); + throw error; + } +} + +function getChangelogForVersion(pkg: PackageInfo): string | null { + const changelogPath = path.join(pkg.dir, 'CHANGELOG.md'); + + if (!existsSync(changelogPath)) { + return null; + } + + const changelogContent = readFileSync(changelogPath, 'utf-8'); + + const versionHeaderRegex = new RegExp(`^##\\s+${pkg.version.replace(/\./g, '\\.')}$`, 'm'); + const versionMatch = changelogContent.match(versionHeaderRegex); + + if (!versionMatch || versionMatch.index === undefined) { + return null; + } + + const startIndex = versionMatch.index; + const nextVersionHeader = changelogContent.indexOf('\n## ', startIndex + 1); + + if (nextVersionHeader === -1) { + return changelogContent.substring(startIndex).trim(); + } + + return changelogContent.substring(startIndex, nextVersionHeader).trim(); +} + +export function generateReleaseNotes(pkg: PackageInfo, changesetContents: string[]): string { + let notes = `# ${pkg.name}\n\n## ${pkg.version}\n\n`; + + const typeGroups: Map = new Map(); + + for (const content of changesetContents) { + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!frontmatterMatch) continue; + + const frontmatter = frontmatterMatch[1]; + const lines = frontmatter.split('\n'); + + const messageMatch = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/); + const message = messageMatch?.[1]?.trim() || ''; + + for (const line of lines) { + const match = line.match(/^"([^"]+)":\s*(\w+)(!?)/); + if (match && match[1] === pkg.name) { + const changesetType = match[2]; + const isBreaking = match[3] === '!'; + + const existing = typeGroups.get(changesetType) || []; + typeGroups.set(changesetType, [...existing, message]); + break; + } + } + } + + if (typeGroups.size === 0) { + return notes + 'No changes recorded.\n'; + } + + const typeEmojis: Record = { + feat: 'šŸš€', + fix: 'šŸ›', + perf: 'āš”ļø', + chore: 'šŸ ', + docs: 'šŸ“š', + style: 'šŸŽØ', + refactor: 'ā™»ļø', + test: 'āœ…', + build: 'šŸ“¦', + ci: 'šŸ¤–', + revert: 'āŖ', + }; + + const typeOrder = ['feat', 'fix', 'perf', 'refactor', 'chore', 'docs', 'style', 'test', 'build', 'ci', 'revert']; + + for (const type of typeOrder) { + const messages = typeGroups.get(type); + if (!messages || messages.length === 0) continue; + + notes += `### ${typeEmojis[type] || '•'} ${type}\n`; + for (const msg of messages) { + notes += `- ${msg}\n`; + } + notes += '\n'; + } + + return notes; +} + +export function escapeShell(str: string): string { + return str.replace(/'/g, "'\\''").replace(/\n/g, '\\n').replace(/"/g, '\\"'); +} diff --git a/src/version.test.ts b/src/version.test.ts index 449e4bf..ed5f8e6 100644 --- a/src/version.test.ts +++ b/src/version.test.ts @@ -46,7 +46,7 @@ Added new feature`; const result = parseChangesetFile('.changeset/test.md'); expect(result).toEqual([ - { type: 'minor', packageName: '@test/package' } + { type: 'minor', packageName: '@test/package', changesetType: 'feat', message: 'Added new feature' } ]); }); @@ -63,7 +63,7 @@ Breaking change added`; const result = parseChangesetFile('.changeset/test.md'); expect(result).toEqual([ - { type: 'major', packageName: '@test/package' } + { type: 'major', packageName: '@test/package', changesetType: 'feat', message: 'Breaking change added' } ]); }); @@ -80,7 +80,7 @@ Bug fix`; const result = parseChangesetFile('.changeset/test.md'); expect(result).toEqual([ - { type: 'patch', packageName: '@test/package' } + { type: 'patch', packageName: '@test/package', changesetType: 'fix', message: 'Bug fix' } ]); }); @@ -98,8 +98,8 @@ Multiple packages updated`; const result = parseChangesetFile('.changeset/test.md'); expect(result).toEqual([ - { type: 'minor', packageName: '@test/package' }, - { type: 'patch', packageName: '@other/package' } + { type: 'minor', packageName: '@test/package', changesetType: 'feat', message: 'Multiple packages updated' }, + { type: 'patch', packageName: '@other/package', changesetType: 'fix', message: 'Multiple packages updated' } ]); }); @@ -118,7 +118,7 @@ Test`; const result = parseChangesetFile('.changeset/test.md'); expect(result).toEqual([ - { type: 'minor', packageName: '@test/package' } + { type: 'minor', packageName: '@test/package', changesetType: 'feat', message: 'Test' } ]); }); @@ -136,8 +136,8 @@ Multiple breaking changes`; const result = parseChangesetFile('.changeset/test.md'); expect(result).toEqual([ - { type: 'major', packageName: '@test/package' }, - { type: 'major', packageName: '@other/package' } + { type: 'major', packageName: '@test/package', changesetType: 'feat', message: 'Multiple breaking changes' }, + { type: 'major', packageName: '@other/package', changesetType: 'fix', message: 'Multiple breaking changes' } ]); }); @@ -169,52 +169,52 @@ Multiple breaking changes`; describe('getHighestReleaseType', () => { test('should return major when any release is major', () => { const releases: ChangesetReleaseType[] = [ - { type: 'major', packageName: '@test/package' }, - { type: 'patch', packageName: '@test/package' } + { type: 'major', packageName: '@test/package', changesetType: 'feat', message: '' }, + { type: 'patch', packageName: '@test/package', changesetType: 'fix', message: '' } ]; - + expect(getHighestReleaseType(releases)).toBe('major'); }); test('should return minor when no major but has minor', () => { const releases: ChangesetReleaseType[] = [ - { type: 'minor', packageName: '@test/package' }, - { type: 'patch', packageName: '@test/package' } + { type: 'minor', packageName: '@test/package', changesetType: 'feat', message: '' }, + { type: 'patch', packageName: '@test/package', changesetType: 'fix', message: '' } ]; - + expect(getHighestReleaseType(releases)).toBe('minor'); }); test('should return patch when only patches', () => { const releases: ChangesetReleaseType[] = [ - { type: 'patch', packageName: '@test/package' }, - { type: 'patch', packageName: '@test/package' } + { type: 'patch', packageName: '@test/package', changesetType: 'fix', message: '' }, + { type: 'patch', packageName: '@test/package', changesetType: 'fix', message: '' } ]; - + expect(getHighestReleaseType(releases)).toBe('patch'); }); test('should return patch for single patch', () => { const releases: ChangesetReleaseType[] = [ - { type: 'patch', packageName: '@test/package' } + { type: 'patch', packageName: '@test/package', changesetType: 'fix', message: '' } ]; - + expect(getHighestReleaseType(releases)).toBe('patch'); }); test('should return major for single major', () => { const releases: ChangesetReleaseType[] = [ - { type: 'major', packageName: '@test/package' } + { type: 'major', packageName: '@test/package', changesetType: 'feat', message: '' } ]; - + expect(getHighestReleaseType(releases)).toBe('major'); }); test('should return minor for single minor', () => { const releases: ChangesetReleaseType[] = [ - { type: 'minor', packageName: '@test/package' } + { type: 'minor', packageName: '@test/package', changesetType: 'feat', message: '' } ]; - + expect(getHighestReleaseType(releases)).toBe('minor'); }); }); @@ -278,6 +278,7 @@ describe('version command', () => { if (pathStr.includes('.changeset')) return true; return false; }); + spyOn(fs, 'writeFileSync').mockImplementation(() => {}); }); afterEach(() => { diff --git a/src/version.ts b/src/version.ts index 6de08c6..ed1c608 100644 --- a/src/version.ts +++ b/src/version.ts @@ -5,10 +5,13 @@ import pc from 'picocolors'; import { execSync } from 'node:child_process'; import { detect } from 'package-manager-detector'; import { readConfig } from './config.js'; +import type { ChangesetType } from './changeset.js'; export interface ChangesetReleaseType { type: 'major' | 'minor' | 'patch'; packageName: string; + message: string; + changesetType: string; } export function parseChangesetFile(filePath: string): ChangesetReleaseType[] { @@ -23,6 +26,9 @@ export function parseChangesetFile(filePath: string): ChangesetReleaseType[] { const frontmatter = frontmatterMatch[1]; const lines = frontmatter.split('\n'); + const messageMatch = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/); + const message = messageMatch?.[1]?.trim() || ''; + for (const line of lines) { const match = line.match(/^"([^"]+)":\s*(\w+)(!?)/); if (match) { @@ -38,7 +44,7 @@ export function parseChangesetFile(filePath: string): ChangesetReleaseType[] { releaseType = 'minor'; } - releases.push({ type: releaseType, packageName }); + releases.push({ type: releaseType, packageName, message, changesetType }); } } @@ -68,6 +74,67 @@ export function bumpVersion(version: string, releaseType: ChangesetReleaseType[' } } +export function generateChangelog(packageName: string, version: string, changesetContents: string[]): string { + let changelog = `## ${version}\n\n`; + + const typeGroups: Map = new Map(); + + for (const content of changesetContents) { + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!frontmatterMatch) continue; + + const frontmatter = frontmatterMatch[1]; + const lines = frontmatter.split('\n'); + + const messageMatch = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/); + const message = messageMatch?.[1]?.trim() || ''; + + for (const line of lines) { + const match = line.match(/^"([^"]+)":\s*(\w+)(!?)/); + if (match && match[1] === packageName) { + const changesetType = match[2]; + + const existing = typeGroups.get(changesetType) || []; + typeGroups.set(changesetType, [...existing, message]); + break; + } + } + } + + if (typeGroups.size === 0) { + return changelog + 'No changes recorded.\n'; + } + + const typeEmojis: Record = { + feat: 'šŸš€', + fix: 'šŸ›', + perf: 'āš”ļø', + chore: 'šŸ ', + docs: 'šŸ“š', + style: 'šŸŽØ', + refactor: 'ā™»ļø', + test: 'āœ…', + build: 'šŸ“¦', + ci: 'šŸ¤–', + revert: 'āŖ', + }; + + const typeOrder = ['feat', 'fix', 'perf', 'refactor', 'chore', 'docs', 'style', 'test', 'build', 'ci', 'revert']; + + for (const type of typeOrder) { + const messages = typeGroups.get(type); + if (!messages || messages.length === 0) continue; + + changelog += `### ${typeEmojis[type] || '•'} ${type}\n`; + for (const msg of messages) { + changelog += `- ${msg}\n`; + } + changelog += '\n'; + } + + return changelog; +} + export async function version({ dryRun = false, ignore = [] as string[], install = false } = {}) { const config = readConfig(); const changesetDir = path.join(process.cwd(), '.changeset'); @@ -88,12 +155,17 @@ export async function version({ dryRun = false, ignore = [] as string[], install } const packageReleases: Map = new Map(); + const packageChangelogs: Map = new Map(); for (const changesetFile of changesetFiles) { + const content = readFileSync(changesetFile, 'utf-8'); const releases = parseChangesetFile(changesetFile); for (const release of releases) { - const existing = packageReleases.get(release.packageName) || []; - packageReleases.set(release.packageName, [...existing, release]); + const existingReleases = packageReleases.get(release.packageName) || []; + packageReleases.set(release.packageName, [...existingReleases, release]); + + const existingChangelogs = packageChangelogs.get(release.packageName) || []; + packageChangelogs.set(release.packageName, [...existingChangelogs, content]); } } @@ -125,6 +197,19 @@ export async function version({ dryRun = false, ignore = [] as string[], install if (!dryRun) { writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf-8'); + + const packageDir = path.dirname(packageJsonPath); + const changelogPath = path.join(packageDir, 'CHANGELOG.md'); + + const changesetContents = packageChangelogs.get(packageName) || []; + const newChangelog = generateChangelog(packageName, newVersion, changesetContents); + + let existingChangelog = ''; + if (existsSync(changelogPath)) { + existingChangelog = readFileSync(changelogPath, 'utf-8'); + } + + writeFileSync(changelogPath, newChangelog + '\n' + existingChangelog, 'utf-8'); } console.log(