update-version.mjsโข14 kB
#!/usr/bin/env node
/**
* Smart Version Update Script
*
* Updates version numbers throughout the codebase while preserving:
* - Version history sections
* - Changelog entries
* - Example/documentation that shouldn't change
*
* Usage:
* npm run update:version -- 1.6.5
* npm run update:version -- 1.6.5 --notes "Added portfolio sync fix"
* npm run update:version -- 1.6.5 --dry-run
*/
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { glob } from 'glob';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Parse command line arguments
const args = process.argv.slice(2);
const newVersion = args[0];
const isDryRun = args.includes('--dry-run');
const notesIndex = args.indexOf('--notes');
const releaseNotes = notesIndex !== -1 && args[notesIndex + 1] ? args[notesIndex + 1] : '';
// Security: Validate release notes length to prevent injection attacks
if (releaseNotes.length > 1000) {
console.error('โ Release notes too long (max 1000 characters)');
process.exit(1);
}
if (!newVersion || !newVersion.match(/^\d+\.\d+\.\d+(-[\w\.]+)?$/)) {
console.error('โ Please provide a valid semantic version (e.g., 1.6.5 or 1.6.5-beta.1)');
console.error('Usage: npm run update:version -- <version> [--notes "Release notes"] [--dry-run]');
process.exit(1);
}
// Get current version from package.json
const packageJsonPath = path.join(path.dirname(__dirname), 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
const currentVersion = packageJson.version;
if (currentVersion === newVersion) {
console.log(`โน๏ธ Version is already ${newVersion}`);
process.exit(0);
}
// Helper function to escape special regex characters
function escapeRegExp(string) {
// Escape all special regex characters including backslashes
return string.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
console.log(`\n๐ Updating version from ${currentVersion} to ${newVersion}`);
if (isDryRun) {
console.log('๐งช DRY RUN MODE - No files will be changed\n');
}
// Configuration for files to update
const updateConfigs = [
{
name: 'package.json',
pattern: /"version":\s*"[^"]+"/,
replacement: `"version": "${newVersion}"`,
required: true
},
{
name: 'package-lock.json',
updates: [
{
// Only update root package version in first occurrence after package name
pattern: /("name":\s*"@dollhousemcp\/mcp-server",[\s\S]*?"version":\s*")[^"]+"/,
replacement: `$1${newVersion}"`,
once: true
},
{
// Update packages section if it exists
pattern: /("packages":\s*{[\s\S]*?"node_modules\/@dollhousemcp\/mcp-server":\s*{[\s\S]*?"version":\s*")[^"]+"/,
replacement: `$1${newVersion}"`,
once: true
}
],
required: true
},
{
name: 'README.md',
updates: [
{
// Update version badge
pattern: /\[Version-v[\d\.]+(-[\w\.]+)?-/g,
replacement: `[Version-v${newVersion}-`
},
{
// Update current version mentions (but not in history/changelog)
pattern: new RegExp(`\\bv?${escapeRegExp(currentVersion)}\\b`, 'g'),
replacement: (match) => match.startsWith('v') ? `v${newVersion}` : newVersion,
// Skip if line contains certain keywords
skipLines: /changelog|history|previous|released|was |fixed in|since|before|after|from [\d\.]+ to|migrat|v[\d\.]+ \(/i
},
{
// Update installation instructions
pattern: /@dollhousemcp\/mcp-server@[\d\.]+(-[\w\.]+)?/g,
replacement: `@dollhousemcp/mcp-server@${newVersion}`
}
]
},
{
name: 'CHANGELOG.md',
updates: [
{
// Add new version entry at the top (after the header)
pattern: /(# Changelog\n+)/,
replacement: (match, p1, offset, string) => {
// Check if version already exists
if (string.includes(`## [${newVersion}]`)) {
console.log(' โญ๏ธ Version already exists in CHANGELOG.md');
return match; // Don't add duplicate
}
return `${p1}## [${newVersion}] - ${new Date().toISOString().split('T')[0]}${releaseNotes ? `\n\n${releaseNotes}` : '\n\n- Version bump'}\n\n`;
},
once: true // Only replace first occurrence
}
]
},
{
name: 'docs/**/*.md',
glob: true,
updates: [
{
// Update version references in documentation
pattern: new RegExp(`(?:Version|v)\\s*${escapeRegExp(currentVersion)}\\b`, 'g'),
replacement: (match) => match.replace(currentVersion, newVersion),
// Skip historical references
skipLines: /changelog|history|previous|released|deprecated|legacy|old version|upgrade from|PR #|Issue #|commit|merged/i
}
]
},
{
name: 'docker-compose.yml',
pattern: new RegExp(`dollhousemcp/mcp-server:${escapeRegExp(currentVersion)}`, 'g'),
replacement: `dollhousemcp/mcp-server:${newVersion}`,
optional: true // Don't fail if file doesn't exist
},
{
name: 'Dockerfile',
updates: [
{
// Update LABEL version
pattern: /LABEL version="[\d\.]+(-[\w\.]+)?"/,
replacement: `LABEL version="${newVersion}"`
}
],
optional: true
}
];
// Security: Helper function to validate file paths and prevent directory traversal
function validateFilePath(filePath, basePath) {
const normalizedPath = path.normalize(filePath);
// Check for path traversal attempts
if (normalizedPath.includes('..') || path.isAbsolute(normalizedPath)) {
throw new Error(`Path traversal detected: ${filePath}`);
}
const resolved = path.resolve(basePath, normalizedPath);
const resolvedBase = path.resolve(basePath);
// Ensure the resolved path is within the base path
if (!resolved.startsWith(resolvedBase)) {
throw new Error(`Path traversal detected: ${filePath}`);
}
return resolved;
}
// Helper function to update a single file
function updateFile(filePath, config) {
try {
// Security: Validate path to prevent directory traversal
const basePath = path.dirname(__dirname);
const fullPath = validateFilePath(filePath, basePath);
if (!fs.existsSync(fullPath)) {
if (config.createIfMissing && config.defaultContent) {
if (!isDryRun) {
// Create directory if needed
const dir = path.dirname(fullPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(fullPath, config.defaultContent);
}
console.log(` โ
Created ${filePath}`);
return true;
} else if (config.optional) {
return false;
} else if (config.required) {
console.error(` โ Required file not found: ${filePath}`);
return false;
}
return false;
}
// Security: Check file size to prevent DoS attacks
const stats = fs.statSync(fullPath);
const maxFileSize = 10 * 1024 * 1024; // 10MB limit
if (stats.size > maxFileSize) {
console.error(` โ File too large: ${filePath} (${Math.round(stats.size / 1024 / 1024)}MB, max 10MB)`);
return false;
}
let content = fs.readFileSync(fullPath, 'utf-8');
const originalContent = content;
let changesMade = false;
if (config.updates) {
// Multiple update patterns for this file
for (const update of config.updates) {
if (update.skipLines) {
// Process line by line for selective updates
const lines = content.split('\n');
const updatedLines = lines.map(line => {
if (update.skipLines.test(line)) {
return line; // Skip this line
}
if (typeof update.replacement === 'function') {
return line.replace(update.pattern, update.replacement);
}
return line.replace(update.pattern, update.replacement);
});
content = updatedLines.join('\n');
} else if (update.requireContext) {
// Only replace if context matches
const lines = content.split('\n');
const updatedLines = lines.map(line => {
if (update.requireContext.test(line)) {
return line.replace(update.pattern, update.replacement);
}
return line;
});
content = updatedLines.join('\n');
} else if (update.once) {
// Replace only first occurrence
content = content.replace(update.pattern, update.replacement);
} else {
// Standard replacement (works for both function and string replacements)
content = content.replace(update.pattern, update.replacement);
}
}
} else if (config.pattern) {
// Single pattern update
if (config.multiple) {
content = content.replace(new RegExp(config.pattern, 'g'), config.replacement);
} else {
content = content.replace(config.pattern, config.replacement);
}
}
changesMade = content !== originalContent;
if (changesMade) {
if (!isDryRun) {
fs.writeFileSync(fullPath, content);
}
console.log(` โ
Updated ${filePath}`);
return true;
} else {
console.log(` โญ๏ธ No changes needed in ${filePath}`);
return false;
}
} catch (error) {
console.error(` โ Error updating ${filePath}: ${error.message}`);
return false;
}
}
// Helper function to handle glob patterns
async function handleGlobPattern(pattern, config) {
const basePath = path.dirname(__dirname);
// Security: Validate pattern to prevent directory traversal
const normalizedPattern = path.normalize(pattern);
if (normalizedPattern.includes('..') || path.isAbsolute(normalizedPattern)) {
throw new Error(`Path traversal detected in pattern: ${pattern}`);
}
const fullPattern = path.join(basePath, normalizedPattern);
// Get files matching the pattern
const files = await glob(fullPattern);
// Security: Limit number of files to prevent DoS
if (files.length > 1000) {
console.error(`โ Too many files matched (${files.length}). Maximum is 1000.`);
process.exit(1);
}
let updated = 0;
for (const file of files) {
const relativePath = path.relative(basePath, file);
if (updateFile(relativePath, config)) {
updated++;
}
}
return updated;
}
// Main update process
async function main() {
let totalUpdated = 0;
let totalErrors = 0;
for (const config of updateConfigs) {
if (config.glob) {
const updated = await handleGlobPattern(config.name, config);
totalUpdated += updated;
} else {
if (updateFile(config.name, config)) {
totalUpdated++;
} else if (config.required && !fs.existsSync(path.join(path.dirname(__dirname), config.name))) {
totalErrors++;
}
}
}
console.log('\n' + '='.repeat(60));
if (totalErrors > 0) {
console.log(`โ Version update failed with ${totalErrors} errors`);
process.exit(1);
}
if (isDryRun) {
console.log(`๐งช DRY RUN COMPLETE: Would update ${totalUpdated} files`);
console.log(`Run without --dry-run to apply changes`);
} else {
console.log(`โ
Version updated to ${newVersion} in ${totalUpdated} files`);
// Update package-lock.json properly using npm
console.log('\n๐ฆ Updating package-lock.json with npm...');
try {
// Security: Use resolved path for cwd to ensure we're in the right directory
const projectRoot = path.resolve(path.dirname(__dirname));
execSync('npm install --package-lock-only', { stdio: 'inherit', cwd: projectRoot });
} catch (error) {
console.error('โ ๏ธ Failed to update package-lock.json with npm:', error.message);
// Don't continue silently - this is important for version consistency
process.exit(1);
}
// Generate version file if script exists
const versionScriptPath = path.join(path.dirname(__dirname), 'scripts/generate-version.js');
if (fs.existsSync(versionScriptPath)) {
console.log('\n๐๏ธ Generating version info...');
try {
// Security: Use resolved path for cwd
const projectRoot = path.resolve(path.dirname(__dirname));
execSync('node scripts/generate-version.js', { stdio: 'inherit', cwd: projectRoot });
} catch (error) {
console.error('โ ๏ธ Failed to generate version info:', error.message);
// Non-critical, so we can continue
}
}
// Build README files if build script exists
const readmeBuildScriptPath = path.join(path.dirname(__dirname), 'scripts/build-readme.js');
if (fs.existsSync(readmeBuildScriptPath)) {
console.log('\n๐ Building modular README files...');
try {
// Security: Use resolved path for cwd
const projectRoot = path.resolve(path.dirname(__dirname));
// Build both NPM and GitHub versions
execSync('npm run build:readme', { stdio: 'inherit', cwd: projectRoot });
console.log(' โ
README files built successfully');
} catch (error) {
console.error('โ ๏ธ Failed to build README files:', error.message);
// Non-critical, so we can continue but warn
console.log(' โน๏ธ You may need to run "npm run build:readme" manually');
}
}
console.log('\n๐ Next steps:');
console.log(` 1. Review the changes: git diff`);
console.log(` 2. Update CHANGELOG.md with detailed release notes`);
console.log(` 3. Commit: git add -A && git commit -m "chore: bump version to ${newVersion}"`);
console.log(` 4. Tag the release: git tag v${newVersion}`);
console.log(` 5. Push: git push && git push --tags`);
}
}
// Run the main process
main().catch(error => {
console.error('โ Fatal error:', error);
process.exit(1);
});