@vabole/patcher
Version:
Tool for patching npm packages without modifying source repositories
436 lines (375 loc) • 15.1 kB
JavaScript
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
import readline from 'node:readline';
import { Command } from 'commander';
// Set up command-line interface
const program = new Command();
program
.name('publish-version')
.description('Automate version updates and publishing for patcher')
.option('-t, --type <type>', 'Version bump type (major, minor, patch)', 'patch')
.option('-v, --version <version>', 'Specific version to set (overrides type)')
.option('-y, --yes', 'Skip confirmation prompt', false)
.option('-b, --branch <branch>', 'Allow publishing from specified branch instead of main')
.option('--dry-run', 'Show what would be done without making changes', false)
.option('--git', 'Perform git operations (commit, tagging, and pushing)', true)
.option('--no-git', 'Skip git operations (commit, tagging, and pushing)')
.addHelpText('after', `
Examples:
# Interactive mode (recommended for manual use)
$ node scripts/publish.js
# Non-interactive mode with automatic patch version bump
$ node scripts/publish.js --type patch --yes
# Non-interactive mode with specific version
$ node scripts/publish.js --version 1.2.3 --yes
# Test what would happen without making changes
$ node scripts/publish.js --dry-run
# Allow publishing from a different branch
$ node scripts/publish.js --type minor --branch feature/my-branch --yes
# Skip git operations
$ node scripts/publish.js --type patch --no-git --yes
`)
.parse(process.argv);
const options = program.opts();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.join(__dirname, '..');
// Paths
const packageJsonPath = path.join(rootDir, 'package.json');
const cliJsPath = path.join(rootDir, 'src', 'cli.js');
// Create readline interface for user input (only used in interactive mode)
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
/**
* Ask a question and get user input
* @param {string} question The question to ask
* @returns {Promise<string>} The user's answer
*/
function askQuestion(question) {
return new Promise(resolve => {
rl.question(question, answer => {
resolve(answer);
});
});
}
/**
* Read package.json and parse it
* @returns {object} The parsed package.json
*/
function getPackageJson() {
const content = fs.readFileSync(packageJsonPath, 'utf8');
return JSON.parse(content);
}
/**
* Read cli.js and extract the version
* @returns {string} The current version in cli.js
*/
function getCliVersion() {
const content = fs.readFileSync(cliJsPath, 'utf8');
const versionMatch = content.match(/\.version\(['"]([^'"]+)['"]\)/);
if (!versionMatch) {
throw new Error('Could not extract version from cli.js');
}
return versionMatch[1];
}
/**
* Update the version in package.json
* @param {string} newVersion The new version to set
*/
function updatePackageJson(newVersion) {
const pkg = getPackageJson();
pkg.version = newVersion;
fs.writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
console.log(`✓ Updated version in package.json to ${newVersion}`);
}
/**
* Update the version in cli.js
* @param {string} newVersion The new version to set
*/
function updateCliJs(newVersion) {
let content = fs.readFileSync(cliJsPath, 'utf8');
content = content.replace(
/\.version\(['"]([^'"]+)['"]\)/,
`.version('${newVersion}')`
);
fs.writeFileSync(cliJsPath, content, 'utf8');
console.log(`✓ Updated version in cli.js to ${newVersion}`);
}
/**
* Create and push a Git tag for the version
* @param {string} version The version to tag
* @param {object} options Command line options
*/
function createAndPushTag(version, options) {
try {
// Check if we're on allowed branch
const allowedBranch = options.branch || `feature/version-update-${version}`;
const isBranchSpecified = !!options.branch;
// Track both initial and working branch for clarity
const initialBranch = execSync('git branch --show-current', { encoding: 'utf8' }).trim();
let workingBranch = initialBranch;
const isMainBranch = initialBranch === 'main';
const isVersionBranch = initialBranch.startsWith('feature/version-update');
// If we're on main and no branch is specified, auto-create a feature branch
if (isMainBranch && !isBranchSpecified) {
console.log(`Creating feature branch ${allowedBranch} for version update...`);
execSync(`git checkout -b ${allowedBranch}`, { stdio: 'inherit' });
// Update workingBranch to the newly created branch
workingBranch = allowedBranch;
} else if (workingBranch !== allowedBranch && !isVersionBranch) {
throw new Error(`You are on branch '${workingBranch}'.
To publish, either:
1. Create a feature branch first: git checkout -b feature/version-update-${version}
2. Specify your branch with --branch: npm run publish:minor -- --branch ${workingBranch}`);
}
if (options.dryRun) {
console.log(`[DRY RUN] Would commit changes to package.json and src/cli.js`);
console.log(`[DRY RUN] Would create and push tag v${version}`);
console.log(`[DRY RUN] Would create PR from ${allowedBranch} to main`);
return;
}
// Stage changes
console.log('Committing version changes...');
execSync('git add package.json src/cli.js', { stdio: 'inherit' });
execSync(`git commit -m "Update version to ${version}"`, { stdio: 'inherit' });
// Push the feature branch
console.log(`Pushing branch ${workingBranch}...`);
let branchPushed = false;
try {
execSync(`git push -u origin ${workingBranch}`, { stdio: 'inherit' });
branchPushed = true;
} catch (pushError) {
console.warn(`Warning: Could not push branch. You may need to push manually.`);
console.warn(`Run: git push -u origin ${workingBranch}`);
}
// Create and push tag
console.log(`Creating tag v${version}...`);
execSync(`git tag v${version}`, { stdio: 'inherit' });
let tagPushed = false;
try {
execSync(`git push origin v${version}`, { stdio: 'inherit' });
tagPushed = true;
} catch (tagPushError) {
console.warn(`Warning: Could not push tag. You may need to push manually.`);
console.warn(`Run: git push origin v${version}`);
}
// Create PR automatically - only if branch was pushed successfully
if (branchPushed) {
// Small delay to ensure GitHub registers the push
console.log(`\nWaiting for GitHub to register the branch...`);
try {
execSync('sleep 2');
} catch (e) {
// Ignore interruption
}
console.log(`Creating PR for version ${version}...`);
try {
const prTitle = `Update version to ${version}`;
const prBody = `## Version ${version}
This PR updates the package version to ${version}.
The version tag has already been created and pushed, which will trigger publishing once this PR is merged.`;
// Explicitly specify the head branch to avoid confusion
execSync(`gh pr create --title "${prTitle}" --body "${prBody}" --head ${workingBranch}`, { stdio: 'inherit' });
console.log('\n✅ PR created successfully!');
console.log('\nNext steps:');
console.log('1. Wait for CI checks to pass on the PR');
console.log('2. Merge the PR to main');
console.log('3. The package will be automatically published by GitHub Actions');
} catch (prError) {
console.warn('\nCould not automatically create PR. Please create it manually:');
console.warn(`gh pr create --title "Update version to ${version}" --body "Version update to ${version}" --head ${workingBranch}`);
}
} else {
console.warn('\nSkipping PR creation because branch push failed.');
console.warn('After pushing the branch manually, create a PR:');
console.warn(`gh pr create --title "Update version to ${version}" --body "Version update to ${version}" --head ${workingBranch}`);
}
} catch (error) {
console.error('Error in Git operations:', error.message);
process.exit(1);
}
}
/**
* Check if there are uncommitted changes
* @returns {boolean} True if there are uncommitted changes
*/
function hasUncommittedChanges() {
try {
const status = execSync('git status --porcelain', { encoding: 'utf8' });
return status.trim() !== '';
} catch (error) {
console.error('Error checking Git status:', error.message);
process.exit(1);
}
}
/**
* Calculate the next version based on the current version and bump type
* @param {string} currentVersion The current version
* @param {string} bumpType The type of bump (major, minor, patch)
* @returns {string} The next version
*/
function calculateNextVersion(currentVersion, bumpType) {
const [major, minor, patch] = currentVersion.split('.').map(Number);
switch (bumpType) {
case 'major':
return `${major + 1}.0.0`;
case 'minor':
return `${major}.${minor + 1}.0`;
case 'patch':
return `${major}.${minor}.${patch + 1}`;
default:
return currentVersion;
}
}
/**
* Run in interactive mode - prompts the user for input
*/
async function runInteractive() {
try {
console.log('=== Patcher Publishing Tool (Interactive Mode) ===');
// Get current versions
const pkgVersion = getPackageJson().version;
const cliVersion = getCliVersion();
console.log(`Current version in package.json: ${pkgVersion}`);
console.log(`Current version in cli.js: ${cliVersion}`);
// Ask for version bump type
console.log('\nWhat kind of version bump would you like to make?');
console.log('1. major (x.0.0) - Breaking changes');
console.log('2. minor (0.x.0) - New features, no breaking changes');
console.log('3. patch (0.0.x) - Bug fixes, no new features or breaking changes');
console.log('4. custom - Enter a custom version number');
const bumpChoice = await askQuestion('\nEnter your choice (1-4): ');
let newVersion;
switch (bumpChoice) {
case '1':
newVersion = calculateNextVersion(pkgVersion, 'major');
break;
case '2':
newVersion = calculateNextVersion(pkgVersion, 'minor');
break;
case '3':
newVersion = calculateNextVersion(pkgVersion, 'patch');
break;
case '4':
newVersion = await askQuestion('\nEnter custom version (e.g., 1.2.3): ');
// Validate version format
if (!/^\d+\.\d+\.\d+$/.test(newVersion)) {
console.error('❌ Error: Invalid version format. Expected format: x.y.z (e.g., 1.2.3)');
process.exit(1);
}
break;
default:
console.error('❌ Error: Invalid choice');
process.exit(1);
}
console.log(`\nUpdating from ${pkgVersion} to ${newVersion}`);
// Confirm before proceeding
const confirm = await askQuestion('\nDo you want to proceed with the version update and publish? (y/n): ');
if (confirm.toLowerCase() !== 'y') {
console.log('Publishing aborted.');
process.exit(0);
}
return newVersion;
} finally {
rl.close();
}
}
/**
* Run in non-interactive mode - use command line options
* @param {object} options Command line options
* @param {string} currentVersion Current version
* @returns {string} New version
*/
function runNonInteractive(options, currentVersion) {
console.log('=== Patcher Publishing Tool (Non-Interactive Mode) ===');
let newVersion;
// If specific version is provided, use that
if (options.version) {
newVersion = options.version;
// Validate version format
if (!/^\d+\.\d+\.\d+$/.test(newVersion)) {
console.error('❌ Error: Invalid version format. Expected format: x.y.z (e.g., 1.2.3)');
process.exit(1);
}
} else {
// Otherwise calculate based on bump type
newVersion = calculateNextVersion(currentVersion, options.type);
}
console.log(`Updating from ${currentVersion} to ${newVersion}`);
// Skip confirmation if --yes is specified
if (!options.yes && !options.dryRun) {
console.log('⚠️ You are running in non-interactive mode, but without --yes flag.');
console.log(' Set --yes to skip this warning in CI environments.');
console.log('');
console.log(' Run with --dry-run to see what would happen without making changes.');
console.log('');
console.log('To proceed anyway, press Ctrl+C now to abort, or wait 5 seconds to continue...');
// Wait for 5 seconds to let the user abort if needed
try {
execSync('sleep 5');
} catch (err) {
console.log('Operation aborted.');
process.exit(1);
}
}
return newVersion;
}
/**
* Main function
*/
async function main() {
try {
// Check for uncommitted changes
if (hasUncommittedChanges() && !options.dryRun) {
console.error('❌ Error: You have uncommitted changes. Please commit or stash them before publishing.');
console.error(' Use --dry-run to see what would happen without making changes.');
process.exit(1);
}
// Get current versions
const pkgVersion = getPackageJson().version;
const cliVersion = getCliVersion();
// Ensure versions match
if (pkgVersion !== cliVersion) {
console.error(`❌ Error: Version mismatch between package.json (${pkgVersion}) and cli.js (${cliVersion})`);
process.exit(1);
}
let newVersion;
// Determine if we're running in interactive or non-interactive mode
const isNonInteractive = options.type || options.version || options.yes;
if (isNonInteractive) {
newVersion = runNonInteractive(options, pkgVersion);
} else {
newVersion = await runInteractive();
}
if (options.dryRun) {
console.log(`[DRY RUN] Would update version in package.json to ${newVersion}`);
console.log(`[DRY RUN] Would update version in cli.js to ${newVersion}`);
} else {
// Update versions
updatePackageJson(newVersion);
updateCliJs(newVersion);
}
// Create and push tag (if git operations are not disabled)
if (options.git) {
createAndPushTag(newVersion, options);
} else if (options.dryRun) {
console.log(`[DRY RUN] Git operations are disabled, would not create tag or push changes`);
} else {
console.log(`⚠️ Git operations are disabled with --no-git`);
console.log(`You will need to manually commit and tag this release.`);
}
console.log('\n✅ Publishing process initiated successfully!');
if (!options.dryRun && options.git) {
console.log('The package will be published by GitHub Actions once the workflow completes.');
}
} catch (error) {
console.error('❌ Error:', error.message);
process.exit(1);
}
}
// Run the main function
main();