diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5c9b50d..5d2d4bb 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,17 @@ version: 2 updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + time: "08:00" + timezone: "America/Denver" + commit-message: + prefix: "npm" + labels: + - "dependencies" + open-pull-requests-limit: 100 + - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/package-lock.json b/package-lock.json index 846f4b2..4cf0467 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,13 +23,14 @@ "chalk": "^5.4.1", "cli-progress": "^3.12.0", "cli-table3": "^0.6.5", - "commander": "^12.1.0", + "commander": "^13.0.0", "find-up": "^7.0.0", "globby": "^14.0.2", "isbinaryfile": "^5.0.4", "micromatch": "^4.0.8", "ora": "^8.1.1", - "yaml": "^2.6.1" + "shell-escape": "^0.2.0", + "yaml": "^2.7.0" }, "bin": { "depsweep": "dist/index.js" @@ -39,11 +40,11 @@ "@types/cli-progress": "^3.11.6", "@types/jest": "^29.5.14", "@types/micromatch": "^4.0.9", - "@types/node": "^22.10.2", + "@types/node": "^22.10.5", + "@types/shell-escape": "^0.2.3", "jest": "^29.7.0", "mikey-pro": "^7.5.3", "ts-jest": "^29.2.5", - "ts-node": "^10.9.2", "typescript": "^5.7.2" }, "engines": { @@ -1836,6 +1837,8 @@ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -1849,6 +1852,8 @@ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -2995,28 +3000,36 @@ "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3164,9 +3177,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.10.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", - "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", + "version": "22.10.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", + "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3180,6 +3193,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/shell-escape": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@types/shell-escape/-/shell-escape-0.2.3.tgz", + "integrity": "sha512-xZWkMuQkn1I20gEzhYRa4/t1pwZ8XiIkqGA1Iee1D2IgAUIRLr57nrgJgF2QmHEfkfVzOM59gi/4xp6V+Aq+4A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -3516,6 +3536,8 @@ "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "acorn": "^8.11.0" }, @@ -3607,7 +3629,9 @@ "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/argparse": { "version": "1.0.10", @@ -4372,9 +4396,9 @@ "license": "MIT" }, "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", + "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==", "license": "MIT", "engines": { "node": ">=18" @@ -4525,7 +4549,9 @@ "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -10986,6 +11012,12 @@ "node": ">=8" } }, + "node_modules/shell-escape": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", + "integrity": "sha512-uRRBT2MfEOyxuECseCZd28jC1AJ8hmqqneWQ4VWUTgCAFvb3wKU1jLqj6egC4Exrr88ogg3dp+zroH4wJuaXzw==", + "license": "MIT" + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -12075,6 +12107,8 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12426,7 +12460,9 @@ "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/v8-to-istanbul": { "version": "9.3.0", @@ -12658,9 +12694,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", - "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -12735,6 +12771,8 @@ "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=6" } diff --git a/package.json b/package.json index fe302e8..e96f4a4 100644 --- a/package.json +++ b/package.json @@ -27,24 +27,25 @@ "chalk": "^5.4.1", "cli-progress": "^3.12.0", "cli-table3": "^0.6.5", - "commander": "^12.1.0", + "commander": "^13.0.0", "find-up": "^7.0.0", "globby": "^14.0.2", "isbinaryfile": "^5.0.4", "micromatch": "^4.0.8", "ora": "^8.1.1", - "yaml": "^2.6.1" + "shell-escape": "^0.2.0", + "yaml": "^2.7.0" }, "devDependencies": { "@types/babel__traverse": "^7.20.6", "@types/cli-progress": "^3.11.6", "@types/jest": "^29.5.14", "@types/micromatch": "^4.0.9", - "@types/node": "^22.10.2", + "@types/node": "^22.10.5", + "@types/shell-escape": "^0.2.3", "jest": "^29.7.0", "mikey-pro": "^7.5.3", "ts-jest": "^29.2.5", - "ts-node": "^10.9.2", "typescript": "^5.7.2" }, "files": [ diff --git a/src/index.ts b/src/index.ts index 2825be3..5b5e811 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import path from 'node:path'; import { stdin as input, stdout as output } from 'node:process'; import * as readline from 'node:readline/promises'; import v8 from 'node:v8'; +import { readdirSync, statSync } from 'node:fs'; import { parse } from '@babel/parser'; import traverse from '@babel/traverse'; @@ -27,20 +28,25 @@ import { isBinaryFileSync } from 'isbinaryfile'; import micromatch from 'micromatch'; import ora from 'ora'; import type { Ora } from 'ora'; +import shellEscape from 'shell-escape'; const MESSAGES = { noPackageJson: 'No package.json found.', monorepoDetected: '\nMonorepo detected. Using root package.json.', monorepoWorkspaceDetected: '\nMonorepo workspace package detected.', analyzingDependencies: 'Analyzing dependencies...', - analysisComplete: 'Analysis complete!', fatalError: '\nFatal error:', noUnusedDependencies: 'No unused dependencies found.', unusedFound: 'Unused dependencies found:\n', - dataDitchedPrefix: '\n~', dryRunNoChanges: '\nDry run - no changes made', - noChangesMade: '\nNo changes made.', + noChangesMade: '\nNo changes made', promptRemove: '\nDo you want to remove these dependencies? (y/N) ', + dependenciesRemoved: 'Dependencies:', + diskSpace: 'Disk Space:', + carbonFootprint: 'Carbon Footprint:', + measuringInstallTime: 'Measuring install time...', + measureComplete: 'Measurement complete', + installTime: 'Total Install Time:', }; // Update interface for package.json structure @@ -829,6 +835,92 @@ async function getPackageSizeFromNpm( } } +// Measure install time by running a subprocess +function measureInstallTime(pkg: string): number { + if (!isValidPackageName(pkg)) { + throw new Error(`Invalid package name: ${pkg}`); + } + const start = Date.now(); + safeExecSync(['npm', 'install', pkg], { + stdio: 'ignore', + cwd: process.cwd(), + timeout: 300000, + }); + return (Date.now() - start) / 1000; +} + +// Add this validation function +function isValidPackageName(name: string): boolean { + return /^[@a-zA-Z0-9-_/.]+$/.test(name); +} + +// Recursively compute dir size for accurate disk usage stats +function getDirectorySize(dir: string): number { + let total = 0; + const files = readdirSync(dir, { withFileTypes: true }); + for (const f of files) { + const fullPath = path.join(dir, f.name); + if (f.isDirectory()) { + total += getDirectorySize(fullPath); + } else { + total += statSync(fullPath).size; + } + } + return total; +} + +// Add a helper function to format bytes into human-readable strings +function formatSize(bytes: number): string { + if (bytes >= 1e9) { + return `${(bytes / 1e9).toFixed(2)} GB`; + } else if (bytes >= 1e6) { + return `${(bytes / 1e6).toFixed(2)} MB`; + } else if (bytes >= 1e3) { + return `${(bytes / 1e3).toFixed(2)} KB`; + } else { + return `${bytes} Bytes`; + } +} + +// Add this validation at the top with other constants +const VALID_PACKAGE_MANAGERS = new Set(['npm', 'yarn', 'pnpm']); + +// Add safe execution wrapper +function safeExecSync( + command: string[], + options: { + cwd: string; + stdio?: 'inherit' | 'ignore'; + timeout?: number; + }, +): void { + if (!Array.isArray(command) || command.length === 0) { + throw new Error('Invalid command array'); + } + + const [packageManager, ...args] = command; + + if (!VALID_PACKAGE_MANAGERS.has(packageManager)) { + throw new Error(`Invalid package manager: ${packageManager}`); + } + + // Validate all arguments + if (!args.every((arg) => typeof arg === 'string' && arg.length > 0)) { + throw new Error('Invalid command arguments'); + } + + try { + execSync(shellEscape(command), { + stdio: options.stdio || 'inherit', + cwd: options.cwd, + timeout: options.timeout || 300000, + encoding: 'utf8', + }); + } catch (error) { + throw new Error(`Command execution failed: ${(error as Error).message}`); + } +} + // Main execution async function main(): Promise { try { @@ -858,6 +950,7 @@ async function main(): Promise { .option('--safe', 'prevent removing essential packages') .option('--dry-run', 'show what would be removed without making changes') .option('--no-progress', 'disable progress bar') + .option('-m, --measure', 'measure saved installation time') .addHelpText('after', '\nExample:\n $ depsweep --verbose'); program.exitOverride(() => { @@ -964,7 +1057,7 @@ async function main(): Promise { } progressBar.stop(); - spinner.succeed(MESSAGES.analysisComplete); + spinner.stop(); // Filter out essential packages if in safe mode if (program.opts().safe) { @@ -1036,11 +1129,51 @@ async function main(): Promise { const sizeResults = await Promise.all(sizePromises); totalSize = sizeResults.reduce((acc, val) => acc + val, 0); - if (totalSize > 0) { + // Additional Impact Reporting + const removedCount = unusedDependencies.length; + const diskSpaceSaved = formatSize(totalSize); + const carbonReduction = (removedCount * 0.002).toFixed(3); + + console.log(chalk.bold('\nImpact:')); + console.log( + `${MESSAGES.dependenciesRemoved} ${chalk.bold(removedCount)}`, + ); + console.log(`${MESSAGES.diskSpace} ${chalk.bold(diskSpaceSaved)}`); + console.log( + `${MESSAGES.carbonFootprint} ${chalk.bold(`~${carbonReduction}`, 'kg', 'CO2e')}`, + ); + + if (options.measure) { + console.log(''); + const spinner = ora({ + text: MESSAGES.measuringInstallTime, + spinner: 'dots', + }).start(); + activeSpinner = spinner; + console.log(''); + + let totalInstallTime = 0; + const totalPackages = unusedDependencies.length; + let completedPackages = 0; + + for (const dep of unusedDependencies) { + let time = 0; + try { + time = measureInstallTime(dep); + totalInstallTime += time; + completedPackages++; + console.log( + `${chalk.blue(`[${completedPackages}/${totalPackages}]`)} ${dep}: ${time.toFixed(2)}s`, + ); + } catch (error) { + console.error(`${chalk.red('✗')} Error measuring ${dep}: ${error}`); + } + } + + spinner.stop(); + console.log( - chalk.bold( - `${MESSAGES.dataDitchedPrefix}${(totalSize / 1024).toFixed(2)} KB`, - ), + `${MESSAGES.installTime} ${chalk.bold(`~${totalInstallTime.toFixed(2)}s`)}`, ); } @@ -1078,12 +1211,23 @@ async function main(): Promise { } } - execSync(uninstallCommand, { - stdio: 'inherit', - cwd: projectDirectory, - }); + // Validate before using in execSync + unusedDependencies = unusedDependencies.filter(isValidPackageName); + + if (unusedDependencies.length > 0) { + try { + safeExecSync([packageManager, 'uninstall', ...unusedDependencies], { + stdio: 'inherit', + cwd: projectDirectory, + timeout: 300000, + }); + } catch (error) { + console.error(chalk.red('Failed to uninstall packages:'), error); + process.exit(1); + } + } } else { - console.log(chalk.blue('\nNo changes made.')); + console.log(chalk.blue(`${MESSAGES.noChangesMade}`)); } rl.close(); activeReadline = null;