#!/usr/bin/env node import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import semver from 'semver'; function tagMatchesVersion(tag, version) { if (!tag) { return false; } if (tag === version) { return true; } if (tag.startsWith('v')) { return tag.slice(1) === version; } return false; } function hasMatchingTag(tagsOutput, version) { return tagsOutput .split('\n') .map(tag => tag.trim()) .filter(Boolean) .some(tag => tagMatchesVersion(tag, version)); } function findMatchingTag(tagsOutput, version) { return tagsOutput .split('\n') .map(tag => tag.trim()) .filter(Boolean) .find(tag => tagMatchesVersion(tag, version)) || null; } function showUsage() { console.log('Usage: node scripts/new-version.mjs [--force] [-m|--message "commit message"]'); console.log(' node scripts/new-version.mjs --check '); console.log(''); console.log('Creates a new version by tagging the current commit.'); console.log('Version must be valid semver (e.g., 1.2.3).'); console.log(''); console.log('Options:'); console.log(' --force Force version creation even with dirty repo or package.json mismatch'); console.log(' --check Analyze repository status and report what would happen for specified version'); console.log(' -m, --message TEXT Custom commit message (only used when commit is needed)'); console.log(''); console.log('Example:'); console.log(' node scripts/new-version.mjs 1.2.0'); console.log(' node scripts/new-version.mjs 1.2.0 --force'); console.log(' node scripts/new-version.mjs 1.2.0 -m "Add new feature XYZ"'); console.log(' node scripts/new-version.mjs --check 1.3.0'); } function performCheck(targetVersion) { console.log('Repository Analysis Report'); console.log('============================'); try { // Read package.json const packagePath = './package.json'; const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); const currentVersion = pkg.version; console.log(`Package.json version: ${currentVersion}`); // Check repository status let isRepoDirty = false; let dirtyFiles = ''; try { const status = execSync('git status --porcelain', { encoding: 'utf8' }); isRepoDirty = status.trim() !== ''; dirtyFiles = status.trim(); } catch (error) { console.log('Warning: Cannot determine git status'); } if (isRepoDirty) { console.log('Repository status: DIRTY'); console.log(' Uncommitted changes:'); dirtyFiles.split('\n').forEach(line => { if (line.trim()) console.log(` ${line}`); }); } else { console.log('Repository status: CLEAN'); } // Check current commit info try { const currentCommit = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim(); const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim(); console.log(`Current commit: ${currentCommit.substring(0, 7)} (${currentBranch})`); // Check if current commit is tagged const tagsOnHead = execSync('git tag --points-at HEAD', { encoding: 'utf8' }).trim(); if (tagsOnHead) { console.log(`Current commit tags: ${tagsOnHead.split('\n').join(', ')}`); } else { console.log('Current commit: No tags'); } } catch (error) { console.log('Warning: Cannot determine commit info'); } // List recent tags try { const recentTags = execSync('git tag --sort=-version:refname | head -5', { encoding: 'utf8' }).trim(); if (recentTags) { console.log('Recent tags:'); recentTags.split('\n').forEach(tag => { if (tag.trim()) console.log(` ${tag}`); }); } else { console.log('No tags found in repository'); } } catch (error) { console.log('Warning: Cannot list tags'); } console.log(''); // Analysis for target version (if provided) if (targetVersion) { const tagName = targetVersion; console.log(`Analysis for version ${targetVersion}:`); console.log('====================================='); // Check if target tag exists try { const existingTags = execSync('git tag -l', { encoding: 'utf8' }); const matchingTag = findMatchingTag(existingTags, targetVersion); if (matchingTag) { console.log(`Error: Tag '${matchingTag}' already exists - CANNOT CREATE`); return; } console.log(`Tag '${tagName}' available`); } catch (error) { console.log('Warning: Cannot check tag availability'); return; } // Analyze what actions would be needed const packageJsonMatches = currentVersion === targetVersion; const needsPackageUpdate = !packageJsonMatches; const needsCommit = isRepoDirty || needsPackageUpdate; console.log(`Package.json: ${packageJsonMatches ? 'MATCHES' : `NEEDS UPDATE (${currentVersion} -> ${targetVersion})`}`); if (needsCommit) { console.log('Actions needed:'); if (needsPackageUpdate) { console.log(' - Update package.json'); } if (isRepoDirty) { console.log(' - Stage uncommitted changes'); } console.log(' - Create commit'); console.log(` - Create tag ${tagName}`); console.log(''); console.log('Commands that would work:'); if (isRepoDirty || needsPackageUpdate) { console.log(` node scripts/new-version.mjs ${targetVersion} --force`); } else { console.log(` node scripts/new-version.mjs ${targetVersion}`); console.log(` node scripts/new-version.mjs ${targetVersion} --force`); } } else { console.log('Actions needed:'); console.log(` - Create tag ${tagName} (no commit needed)`); console.log(''); console.log('Commands that would work:'); console.log(` node scripts/new-version.mjs ${targetVersion}`); console.log(` node scripts/new-version.mjs ${targetVersion} --force`); } console.log(''); console.log('Default mode requirements:'); if (isRepoDirty) { console.log(' Repository must be clean (currently dirty)'); } else { console.log(' Repository is clean'); } if (!packageJsonMatches) { console.log(` Package.json must match version (currently ${currentVersion})`); } else { console.log(' Package.json version matches'); } } else { // This should never happen since version is now required console.error('Internal error: No version provided to performCheck'); process.exit(1); } } catch (error) { console.error('Error during analysis:', error.message); process.exit(1); } } function main() { // Parse command line arguments const args = process.argv.slice(2); if (args.length === 0 || args.includes('-h') || args.includes('--help')) { showUsage(); process.exit(args.length === 0 ? 1 : 0); } const isCheck = args.includes('--check'); const isForce = args.includes('--force'); // Parse custom commit message let customMessage = null; const messageIndex = args.findIndex(arg => arg === '-m' || arg === '--message'); if (messageIndex !== -1 && messageIndex + 1 < args.length) { customMessage = args[messageIndex + 1]; } let newVersion; if (isCheck) { // For --check, version is required newVersion = args.find(arg => !arg.startsWith('--') && arg !== '-m' && arg !== customMessage); if (!newVersion) { console.error('Error: Version argument required for --check'); showUsage(); process.exit(1); } } else { // For normal operation, version is required newVersion = args.find(arg => !arg.startsWith('--') && arg !== '-m' && arg !== customMessage); if (!newVersion) { console.error('Error: Version argument required'); showUsage(); process.exit(1); } } if (newVersion && newVersion.startsWith('v')) { console.error('Error: Version must not start with "v". Use plain semver like 1.2.3.'); process.exit(1); } const normalizedVersion = newVersion; if (!semver.valid(normalizedVersion)) { console.error('Error: Version must be valid semver (e.g., 1.2.3)'); process.exit(1); } if (isCheck) { performCheck(normalizedVersion); return; } const tagName = normalizedVersion; console.log(`Creating new version: ${normalizedVersion}${isForce ? ' (forced)' : ''}`); try { // 1. Check if tag already exists - Always ERROR try { const existingTags = execSync('git tag -l', { encoding: 'utf8' }); const matchingTag = findMatchingTag(existingTags, normalizedVersion); if (matchingTag) { console.error(`Error: Tag '${matchingTag}' already exists`); process.exit(1); } } catch (error) { console.error('Error: Failed to check existing tags'); process.exit(1); } // 2. Check repository status let isRepoDirty = false; try { const status = execSync('git status --porcelain', { encoding: 'utf8' }); isRepoDirty = status.trim() !== ''; } catch (error) { console.error('Error: Failed to check git status'); process.exit(1); } // 3. Check package.json version const packagePath = './package.json'; const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); const currentVersion = pkg.version; const packageJsonMatches = currentVersion === normalizedVersion; // 4. Determine what action is needed const needsPackageUpdate = !packageJsonMatches; const needsCommit = isRepoDirty || needsPackageUpdate; // 5. Check if force is required if (!isForce) { if (isRepoDirty) { console.error('Error: Working directory has uncommitted changes'); console.error('Please commit your changes first or use --force'); process.exit(1); } if (needsPackageUpdate) { console.error(`Error: Package.json version is ${currentVersion}, requested ${normalizedVersion}`); console.error('Use --force to update package.json'); process.exit(1); } } // 6. Execute the versioning if (needsCommit) { console.log(`Needs commit: ${needsPackageUpdate ? 'package.json update' : ''}${needsPackageUpdate && isRepoDirty ? ' + ' : ''}${isRepoDirty ? 'uncommitted changes' : ''}`); // Update package.json if needed if (needsPackageUpdate) { pkg.version = normalizedVersion; fs.writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n'); console.log(`Updated package.json: ${currentVersion} -> ${normalizedVersion}`); } // Stage all changes execSync('git add .', { stdio: 'inherit' }); // Commit const commitMessage = customMessage || (needsPackageUpdate ? `Version ${normalizedVersion}` : `Prepare for version ${normalizedVersion}`); execSync(`git commit -m "${commitMessage}"`, { stdio: 'inherit' }); console.log('Committed changes'); } else { console.log('Repository clean, package.json matches - tagging current commit'); } // 7. Tag the commit execSync(`git tag ${tagName}`, { stdio: 'inherit' }); console.log(`Created tag: ${tagName}`); console.log(''); console.log('Version created successfully!'); console.log(''); console.log('Next steps:'); console.log(` git push origin main --tags # Push the commit and tag`); } catch (error) { console.error('Error during version creation:', error.message); process.exit(1); } } const isDirectRun = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); if (isDirectRun) { main(); }