UNPKG

npm-install-size

Version:

Check the install size of any NPM package before installing it.

561 lines (547 loc) 23.2 kB
#!/usr/bin/env node import fs from 'fs/promises'; import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; import { getLatestTarballUrl, downloadAndExtractTarball, fetchPackageVersions, parsePkgArg, fetchPackageMeta } from './fetch.js'; import { getDirStats, estimateDownloadTime, cleanup, findMainJsFile, getAllFilesWithSizes, getFileTypeBreakdown } from './analyze.js'; import { printResult, printTable, printDependencyTree, printCompareTable, printBadgeMarkdown } from './output.js'; import { getDependencyTreeSize } from './tree.js'; import { minifyAndGzipFile } from './minify.js'; import { promptForOptions } from './interactive.js'; import cliProgress from 'cli-progress'; import { loadConfig } from './config.js'; import { existsSync } from 'fs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function retry(fn, retries = 3, delay = 500) { let lastErr; for (let i = 0; i < retries; i++) { try { return await fn(); } catch (err) { lastErr = err; if (i < retries - 1) await new Promise(res => setTimeout(res, delay)); } } throw lastErr; } function parseTopFlag(args) { const idx = args.findIndex(a => a === '--top'); if (idx !== -1 && args[idx + 1]) { const n = parseInt(args[idx + 1], 10); if (!isNaN(n) && n > 0) return n; } return null; } function parseHistoryFlag(args) { const idx = args.findIndex(a => a === '--history'); if (idx !== -1 && args[idx + 1]) { const n = parseInt(args[idx + 1], 10); if (!isNaN(n) && n > 0) return n; } return null; } function parseOutputFlag(args) { const idx = args.findIndex(a => a === '--output'); if (idx !== -1 && args[idx + 1]) { return args[idx + 1]; } return null; } function parseSpeedFlag(args) { const idx = args.findIndex(a => a === '--speed'); if (idx !== -1 && args[idx + 1]) { const n = parseFloat(args[idx + 1]); if (!isNaN(n) && n > 0) return n; } return 10; // default 10 Mbps } // Helper to check if a string is a local path function isLocalPath(str) { return str.startsWith('.') || str.startsWith('/') || str.match(/^[a-zA-Z]:\\/); } // Helper to get workspaces from root package.json async function getWorkspaces() { try { const pkgJson = JSON.parse(await fs.readFile(path.join(process.cwd(), 'package.json'), 'utf8')); if (pkgJson.workspaces) { if (Array.isArray(pkgJson.workspaces)) return pkgJson.workspaces; if (pkgJson.workspaces.packages) return pkgJson.workspaces.packages; } } catch {} return null; } // Helper to resolve workspace name to path async function resolveWorkspacePath(name) { const workspaces = await getWorkspaces(); if (!workspaces) return null; for (const pattern of workspaces) { const base = pattern.replace(/\*\*\/*|\*$/, ''); // Check if the pattern itself is a directory with the right name const direct = path.join(process.cwd(), base); if (path.basename(direct) === name && existsSync(direct) && existsSync(path.join(direct, 'package.json'))) { return direct; } // Check for subdirectory with the workspace name const candidate = path.join(process.cwd(), base, name); if (existsSync(candidate) && existsSync(path.join(candidate, 'package.json'))) { return candidate; } } // Fallback: search all workspace dirs for (const pattern of workspaces) { const base = pattern.replace(/\*\*\/*|\*$/, ''); const dir = path.join(process.cwd(), base); try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && entry.name === name && existsSync(path.join(dir, entry.name, 'package.json'))) { return path.join(dir, entry.name); } } } catch {} } return null; } // Analyze a local package directory (simulate npm pack by analyzing the directory directly) async function checkLocalPackage(localPath, opts = {}) { const absPath = path.resolve(localPath); const stats = await getDirStats(absPath); let minified = null, gzipped = null; if (opts.minified || opts.gzipped) { const mainJs = await findMainJsFile(absPath); if (mainJs) { const minifyRes = await minifyAndGzipFile(mainJs); if (opts.minified) minified = minifyRes.minifiedSize; if (opts.gzipped) gzipped = minifyRes.gzippedSize; } } let topFiles = null; let typeBreakdown = null; if (opts.topN || opts.types) { const allFiles = await getAllFilesWithSizes(absPath); if (opts.topN) topFiles = allFiles.sort((a, b) => b.size - a.size).slice(0, opts.topN); if (opts.types) typeBreakdown = getFileTypeBreakdown(allFiles); } // Try to get version from package.json let version = null; try { const pkgJson = JSON.parse(await fs.readFile(path.join(absPath, 'package.json'), 'utf8')); version = pkgJson.version; } catch {} return { pkg: localPath, version, size: stats.total, fileCount: stats.count, largestFile: stats.largest, downloadTime: estimateDownloadTime(stats.total, opts.speed), minified, gzipped, topFiles, typeBreakdown }; } async function checkPackage(pkg, opts = {}) { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), `npm-install-size-`)); try { const { tarball, version } = await getLatestTarballUrl(pkg); await downloadAndExtractTarball(tarball, tempDir); const stats = await getDirStats(tempDir); let minified = null, gzipped = null; if (opts.minified || opts.gzipped) { const mainJs = await findMainJsFile(tempDir); if (mainJs) { const minifyRes = await minifyAndGzipFile(mainJs); if (opts.minified) minified = minifyRes.minifiedSize; if (opts.gzipped) gzipped = minifyRes.gzippedSize; } } let topFiles = null; let typeBreakdown = null; if (opts.topN || opts.types) { const allFiles = await getAllFilesWithSizes(tempDir); if (opts.topN) topFiles = allFiles.sort((a, b) => b.size - a.size).slice(0, opts.topN); if (opts.types) typeBreakdown = getFileTypeBreakdown(allFiles); } return { pkg, version, size: stats.total, fileCount: stats.count, largestFile: stats.largest, downloadTime: estimateDownloadTime(stats.total, opts.speed), minified, gzipped, topFiles, typeBreakdown }; } finally { await cleanup(tempDir); } } async function checkPackageAtVersion(pkg, version, opts = {}) { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), `npm-install-size-`)); try { const { tarball } = await getLatestTarballUrl(`${pkg}@${version}`); await downloadAndExtractTarball(tarball, tempDir); const stats = await getDirStats(tempDir); return { version, size: stats.total }; } finally { await cleanup(tempDir); } } // Replace checkPackage with checkLocalPackage if local path async function checkAny(pkg, opts) { if (isLocalPath(pkg) || existsSync(pkg)) { return checkLocalPackage(pkg, opts); } return checkPackage(pkg, opts); } export async function runCli() { let args = process.argv.slice(2); const config = await loadConfig(); // If no CLI args, use config defaults if (args.length === 0 && config.packages) { args = [...config.packages]; if (config.json) args.push('--json'); if (config.summarize) args.push('--summarize'); if (config.csv) args.push('--csv'); if (config.md) args.push('--md'); if (config.deps) args.push('--deps'); if (config.minified) args.push('--minified'); if (config.gzipped) args.push('--gzipped'); if (config.types) args.push('--types'); if (config.compare) args.push('--compare'); if (config.badge) args.push('--badge'); if (config.topN) { args.push('--top'); args.push(String(config.topN)); } if (config.historyN) { args.push('--history'); args.push(String(config.historyN)); } if (config.output) { args.push('--output'); args.push(String(config.output)); } if (config.speed) { args.push('--speed'); args.push(String(config.speed)); } if (config.readme) args.push('--readme'); if (config.meta) args.push('--meta'); if (config.sequential) args.push('--sequential'); } const jsonFlag = args.includes('--json'); const summarizeFlag = args.includes('--summarize'); const csvFlag = args.includes('--csv'); const mdFlag = args.includes('--md'); const depsFlag = args.includes('--deps') || args.includes('--tree'); const minifiedFlag = args.includes('--minified'); const gzippedFlag = args.includes('--gzipped'); const typesFlag = args.includes('--types'); const compareFlag = args.includes('--compare'); const badgeFlag = args.includes('--badge'); const readmeFlag = args.includes('--readme'); const metaFlag = args.includes('--meta'); const sequentialFlag = args.includes('--sequential'); const outputFile = parseOutputFlag(args); const speed = parseSpeedFlag(args); const topN = parseTopFlag(args); const historyN = parseHistoryFlag(args); // Add workspace flag parsing const workspaceIdx = args.findIndex(a => a === '--workspace'); let workspaceName = null; if (workspaceIdx !== -1 && args[workspaceIdx + 1]) { workspaceName = args[workspaceIdx + 1]; args.splice(workspaceIdx, 2); // Remove from args } const pkgs = args.filter((a, i) => !a.startsWith('-') && (args[i - 1] !== '--top') && (args[i - 1] !== '--history') && (args[i - 1] !== '--output') && (args[i - 1] !== '--speed')); // Workspace support if (workspaceName) { const wsPath = await resolveWorkspacePath(workspaceName); if (!wsPath) { console.error(`Workspace '${workspaceName}' not found.`); process.exit(1); } pkgs.length = 0; pkgs.push(wsPath); } // If in interactive mode and no pkgs, prompt for workspace if (args.includes('--interactive') && pkgs.length === 0) { const workspaces = await getWorkspaces(); if (workspaces) { const inquirer = await import('inquirer'); const { ws } = await inquirer.default.prompt([ { type: 'list', name: 'ws', message: 'Select a workspace to analyze:', choices: workspaces } ]); const wsPath = await resolveWorkspacePath(ws); pkgs.push(wsPath); } } if (!pkgs.length) { console.log('Usage: npm-install-size <package> [package ...] [--json] [--summarize] [--csv] [--md] [--deps] [--minified] [--gzipped] [--top N] [--types] [--compare] [--history N] [--badge] [--interactive] [--output <file>] [--speed <mbps>] [--readme] [--meta]'); process.exit(1); } let output = ''; if (readmeFlag || metaFlag) { for (const pkg of pkgs) { try { const meta = await fetchPackageMeta(pkg); if (metaFlag) { const info = { name: meta.name, version: meta.version, description: meta.description, repository: meta.repository, homepage: meta.homepage, license: meta.license, author: meta.author, keywords: meta.keywords }; if (jsonFlag) { output += JSON.stringify(info, null, 2) + '\n'; console.log(JSON.stringify(info, null, 2)); } else { const lines = [ `Name: ${info.name}`, `Version: ${info.version}`, `Description: ${info.description}`, `Repository: ${info.repository?.url || ''}`, `Homepage: ${info.homepage || ''}`, `License: ${info.license || ''}`, `Author: ${typeof info.author === 'object' ? info.author.name : info.author || ''}`, `Keywords: ${(info.keywords || []).join(', ')}` ]; output += lines.join('\n') + '\n'; lines.forEach(line => console.log(line)); } } if (readmeFlag && meta.readme) { const truncated = meta.readme.length > 2000 ? meta.readme.slice(0, 2000) + '\n... (truncated)' : meta.readme; output += `\nREADME for ${meta.name}@${meta.version}:\n` + truncated + '\n'; console.log(`\nREADME for ${meta.name}@${meta.version}:\n` + truncated + '\n'); } } catch (e) { output += `❌ ${pkg}: ${e.message}\n`; console.error(`❌ ${pkg}: ${e.message}`); } } if (outputFile) { await fs.writeFile(outputFile, output); console.log(`Output written to ${outputFile}`); } return; } if (badgeFlag) { for (const pkg of pkgs) { try { const result = await checkAny(pkg, { speed }); const label = encodeURIComponent(`${pkg} install size`); const sizeStr = encodeURIComponent(result.size); const url = `https://img.shields.io/badge/${label}-${sizeStr}-blue`; output += `![${pkg} install size](${url})\n`; printBadgeMarkdown(pkg, result.size); } catch (e) { output += `❌ ${pkg}: ${e.message}\n`; console.error(`❌ ${pkg}: ${e.message}`); } } if (outputFile) { await fs.writeFile(outputFile, output); console.log(`Output written to ${outputFile}`); } return; } if (historyN) { for (const pkg of pkgs) { try { const { name } = parsePkgArg(pkg); const versions = (await fetchPackageVersions(name)).slice(0, historyN); const results = []; for (const v of versions) { const res = await checkPackageAtVersion(name, v, { speed }); results.push(res); } if (jsonFlag) { output += JSON.stringify(results, null, 2) + '\n'; console.log(JSON.stringify(results, null, 2)); } else { output += `| Version | Size |\n|---------|------|\n`; console.log(`| Version | Size |`); console.log(`|---------|------|`); for (const r of results) { output += `| ${r.version} | ${Math.round(r.size / 1024)} kB |\n`; console.log(`| ${r.version} | ${Math.round(r.size / 1024)} kB |`); } } } catch (e) { output += `❌ ${pkg}: ${e.message}\n`; console.error(`❌ ${pkg}: ${e.message}`); } } if (outputFile) { await fs.writeFile(outputFile, output); console.log(`Output written to ${outputFile}`); } return; } if (depsFlag) { for (const pkg of pkgs) { try { const { totalSize, tree } = await getDependencyTreeSize(pkg); const depOutput = `Total install size for ${pkg} and all dependencies: ${Math.round(totalSize / 1024)} kB\n`; output += depOutput; console.log(depOutput.trim()); // For file output, capture the tree as text let treeLines = []; function captureTree(t, indent = 0) { const pad = ' '.repeat(indent); treeLines.push(`${pad}- ${t.name}: ${Math.round(t.size / 1024)} kB`); for (const dep in t.dependencies) captureTree(t.dependencies[dep], indent + 1); } captureTree(tree); output += treeLines.join('\n') + '\n'; treeLines.forEach(line => console.log(line)); } catch (e) { output += `❌ ${pkg}: ${e.message}\n`; console.error(`❌ ${pkg}: ${e.message}`); } } if (outputFile) { await fs.writeFile(outputFile, output); console.log(`Output written to ${outputFile}`); } return; } let results; if (sequentialFlag) { const bar = new cliProgress.SingleBar({ format: 'Progress |{bar}| {value}/{total} Packages' }, cliProgress.Presets.shades_classic); bar.start(pkgs.length, 0); results = []; for (const [i, pkg] of pkgs.entries()) { try { const result = await checkAny(pkg, { summarize: summarizeFlag, minified: minifiedFlag, gzipped: gzippedFlag, topN, types: typesFlag, speed }); results.push({ ...result, error: null }); } catch (e) { results.push({ pkg, version: null, size: null, fileCount: null, largestFile: null, downloadTime: null, minified: null, gzipped: null, topFiles: null, typeBreakdown: null, error: e.message }); } bar.update(i + 1); } bar.stop(); } else { const bar = new cliProgress.SingleBar({ format: 'Progress |{bar}| {value}/{total} Packages' }, cliProgress.Presets.shades_classic); bar.start(pkgs.length, 0); let done = 0; results = await Promise.all(pkgs.map(async pkg => { try { const result = await checkAny(pkg, { summarize: summarizeFlag, minified: minifiedFlag, gzipped: gzippedFlag, topN, types: typesFlag, speed }); bar.update(++done); return { ...result, error: null }; } catch (e) { bar.update(++done); return { pkg, version: null, size: null, fileCount: null, largestFile: null, downloadTime: null, minified: null, gzipped: null, topFiles: null, typeBreakdown: null, error: e.message }; } })); bar.stop(); } if (compareFlag) { if (jsonFlag) { const out = results.map(r => ({ package: r.pkg, version: r.version, size: r.size, minified: r.minified, gzipped: r.gzipped, fileCount: r.fileCount, largestFile: r.largestFile, error: r.error })); output += JSON.stringify(out, null, 2) + '\n'; console.log(JSON.stringify(out, null, 2)); } else { // Markdown table let table = '| Package | Version | Size | Minified | Gzipped | Files | Largest File | Largest Size |\n'; table += '|---------|---------|------|----------|---------|-------|--------------|-------------|\n'; for (const r of results) { table += `| ${r.pkg} | ${r.version || ''} | ${r.size ? Math.round(r.size / 1024) + ' kB' : ''} | ${r.minified ? Math.round(r.minified / 1024) + ' kB' : ''} | ${r.gzipped ? Math.round(r.gzipped / 1024) + ' kB' : ''} | ${r.fileCount || ''} | ${r.largestFile?.name || ''} | ${r.largestFile?.size ? Math.round(r.largestFile.size / 1024) + ' kB' : ''} |\n`; } output += table; console.log(table.trim()); } if (outputFile) { await fs.writeFile(outputFile, output); console.log(`Output written to ${outputFile}`); } return; } if (jsonFlag) { const out = results.map(r => ({ package: r.pkg, version: r.version, size: r.size, fileCount: r.fileCount, largestFile: r.largestFile, downloadTime: r.downloadTime, minified: r.minified, gzipped: r.gzipped, topFiles: r.topFiles, typeBreakdown: r.typeBreakdown, error: r.error })); output += JSON.stringify(out, null, 2) + '\n'; console.log(JSON.stringify(out, null, 2)); } else if (csvFlag) { // CSV output let csv = 'package,version,size_bytes,size,file_count,largest_file,largest_file_size,download_time,error\n'; for (const r of results) { csv += `${r.pkg},${r.version || ''},${r.size || ''},${r.size ? Math.round(r.size / 1024) + ' kB' : ''},${r.fileCount || ''},${r.largestFile?.name || ''},${r.largestFile?.size || ''},${r.downloadTime || ''},${r.error || ''}\n`; } output += csv; printTable(results, 'csv'); } else if (mdFlag) { // Markdown table let table = '| Package | Version | Size | Files | Largest File | Largest Size | Download | Error |\n'; table += '|---------|---------|------|-------|--------------|-------------|----------|-------|\n'; for (const r of results) { table += `| ${r.pkg} | ${r.version || ''} | ${r.size ? Math.round(r.size / 1024) + ' kB' : ''} | ${r.fileCount || ''} | ${r.largestFile?.name || ''} | ${r.largestFile?.size ? Math.round(r.largestFile.size / 1024) + ' kB' : ''} | ${r.downloadTime || ''} | ${r.error || ''} |\n`; } output += table; printTable(results, 'md'); } else { for (const r of results) { let line = ''; if (r.error) { line = `❌ ${r.pkg}: ${r.error}`; } else if (summarizeFlag) { line = `📦 ${r.pkg}@${r.version}: ${Math.round(r.size / 1024)} kB, ${r.fileCount} files, largest: ${r.largestFile.name} (${Math.round(r.largestFile.size / 1024)} kB), est. download: ${r.downloadTime}`; } else { line = `📦 ${r.pkg}@${r.version}: ${Math.round(r.size / 1024)} kB`; } output += line + '\n'; printResult(r, r.error ? { message: r.error } : null, { summarize: summarizeFlag }); if (minifiedFlag && r.minified != null) { output += ` Minified size: ${Math.round(r.minified / 1024)} kB\n`; console.log(` Minified size: ${Math.round(r.minified / 1024)} kB`); } if (gzippedFlag && r.gzipped != null) { output += ` Gzipped size: ${Math.round(r.gzipped / 1024)} kB\n`; console.log(` Gzipped size: ${Math.round(r.gzipped / 1024)} kB`); } if (topN && r.topFiles) { output += ` Top ${topN} largest files:\n`; console.log(` Top ${topN} largest files:`); for (const f of r.topFiles) { output += ` ${f.path} (${Math.round(f.size / 1024)} kB)\n`; console.log(` ${f.path} (${Math.round(f.size / 1024)} kB)`); } } if (typesFlag && r.typeBreakdown) { output += ' File type breakdown:\n'; console.log(' File type breakdown:'); for (const ext in r.typeBreakdown) { output += ` ${ext}: ${Math.round(r.typeBreakdown[ext] / 1024)} kB\n`; console.log(` ${ext}: ${Math.round(r.typeBreakdown[ext] / 1024)} kB`); } } } } if (outputFile) { await fs.writeFile(outputFile, output); console.log(`Output written to ${outputFile}`); } const installedFlag = args.includes('--installed'); if (installedFlag) { const prettyBytes = (await import('pretty-bytes')).default; console.log('⚠️ This is the actual installed size on disk, including all dependencies. It may be much larger than the published tarball size.'); if (pkgs.length === 0) { // Show total node_modules size const nmPath = path.join(process.cwd(), 'node_modules'); if (!existsSync(nmPath)) { console.log('node_modules folder not found. Run npm install first.'); return; } const stats = await getDirStats(nmPath); console.log(`📦 node_modules: ${prettyBytes(stats.total)}`); } else { for (const pkg of pkgs) { const pkgPath = path.join(process.cwd(), 'node_modules', pkg); if (!existsSync(pkgPath)) { console.log(`node_modules/${pkg} not found. Is it installed?`); continue; } const stats = await getDirStats(pkgPath); console.log(`📦 node_modules/${pkg}: ${prettyBytes(stats.total)}`); } } return; } }