update-dependencies.js
1 #!/usr/bin/env node 2 import { execSync } from 'child_process'; 3 import fs from 'fs'; 4 import path from 'path'; 5 import { fileURLToPath } from 'url'; 6 7 const __filename = fileURLToPath(import.meta.url); 8 const __dirname = path.dirname(__filename); 9 10 // Parse command-line arguments 11 const args = process.argv.slice(2); 12 const options = { 13 level: 'minors', // patches, minors, majors, all 14 dryRun: false, 15 noCommit: false, 16 package: null, 17 verbose: false, 18 }; 19 20 for (const arg of args) { 21 if (arg.startsWith('--level=')) { 22 options.level = arg.split('=')[1]; 23 } else if (arg === '--dry-run') { 24 options.dryRun = true; 25 } else if (arg === '--no-commit') { 26 options.noCommit = true; 27 } else if (arg.startsWith('--package=')) { 28 options.package = arg.split('=')[1]; 29 } else if (arg === '--verbose' || arg === '-v') { 30 options.verbose = true; 31 } 32 } 33 34 const log = { 35 info: msg => console.log(`[INFO] ${msg}`), 36 success: msg => console.log(`[SUCCESS] ${msg}`), 37 warn: msg => console.log(`[WARN] ${msg}`), 38 error: msg => console.error(`[ERROR] ${msg}`), 39 debug: msg => options.verbose && console.log(`[DEBUG] ${msg}`), 40 }; 41 42 // Utility to run commands with output 43 function runCommand(command, options = {}) { 44 const { silent = false, ignoreError = false } = options; 45 46 try { 47 log.debug(`Running: ${command}`); 48 const output = execSync(command, { 49 encoding: 'utf8', 50 stdio: silent ? 'pipe' : 'inherit', 51 cwd: path.join(__dirname, '..'), 52 }); 53 return { success: true, output }; 54 } catch (error) { 55 if (!ignoreError) { 56 log.error(`Command failed: ${command}`); 57 log.error(error.message); 58 } 59 return { success: false, error }; 60 } 61 } 62 63 // Check if working directory is clean 64 function isWorkingDirectoryClean() { 65 const result = runCommand('git status --porcelain', { silent: true }); 66 if (!result.success) { 67 log.warn('Could not check git status'); 68 return false; 69 } 70 return result.output.trim() === ''; 71 } 72 73 // Backup package files 74 function backupPackageFiles() { 75 const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); 76 const backupDir = path.join(__dirname, '..', '.dependency-updates'); 77 78 if (!fs.existsSync(backupDir)) { 79 fs.mkdirSync(backupDir, { recursive: true }); 80 } 81 82 const packageJson = path.join(__dirname, '..', 'package.json'); 83 const packageLock = path.join(__dirname, '..', 'package-lock.json'); 84 85 const backupPackageJson = path.join(backupDir, `package.json.${timestamp}`); 86 const backupPackageLock = path.join(backupDir, `package-lock.json.${timestamp}`); 87 88 fs.copyFileSync(packageJson, backupPackageJson); 89 if (fs.existsSync(packageLock)) { 90 fs.copyFileSync(packageLock, backupPackageLock); 91 } 92 93 log.info(`Backups created in ${backupDir}`); 94 return { backupPackageJson, backupPackageLock }; 95 } 96 97 // Get outdated packages 98 function getOutdatedPackages() { 99 log.info('Checking for outdated packages...'); 100 const result = runCommand('npm outdated --json', { 101 silent: true, 102 ignoreError: true, 103 }); 104 105 if (!result.output || result.output.trim() === '') { 106 return {}; 107 } 108 109 try { 110 return JSON.parse(result.output); 111 } catch (error) { 112 log.error('Failed to parse npm outdated output'); 113 return {}; 114 } 115 } 116 117 // Categorize update by semver type 118 function categorizeUpdate(current, latest) { 119 const currentParts = current.split('.').map(Number); 120 const latestParts = latest.split('.').map(Number); 121 122 if (latestParts[0] > currentParts[0]) return 'major'; 123 if (latestParts[1] > currentParts[1]) return 'minor'; 124 if (latestParts[2] > currentParts[2]) return 'patch'; 125 126 return 'unknown'; 127 } 128 129 // Filter packages by update level 130 function filterPackagesByLevel(outdated, level) { 131 const filtered = {}; 132 133 for (const [name, info] of Object.entries(outdated)) { 134 const updateType = categorizeUpdate(info.current, info.latest); 135 136 if (level === 'all') { 137 filtered[name] = { ...info, updateType }; 138 } else if (level === 'majors' && updateType === 'major') { 139 filtered[name] = { ...info, updateType }; 140 } else if (level === 'minors' && ['major', 'minor'].includes(updateType)) { 141 filtered[name] = { ...info, updateType }; 142 } else if (level === 'patches' && updateType === 'patch') { 143 filtered[name] = { ...info, updateType }; 144 } 145 } 146 147 return filtered; 148 } 149 150 // Run tests 151 function runTests() { 152 log.info('Running tests...'); 153 const result = runCommand('npm test', { silent: false }); 154 return result.success; 155 } 156 157 // Run linting 158 function runLint() { 159 log.info('Running lint:fix...'); 160 const result = runCommand('npm run lint:fix', { silent: false }); 161 return result.success; 162 } 163 164 // Update a single package 165 function updatePackage(packageName, version) { 166 log.info(`Updating ${packageName} to ${version}...`); 167 const result = runCommand(`npm install ${packageName}@${version}`, { 168 silent: false, 169 }); 170 return result.success; 171 } 172 173 // Rollback package files 174 function rollbackPackageFiles(backup) { 175 log.warn('Rolling back package files...'); 176 const packageJson = path.join(__dirname, '..', 'package.json'); 177 const packageLock = path.join(__dirname, '..', 'package-lock.json'); 178 179 fs.copyFileSync(backup.backupPackageJson, packageJson); 180 if (fs.existsSync(backup.backupPackageLock)) { 181 fs.copyFileSync(backup.backupPackageLock, packageLock); 182 } 183 184 // Re-install to sync node_modules 185 runCommand('npm install', { silent: true }); 186 log.success('Rollback complete'); 187 } 188 189 // Create git commit for successful updates 190 function createCommit(updates) { 191 const updateList = updates.map(u => ` - ${u.name}: ${u.from} → ${u.to}`).join('\n'); 192 193 const message = `chore: update dependencies (automated) 194 195 Updated packages: 196 ${updateList} 197 198 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>`; 199 200 const escapedMessage = message.replace(/'/g, "'\\''"); 201 202 runCommand(`git add package.json package-lock.json && git commit -m '${escapedMessage}'`, { 203 silent: false, 204 }); 205 206 log.success('Commit created'); 207 } 208 209 // Main execution 210 async function main() { 211 log.info('=== Automated Dependency Updater ==='); 212 log.info(`Level: ${options.level}`); 213 log.info(`Dry run: ${options.dryRun}`); 214 log.info(`Auto-commit: ${!options.noCommit}`); 215 216 if (options.package) { 217 log.info(`Target package: ${options.package}`); 218 } 219 220 // Safety checks 221 if (!options.dryRun && !isWorkingDirectoryClean()) { 222 log.error('Working directory is not clean. Commit or stash changes first.'); 223 process.exit(1); 224 } 225 226 // Get outdated packages 227 const outdated = getOutdatedPackages(); 228 const outdatedCount = Object.keys(outdated).length; 229 230 if (outdatedCount === 0) { 231 log.success('All dependencies are up to date!'); 232 return; 233 } 234 235 log.info(`Found ${outdatedCount} outdated packages`); 236 237 // Filter by level and specific package 238 let filtered = filterPackagesByLevel(outdated, options.level); 239 240 if (options.package) { 241 if (filtered[options.package]) { 242 filtered = { [options.package]: filtered[options.package] }; 243 } else { 244 log.error( 245 `Package ${options.package} not found in outdated packages at level ${options.level}` 246 ); 247 process.exit(1); 248 } 249 } 250 251 const filteredCount = Object.keys(filtered).length; 252 253 if (filteredCount === 0) { 254 log.info(`No packages to update at level: ${options.level}`); 255 return; 256 } 257 258 // Display what will be updated 259 log.info(`\nPackages to update (${filteredCount}):`); 260 for (const [name, info] of Object.entries(filtered)) { 261 log.info(` - ${name}: ${info.current} → ${info.latest} (${info.updateType})`); 262 } 263 264 if (options.dryRun) { 265 log.info('\n[DRY RUN] No changes made'); 266 return; 267 } 268 269 // Backup before starting 270 const backup = backupPackageFiles(); 271 272 // Track results 273 const results = { 274 successful: [], 275 failed: [], 276 }; 277 278 // Update packages one by one 279 for (const [name, info] of Object.entries(filtered)) { 280 log.info(`\n--- Updating ${name} ---`); 281 282 const updateSuccess = updatePackage(name, info.latest); 283 if (!updateSuccess) { 284 log.error(`Failed to update ${name}`); 285 results.failed.push({ 286 name, 287 from: info.current, 288 to: info.latest, 289 reason: 'npm install failed', 290 }); 291 rollbackPackageFiles(backup); 292 continue; 293 } 294 295 // Run lint 296 const lintSuccess = runLint(); 297 if (!lintSuccess) { 298 log.warn(`Lint failed after updating ${name} (continuing anyway)`); 299 } 300 301 // Run tests 302 const testsSuccess = runTests(); 303 if (!testsSuccess) { 304 log.error(`Tests failed after updating ${name}`); 305 results.failed.push({ 306 name, 307 from: info.current, 308 to: info.latest, 309 reason: 'tests failed', 310 }); 311 rollbackPackageFiles(backup); 312 continue; 313 } 314 315 log.success(`Successfully updated ${name}`); 316 results.successful.push({ 317 name, 318 from: info.current, 319 to: info.latest, 320 updateType: info.updateType, 321 }); 322 } 323 324 // Summary 325 log.info('\n=== Update Summary ==='); 326 log.success(`Successful: ${results.successful.length}`); 327 log.error(`Failed: ${results.failed.length}`); 328 329 if (results.successful.length > 0) { 330 log.info('\nSuccessful updates:'); 331 results.successful.forEach(u => { 332 log.success(` ✓ ${u.name}: ${u.from} → ${u.to} (${u.updateType})`); 333 }); 334 335 // Create commit if requested 336 if (!options.noCommit && results.successful.length > 0) { 337 log.info('\nCreating git commit...'); 338 createCommit(results.successful); 339 } 340 } 341 342 if (results.failed.length > 0) { 343 log.info('\nFailed updates:'); 344 results.failed.forEach(u => { 345 log.error(` ✗ ${u.name}: ${u.from} → ${u.to} (${u.reason})`); 346 }); 347 } 348 349 // Cleanup old backups (keep last 5) 350 const backupDir = path.join(__dirname, '..', '.dependency-updates'); 351 const backupFiles = fs.readdirSync(backupDir).sort().reverse(); 352 if (backupFiles.length > 10) { 353 // Keep 5 pairs (package.json + package-lock.json) 354 const toDelete = backupFiles.slice(10); 355 toDelete.forEach(file => { 356 fs.unlinkSync(path.join(backupDir, file)); 357 }); 358 log.info(`\nCleaned up ${toDelete.length} old backup files`); 359 } 360 361 log.info('\n=== Done ==='); 362 } 363 364 main().catch(error => { 365 log.error('Unexpected error:'); 366 console.error(error); 367 process.exit(1); 368 });