claude-buddy
Version:
Claude Buddy installation and setup script
1,063 lines (919 loc) • 32.6 kB
JavaScript
/**
* Updater Module
*
* Handles updating existing Claude Buddy installations while preserving
* user customizations through intelligent file merging and configuration migration.
*
* @module setup/lib/updater
*/
const fs = require('fs').promises;
const path = require('path');
const { createLogger } = require('./logger');
const { createTransaction, executeAction, commitTransaction } = require('./transaction');
const { checkPermissions } = require('./environment');
const { promptConfiguration } = require('./config-prompts');
const logger = createLogger('updater');
/**
* Update Error Class
*/
class UpdateError extends Error {
constructor(message, fromVersion, toVersion, details = {}) {
super(message);
this.name = 'UpdateError';
this.fromVersion = fromVersion;
this.toVersion = toVersion;
this.details = details;
}
}
/**
* Migration Error Class
*/
class MigrationError extends Error {
constructor(message, migrationId, details = {}) {
super(message);
this.name = 'MigrationError';
this.migrationId = migrationId;
this.details = details;
}
}
/**
* Backup Error Class
*/
class BackupError extends Error {
constructor(message, backupPath, details = {}) {
super(message);
this.name = 'BackupError';
this.backupPath = backupPath;
this.details = details;
}
}
/**
* Migration Registry
* Maps version transitions to migration functions
*/
const migrations = {
'1.0.0-to-1.1.0': {
description: 'Add persona auto-activation settings',
migrate: async (config) => {
if (!config.personas) {
config.personas = {
autoActivation: true,
confidenceThreshold: 0.7
};
}
return config;
}
}
// Additional migrations can be added here
};
/**
* Performs complete update process from one version to another
*
* @param {Object} options - Update options
* @param {string} options.targetDirectory - Target directory
* @param {string} options.fromVersion - Source version
* @param {string} options.toVersion - Target version
* @param {Object} options.manifest - Installation manifest
* @param {Array} options.components - Components to install
* @param {Object} options.existingMetadata - Existing installation metadata
* @param {boolean} options.preserveAll - Preserve all files (default: false)
* @param {boolean} options.mergeConfig - Merge configurations (default: true)
* @param {boolean} options.dryRun - Dry-run mode (default: false)
* @param {boolean} options.verbose - Verbose logging (default: false)
* @returns {Promise<Object>} Update result
*/
async function performUpdate(options) {
const {
targetDirectory,
fromVersion,
toVersion,
// manifest, // Reserved for future use
components,
existingMetadata,
// preserveAll = false, // Reserved for future use
mergeConfig = true,
isMigration = false,
dryRun = false,
verbose = false,
nonInteractive = false,
skipConfigPrompts = false
} = options;
const startTime = Date.now();
const result = {
success: false,
fromVersion,
toVersion,
updatedFiles: [],
preservedFiles: [],
migratedConfigs: [],
backupPath: '',
duration: 0,
warnings: [],
errors: [],
isMigration,
cleanedOldStructure: false
};
let transaction = null;
try {
logger.section(isMigration ? 'Migration Process (v2.x → v3.0)' : 'Update Process', verbose);
logger.info(`From: ${fromVersion}`, verbose);
logger.info(`To: ${toVersion}`, verbose);
logger.info(`Target: ${targetDirectory}`, verbose);
if (isMigration) {
logger.info('Mode: Migration (all framework files will be updated)', verbose);
}
// Check for downgrade
if (isDowngrade(fromVersion, toVersion)) {
logger.warn(`Warning: Downgrading from ${fromVersion} to ${toVersion}`, verbose);
result.warnings.push({
type: 'downgrade',
message: `Downgrading from ${fromVersion} to ${toVersion} may cause compatibility issues`
});
}
// Phase 1: Create backup (skip if project is in git)
if (!dryRun) {
// Check if project is in source control
const permissions = await checkPermissions(targetDirectory);
if (permissions.isGitRepo) {
logger.info('Project is in git - skipping backup (use git to revert if needed)', verbose);
result.backupPath = 'skipped-git-repo';
} else {
logger.progress('Creating backup', verbose);
const backupPath = await createBackup(targetDirectory, existingMetadata, verbose);
result.backupPath = backupPath;
logger.success(`Backup created: ${backupPath}`, verbose);
}
} else {
logger.info('Would create backup (unless in git repo)', verbose);
}
// Phase 2: Create transaction
if (!dryRun) {
logger.progress('Creating transaction', verbose);
transaction = await createTransaction({
operation: 'update',
fromVersion,
toVersion,
targetDirectory
});
}
// Phase 3: Detect user customizations
logger.progress('Detecting user customizations', verbose);
const customizations = await detectUserCustomizations(
targetDirectory,
existingMetadata,
isMigration,
verbose
);
if (dryRun || verbose) {
logger.info(`Found ${customizations.length} user customizations`, verbose);
if (verbose) {
customizations.forEach(custom => {
logger.debug(` - ${custom.file}`, verbose);
});
}
}
result.preservedFiles = customizations.map(c => c.file);
// Phase 4: Run migrations
logger.progress('Running migrations', verbose);
const migrationResult = await runMigrations(
fromVersion,
toVersion,
targetDirectory,
dryRun,
verbose
);
if (migrationResult.applied.length > 0) {
if (dryRun) {
logger.info(`Would apply ${migrationResult.applied.length} migrations`, verbose);
} else {
logger.success(`Applied ${migrationResult.applied.length} migrations`, verbose);
}
result.migratedConfigs = migrationResult.applied.map(m => m.id);
}
if (migrationResult.errors.length > 0) {
migrationResult.errors.forEach(error => {
result.errors.push({
type: 'migration_error',
message: error.message,
migrationId: error.migrationId
});
});
}
// Phase 5: Update framework files
logger.progress('Updating framework files', verbose);
for (const component of components) {
try {
const updateResult = await updateComponent(
component,
targetDirectory,
customizations,
transaction,
dryRun,
verbose
);
result.updatedFiles.push(...updateResult.updated);
if (dryRun) {
logger.info(`Would update ${component.name}: ${updateResult.updated.length} files`, verbose);
} else {
logger.success(`Updated ${component.name}: ${updateResult.updated.length} files`, verbose);
}
if (updateResult.skipped.length > 0 && verbose) {
logger.debug(`Skipped ${updateResult.skipped.length} customized files`, verbose);
}
} catch (error) {
if (component.type === 'required') {
throw error;
} else {
result.warnings.push({
type: 'component_update_failed',
message: `Failed to update optional component ${component.name}: ${error.message}`,
component: component.name
});
logger.warn(`Skipped ${component.name}: ${error.message}`, verbose);
}
}
}
// Phase 5.5: Update hooks.json configuration (interactive mode only)
if (!dryRun && !nonInteractive && !skipConfigPrompts && result.updatedComponents.includes('config')) {
logger.progress('Updating Claude Buddy configuration', verbose);
try {
const hooksJsonPath = path.join(targetDirectory, '.claude', 'hooks.json');
// Read existing hooks.json to get current config as defaults
const existingHooks = await fs.readJson(hooksJsonPath);
const existingConfig = existingHooks.config || null;
// Prompt user for configuration with existing values as defaults
const userConfig = await promptConfiguration('update', existingConfig);
// Merge user config into hooks.json config section
existingHooks.config = userConfig;
// Write updated hooks.json
await fs.writeJson(hooksJsonPath, existingHooks, { spaces: 2 });
// Log the configuration action in transaction
if (transaction) {
await executeAction(transaction, {
type: 'update',
path: '.claude/hooks.json',
reason: 'Update user configuration'
});
}
logger.success('Configuration updated', verbose);
} catch (error) {
result.warnings.push({
type: 'configuration_update_failed',
message: `Failed to update hooks.json configuration: ${error.message}`
});
logger.warn(`Configuration update skipped: ${error.message}`, verbose);
}
} else if (!dryRun && (nonInteractive || skipConfigPrompts)) {
logger.info('Configuration prompts skipped (preserving existing)', verbose);
} else if (dryRun) {
logger.info('Would prompt for configuration update', verbose);
}
// Phase 6: Merge configurations
if (mergeConfig && !dryRun) {
logger.progress('Merging configurations', verbose);
const configFiles = [
'.claude-buddy/buddy-config.json',
'.claude/hooks.json'
];
for (const configFile of configFiles) {
try {
const configPath = path.join(targetDirectory, configFile);
// Check if file exists
try {
await fs.access(configPath);
} catch {
// File doesn't exist, skip
continue;
}
const existingConfig = JSON.parse(await fs.readFile(configPath, 'utf8'));
// Load new config from component source directory (same logic as updateComponent)
const componentForConfig = components.find(comp => {
const fileName = path.basename(configFile);
return comp.filePatterns.includes(fileName);
});
if (!componentForConfig) {
// No component found for this config, preserve existing
logger.debug(`No component found for ${configFile}, preserving existing`, verbose);
continue;
}
const setupDir = path.join(__dirname, '..');
const sourcePath = path.join(setupDir, '..', componentForConfig.source);
const newConfigPath = path.join(sourcePath, path.basename(configFile));
let newConfig;
try {
newConfig = JSON.parse(await fs.readFile(newConfigPath, 'utf8'));
} catch {
// New config doesn't exist in source, preserve existing
logger.debug(`No source config at ${newConfigPath}, preserving existing`, verbose);
continue;
}
const mergeResult = await mergeConfigurations(
configFile,
newConfig,
existingConfig,
'shallow_merge',
verbose
);
if (mergeResult.conflicts.length > 0) {
logger.warn(`Configuration conflicts in ${configFile}:`, verbose);
mergeResult.conflicts.forEach(conflict => {
logger.debug(` ${conflict.path}: keeping user value`, verbose);
});
}
// Write merged configuration
await fs.writeFile(
configPath,
JSON.stringify(mergeResult.mergedContent, null, 2),
'utf8'
);
if (transaction) {
await executeAction(transaction, {
type: 'update',
path: configFile,
reason: 'Merge configuration'
});
}
logger.success(`Merged ${configFile}`, verbose);
} catch (error) {
logger.warn(`Failed to merge ${configFile}: ${error.message}`, verbose);
}
}
} else if (dryRun) {
logger.info('Would merge configurations', verbose);
}
// Phase 6.5: Clean up old v2.x structure (migration only)
if (isMigration && !dryRun) {
logger.progress('Cleaning up old v2.x structure', verbose);
const oldDir = path.join(targetDirectory, '.claude-buddy');
try {
// Safety check: Verify files were actually updated (confirms migration is working)
if (result.updatedFiles.length === 0) {
throw new Error('No files were updated, skipping cleanup for safety');
}
// Remove old directory completely
await fs.rm(oldDir, { recursive: true, force: true });
logger.success('Removed old .claude-buddy directory', verbose);
// Log to transaction
if (transaction) {
await executeAction(transaction, {
type: 'delete',
path: '.claude-buddy',
reason: 'Clean up v2.x structure after migration to v3.0'
});
}
result.cleanedOldStructure = true;
} catch (error) {
// Don't fail migration if cleanup fails
logger.warn(`Could not remove old .claude-buddy directory: ${error.message}`, verbose);
result.warnings.push({
type: 'cleanup_failed',
message: `Old .claude-buddy directory not removed: ${error.message}`
});
result.cleanedOldStructure = false;
}
} else if (isMigration && dryRun) {
logger.info('Would remove old .claude-buddy directory', verbose);
}
// Phase 7: Update metadata
if (!dryRun) {
logger.progress('Updating metadata', verbose);
await updateMetadata(
targetDirectory,
toVersion,
existingMetadata,
customizations,
transaction,
verbose
);
logger.success('Metadata updated', verbose);
} else {
logger.info('Would update metadata', verbose);
}
// Phase 8: Commit transaction
if (!dryRun && transaction) {
logger.progress('Committing transaction', verbose);
await commitTransaction(transaction);
logger.success('Transaction committed', verbose);
}
result.success = true;
result.duration = Date.now() - startTime;
logger.section('Update Complete', verbose);
logger.success(`Updated from ${fromVersion} to ${toVersion} in ${result.duration}ms`, verbose);
logger.info(`Updated: ${result.updatedFiles.length} files`, verbose);
logger.info(`Preserved: ${result.preservedFiles.length} customizations`, verbose);
return result;
} catch (error) {
logger.error(`Update failed: ${error.message}`, verbose);
// Provide manual recovery instructions
if (!dryRun && transaction) {
try {
const permissions = await checkPermissions(targetDirectory);
logger.section('Manual Recovery Options', verbose);
if (permissions.isGitRepo) {
logger.info('Git repository detected:', verbose);
logger.info(' - Use "git status" to see changes', verbose);
logger.info(' - Use "git diff" to review changes', verbose);
logger.info(' - Use "git reset HEAD" to unstage changes', verbose);
logger.info(' - Use "git checkout -- <file>" to discard changes', verbose);
} else if (result.backupPath && result.backupPath !== 'skipped-git-repo') {
logger.info('Backup available:', verbose);
logger.info(` - Restore from: ${result.backupPath}`, verbose);
logger.info(' - Copy files back to project directory', verbose);
} else {
logger.info('Manual recovery:', verbose);
logger.info(' - Restore files manually if needed', verbose);
logger.info(' - Update state has been partially modified', verbose);
}
logger.info(`Transaction log: .claude/logs/${transaction.transactionId}.json`, verbose);
} catch (recoveryError) {
logger.warn(`Could not determine recovery options: ${recoveryError.message}`, verbose);
}
}
result.errors.push({
type: 'update_failed',
message: error.message
});
result.duration = Date.now() - startTime;
return result;
}
}
/**
* Detects files modified by users since installation
*
* @param {string} targetDirectory - Base directory
* @param {Object} metadata - Existing installation metadata
* @param {boolean} isMigration - Whether this is a v2.x to v3.0 migration
* @param {boolean} verbose - Verbose logging
* @returns {Promise<Array>} User customizations
*/
async function detectUserCustomizations(targetDirectory, metadata, isMigration, verbose) {
const customizations = [];
// During migration from v2.x to v3.0, skip framework file preservation
// We need to update ALL framework files to the new structure
if (isMigration) {
logger.info('Migration mode: updating all framework files to v3.0 structure', verbose);
// Don't mark any files for preservation during migration
return customizations;
}
if (!metadata || !metadata.installDate) {
logger.warn('No installation metadata found, assuming no customizations', verbose);
return customizations;
}
const installTime = new Date(metadata.installDate);
// Check for user customizations in metadata
if (metadata.userCustomizations && metadata.userCustomizations.length > 0) {
customizations.push(...metadata.userCustomizations);
}
// Check for custom skills in v3.0 structure
const skillsDir = path.join(targetDirectory, '.claude', 'skills', 'personas');
try {
const files = await fs.readdir(skillsDir);
for (const file of files) {
if (file.startsWith('custom-') || file.includes('user-')) {
const filePath = path.join(skillsDir, file);
const stats = await fs.stat(filePath);
if (stats.mtime > installTime) {
customizations.push({
file: path.join('.claude', 'skills', 'personas', file),
createdDate: stats.birthtime?.toISOString() || stats.mtime.toISOString(),
lastModified: stats.mtime.toISOString(),
description: 'Custom skill',
preserveOnUpdate: true
});
}
}
}
} catch (error) {
// Directory doesn't exist or not accessible
logger.debug(`Could not scan skills directory: ${error.message}`, verbose);
}
// Check for modified framework files (v3.0 paths only)
const frameworkFiles = [
'.claude/hooks.json',
'.claude/CLAUDE.md'
];
for (const file of frameworkFiles) {
const filePath = path.join(targetDirectory, file);
try {
const stats = await fs.stat(filePath);
if (stats.mtime > installTime) {
customizations.push({
file,
createdDate: metadata.installDate,
lastModified: stats.mtime.toISOString(),
description: 'Modified configuration',
preserveOnUpdate: true
});
}
} catch (error) {
// File doesn't exist
logger.debug(`File not found: ${file}`, verbose);
}
}
return customizations;
}
/**
* Merges new configuration with existing user configuration
*
* @param {string} configFile - Configuration file path
* @param {Object} newConfig - New configuration
* @param {Object} existingConfig - Existing configuration
* @param {string} strategy - Merge strategy
* @param {boolean} verbose - Verbose logging
* @returns {Promise<Object>} Merge result
*/
async function mergeConfigurations(configFile, newConfig, existingConfig, strategy, verbose) {
const result = {
filePath: configFile,
strategy,
isUserModified: true,
conflicts: [],
mergedContent: null,
backupPath: '',
requiresUserInput: false
};
// Detect conflicts
for (const [key, newValue] of Object.entries(newConfig)) {
if (Object.prototype.hasOwnProperty.call(existingConfig, key)) {
const userValue = existingConfig[key];
if (JSON.stringify(userValue) !== JSON.stringify(newValue)) {
result.conflicts.push({
path: key,
userValue,
newValue,
resolution: 'keep_user'
});
}
}
}
// Apply merge strategy
switch (strategy) {
case 'keep_user':
result.mergedContent = { ...existingConfig };
break;
case 'use_new':
result.mergedContent = { ...newConfig };
break;
case 'shallow_merge':
// New defaults, overridden by user values
result.mergedContent = { ...newConfig, ...existingConfig };
break;
case 'deep_merge':
result.mergedContent = deepMerge(newConfig, existingConfig);
break;
default:
throw new Error(`Unknown merge strategy: ${strategy}`);
}
logger.debug(`Merged ${configFile} using ${strategy} strategy`, verbose);
return result;
}
/**
* Deep merge utility
*
* @param {Object} target - Target object
* @param {Object} source - Source object
* @returns {Object} Merged object
*/
function deepMerge(target, source) {
const result = { ...target };
for (const [key, value] of Object.entries(source)) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
if (result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) {
result[key] = deepMerge(result[key], value);
} else {
result[key] = value;
}
} else {
result[key] = value;
}
}
return result;
}
/**
* Runs version-specific migrations
*
* @param {string} fromVersion - Source version
* @param {string} toVersion - Target version
* @param {string} targetDirectory - Base directory
* @param {boolean} dryRun - Dry-run mode
* @param {boolean} verbose - Verbose logging
* @returns {Promise<Object>} Migration result
*/
async function runMigrations(fromVersion, toVersion, targetDirectory, dryRun, verbose) {
const result = {
applied: [],
skipped: [],
errors: []
};
const migrationPath = getMigrationPath(fromVersion, toVersion);
for (const migrationId of migrationPath) {
const migration = migrations[migrationId];
if (!migration) {
result.skipped.push({
id: migrationId,
description: 'Migration not found',
applied: false
});
logger.debug(`Migration ${migrationId} not found, skipping`, verbose);
continue;
}
try {
if (dryRun) {
result.applied.push({
id: migrationId,
description: migration.description,
applied: false
});
logger.debug(`Would apply migration: ${migrationId}`, verbose);
continue;
}
// Load configuration
const configPath = path.join(targetDirectory, '.claude-buddy', 'buddy-config.json');
const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
// Apply migration
const migratedConfig = await migration.migrate(config, targetDirectory);
// Save migrated configuration
await fs.writeFile(configPath, JSON.stringify(migratedConfig, null, 2), 'utf8');
result.applied.push({
id: migrationId,
description: migration.description,
applied: true
});
logger.debug(`Applied migration: ${migrationId}`, verbose);
} catch (error) {
result.errors.push({
migrationId,
message: error.message
});
logger.error(`Migration ${migrationId} failed: ${error.message}`, verbose);
}
}
return result;
}
/**
* Creates backup of existing installation
*
* @param {string} targetDirectory - Base directory
* @param {Object} metadata - Installation metadata
* @param {boolean} verbose - Verbose logging
* @returns {Promise<string>} Backup path
*/
async function createBackup(targetDirectory, metadata, verbose) {
const timestamp = new Date().toISOString().replace(/:/g, '-').replace(/\..+/, '');
const backupDir = path.join(targetDirectory, '.claude', 'backups', `backup-${timestamp}`);
try {
// Create backup directory
await fs.mkdir(backupDir, { recursive: true });
// Backup directories
const dirsToBackup = [
'.claude-buddy',
'.claude',
'directive'
];
for (const dir of dirsToBackup) {
const sourcePath = path.join(targetDirectory, dir);
const destPath = path.join(backupDir, dir);
try {
// Check if source exists
await fs.access(sourcePath);
// Copy directory recursively
await copyDirectory(sourcePath, destPath);
logger.debug(`Backed up ${dir}`, verbose);
} catch (error) {
// Directory doesn't exist, skip
logger.debug(`Directory ${dir} not found, skipping backup`, verbose);
}
}
// Clean old backups (keep last 3)
await cleanOldBackups(path.join(targetDirectory, '.claude', 'backups'), 3, verbose);
logger.debug(`Backup created: ${backupDir}`, verbose);
return backupDir;
} catch (error) {
throw new BackupError(
`Failed to create backup: ${error.message}`,
backupDir,
{ error: error.message }
);
}
}
/**
* Updates a component while preserving customizations
*
* @param {Object} component - Component to update
* @param {string} targetDirectory - Base directory
* @param {Array} customizations - User customizations
* @param {Object} transaction - Active transaction
* @param {boolean} dryRun - Dry-run mode
* @param {boolean} verbose - Verbose logging
* @returns {Promise<Object>} Update result
*/
async function updateComponent(component, targetDirectory, customizations, transaction, dryRun, verbose) {
const result = {
component: component.name,
updated: [],
skipped: []
};
const { glob } = require('glob');
const setupDir = path.join(__dirname, '..'); // setup/lib/.. = setup/
const sourcePath = path.join(setupDir, component.source); // setup/ + dist/.claude/ = setup/dist/.claude/
const targetPath = path.join(targetDirectory, component.target);
// Find files in component
const files = [];
for (const pattern of component.filePatterns) {
const matchedFiles = await glob(pattern, {
cwd: sourcePath,
nodir: true,
dot: true
});
files.push(...matchedFiles);
}
// Update each file if not customized
for (const file of files) {
const relativeFilePath = path.join(component.target, file);
// Check if file is a user customization
if (shouldUpdateFile(relativeFilePath, customizations)) {
if (!dryRun) {
const sourceFile = path.join(sourcePath, file);
const targetFile = path.join(targetPath, file);
// Create parent directory if needed
const targetDir = path.dirname(targetFile);
await fs.mkdir(targetDir, { recursive: true });
// Copy file
await fs.copyFile(sourceFile, targetFile);
// Set permissions for executable files (Unix only)
if (process.platform !== 'win32' && file.endsWith('.py')) {
await fs.chmod(targetFile, 0o755);
}
// Log to transaction
if (transaction) {
await executeAction(transaction, {
type: 'update',
path: relativeFilePath,
component: component.name,
reason: `Update ${component.name} file`
});
}
}
result.updated.push(relativeFilePath);
} else {
result.skipped.push(relativeFilePath);
logger.debug(`Skipped customized file: ${relativeFilePath}`, verbose);
}
}
return result;
}
/**
* Updates installation metadata
*
* @param {string} targetDirectory - Base directory
* @param {string} toVersion - Target version
* @param {Object} existingMetadata - Existing metadata
* @param {Array} customizations - User customizations
* @param {Object} transaction - Active transaction
* @param {boolean} verbose - Verbose logging
* @returns {Promise<void>}
*/
async function updateMetadata(targetDirectory, toVersion, existingMetadata, customizations, transaction, verbose) {
const metadata = {
...existingMetadata,
version: toVersion,
lastUpdateDate: new Date().toISOString(),
userCustomizations: customizations
};
// Add transaction record
if (!metadata.transactionHistory) {
metadata.transactionHistory = [];
}
metadata.transactionHistory.push({
transactionId: transaction ? transaction.transactionId : 'dry-run',
operation: 'update',
version: toVersion,
timestamp: new Date().toISOString(),
status: 'completed'
});
// Write metadata (v3.0: moved to .claude directory)
const metadataPath = path.join(targetDirectory, '.claude', 'install-metadata.json');
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf8');
if (transaction) {
await executeAction(transaction, {
type: 'update',
path: '.claude/install-metadata.json',
reason: 'Update installation metadata'
});
}
logger.debug('Updated install-metadata.json', verbose);
}
/**
* Utility: Compare semantic versions
*
* @param {string} v1 - First version
* @param {string} v2 - Second version
* @returns {number} -1 if v1 < v2, 0 if equal, 1 if v1 > v2
*/
function compareVersions(v1, v2) {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if (parts1[i] > parts2[i]) return 1;
if (parts1[i] < parts2[i]) return -1;
}
return 0;
}
/**
* Utility: Check if update is a downgrade
*
* @param {string} fromVersion - Source version
* @param {string} toVersion - Target version
* @returns {boolean} True if downgrade
*/
function isDowngrade(fromVersion, toVersion) {
return compareVersions(toVersion, fromVersion) < 0;
}
/**
* Utility: Check if file should be updated
*
* @param {string} filePath - File path
* @param {Array} customizations - User customizations
* @returns {boolean} True if should update
*/
function shouldUpdateFile(filePath, customizations) {
return !customizations.some(custom => custom.file === filePath && custom.preserveOnUpdate);
}
/**
* Utility: Get migration path
*
* @param {string} fromVersion - Source version
* @param {string} toVersion - Target version
* @returns {Array<string>} Migration IDs
*/
function getMigrationPath(fromVersion, toVersion) {
const migrationIds = [];
const migrationId = `${fromVersion}-to-${toVersion}`;
if (migrations[migrationId]) {
migrationIds.push(migrationId);
}
return migrationIds;
}
/**
* Utility: Copy directory recursively
*
* @param {string} source - Source directory
* @param {string} dest - Destination directory
* @returns {Promise<void>}
*/
async function copyDirectory(source, dest) {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(source, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = path.join(source, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirectory(sourcePath, destPath);
} else {
await fs.copyFile(sourcePath, destPath);
}
}
}
/**
* Utility: Clean old backups
*
* @param {string} backupRoot - Backup root directory
* @param {number} keepCount - Number of backups to keep
* @param {boolean} verbose - Verbose logging
* @returns {Promise<void>}
*/
async function cleanOldBackups(backupRoot, keepCount, verbose) {
try {
const entries = await fs.readdir(backupRoot, { withFileTypes: true });
const backups = entries
.filter(entry => entry.isDirectory() && entry.name.startsWith('backup-'))
.map(entry => ({
name: entry.name,
path: path.join(backupRoot, entry.name),
timestamp: entry.name.replace('backup-', '')
}))
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
// Delete old backups
for (let i = keepCount; i < backups.length; i++) {
await fs.rm(backups[i].path, { recursive: true, force: true });
logger.debug(`Deleted old backup: ${backups[i].name}`, verbose);
}
} catch (error) {
logger.debug(`Failed to clean old backups: ${error.message}`, verbose);
}
}
module.exports = {
performUpdate,
detectUserCustomizations,
mergeConfigurations,
deepMerge,
runMigrations,
createBackup,
UpdateError,
MigrationError,
BackupError,
compareVersions,
isDowngrade,
shouldUpdateFile,
getMigrationPath
};