/ scripts / update-dependencies.js
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  });