npm-install-size
Version:
Check the install size of any NPM package before installing it.
561 lines (547 loc) • 23.2 kB
JavaScript
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 += `\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;
}
}