Inspect Any package.json from the Terminal — Build a Query CLI
Every JavaScript developer spends time staring at package.json files. Whether you're auditing dependencies in a new project, checking what version of React you're running, or trying to figure out why your node_modules folder ballooned to 800 MB, you end up doing the same manual dance: open the file, scroll, squint, copy a package name, open npmjs.com, search, compare versions.
What if a single CLI could do all of that — pretty-print fields, list dependencies, check for outdated packages against the npm registry, and even scan your node_modules size — right from the terminal?
That's exactly what pkgjson-cli does. In this tutorial, I'll walk through how I built it and how each feature works under the hood. By the end, you'll have a solid understanding of how to query package.json programmatically and interact with the npm registry from Node.js.
Why Build a package.json CLI?
You might wonder: doesn't npm ls and npm outdated already exist? They do — but they're slow, verbose, and tightly coupled to your installed node_modules tree. Sometimes you want to:
-
Pretty-print a specific field from
package.jsonwithout opening an editor - List all dependencies (dev, peer, optional) in a clean table
- Check outdated packages against the npm registry without running a full install
- Query the npm registry for metadata about any package
- Scan node_modules to find what's eating your disk space
A lightweight, focused CLI that does these five things well is more useful than you'd expect. Let's build it.
Getting Started
Install it globally:
npm install -g pkgjson-cli
Or try it without installing:
npx pkgjson-cli --help
The CLI exposes a single command pkgjson with several subcommands. Let's look at each.
Feature 1: Pretty-Printing package.json Fields
The simplest feature is also one of the most useful. Want to see just the scripts section?
pkgjson show scripts
Output:
{
"start": "node server.js",
"build": "webpack --mode production",
"test": "jest --coverage",
"lint": "eslint src/"
}
How It Works
Under the hood, this reads the nearest package.json (walking up from cwd), parses it, and uses a dot-notation path to extract nested fields:
const fs = require('fs');
const path = require('path');
function findPackageJson(dir) {
const filePath = path.join(dir, 'package.json');
if (fs.existsSync(filePath)) return filePath;
const parent = path.dirname(dir);
if (parent === dir) return null; // reached root
return findPackageJson(parent);
}
function getField(obj, fieldPath) {
return fieldPath.split('.').reduce((acc, key) => {
return acc && acc[key] !== undefined ? acc[key] : undefined;
}, obj);
}
const pkgPath = findPackageJson(process.cwd());
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
const value = getField(pkg, 'scripts');
console.log(JSON.stringify(value, null, 2));
The findPackageJson function walks up the directory tree, which means you can run pkgjson show from any subdirectory in a project — it'll find the right file. The dot-notation support means pkgjson show repository.url works too.
For terminal output, we use chalk to add syntax highlighting to JSON — green for strings, yellow for numbers, cyan for keys. This small touch makes a huge difference when you're scanning output quickly.
Feature 2: Dependency Listing
Run pkgjson deps and you get a clean, categorized table:
pkgjson deps
┌─────────────────────┬──────────┬──────────┐
│ Package │ Version │ Type │
├─────────────────────┼──────────┼──────────┤
│ express │ ^4.18.2 │ prod │
│ cors │ ^2.8.5 │ prod │
│ jest │ ^29.7.0 │ dev │
│ eslint │ ^8.56.0 │ dev │
│ typescript │ ^5.3.0 │ peer │
└─────────────────────┴──────────┴──────────┘
Total: 5 dependencies (2 prod, 2 dev, 1 peer)
How It Works
We iterate over four dependency fields — dependencies, devDependencies, peerDependencies, and optionalDependencies — and merge them into a unified list with type labels:
function listDeps(pkg) {
const depTypes = [
['dependencies', 'prod'],
['devDependencies', 'dev'],
['peerDependencies', 'peer'],
['optionalDependencies', 'optional'],
];
const allDeps = [];
for (const [field, type] of depTypes) {
if (pkg[field]) {
for (const [name, version] of Object.entries(pkg[field])) {
allDeps.push({ name, version, type });
}
}
}
return allDeps;
}
We render this with the cli-table3 package, which gives us those nice box-drawing characters. You can also pass --json to get machine-readable output for piping into other tools, or --type dev to filter by dependency type.
Feature 3: Outdated Package Checking
This is the feature that saves the most time. Run:
pkgjson outdated
Checking 47 packages against npm registry...
┌──────────────────┬───────────┬───────────┬────────┐
│ Package │ Current │ Latest │ Status │
├──────────────────┼───────────┼───────────┼────────┤
│ webpack │ ^5.89.0 │ 5.95.0 │ minor │
│ eslint │ ^8.56.0 │ 9.17.0 │ major │
│ typescript │ ^5.3.0 │ 5.7.2 │ minor │
│ @types/node │ ^20.10.0 │ 22.10.0 │ major │
└──────────────────┴───────────┴───────────┴────────┘
4 outdated (2 major, 2 minor) — 43 up to date
How It Works
For each dependency, we query the npm registry API:
const https = require('https');
function fetchLatestVersion(packageName) {
return new Promise((resolve, reject) => {
const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`;
https.get(url, { headers: { 'Accept': 'application/json' } }, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const parsed = JSON.parse(data);
resolve(parsed.version);
} catch (e) {
reject(e);
}
});
}).on('error', reject);
});
}
The naive approach — sequential requests — is painfully slow for projects with 50+ dependencies. We batch requests using Promise.allSettled with a concurrency limiter (default: 10 concurrent requests) to keep things fast without hammering the registry:
async function checkOutdated(deps, concurrency = 10) {
const results = [];
for (let i = 0; i < deps.length; i += concurrency) {
const batch = deps.slice(i, i + concurrency);
const settled = await Promise.allSettled(
batch.map(async (dep) => {
const latest = await fetchLatestVersion(dep.name);
return { ...dep, latest, outdated: semver.lt(
semver.minVersion(dep.version), latest
)};
})
);
results.push(...settled.filter(r => r.status === 'fulfilled').map(r => r.value));
}
return results;
}
We use the semver package to compare versions properly. The semver.minVersion call resolves range specifiers like ^4.18.2 to 4.18.2 for comparison. We classify updates as patch, minor, or major so you can prioritize what to update first.
Feature 4: npm Registry Queries
Want to know about a package without leaving the terminal?
pkgjson info lodash
lodash v4.17.21
A modern JavaScript utility library delivering modularity, performance & extras.
License: MIT
Homepage: https://lodash.com/
Repository: https://github.com/lodash/lodash
Downloads: 52,483,219 / week
Size: 1.41 MB (unpacked)
Last published: 2021-02-20
How It Works
This queries two npm registry endpoints. The package metadata comes from https://registry.npmjs.org/{package} and the download count from the npm downloads API at https://api.npmjs.org/downloads/point/last-week/{package}.
async function getPackageInfo(name) {
const [meta, downloads] = await Promise.all([
fetchJson(`https://registry.npmjs.org/${name}`),
fetchJson(`https://api.npmjs.org/downloads/point/last-week/${name}`),
]);
const latest = meta['dist-tags'].latest;
const version = meta.versions[latest];
return {
name: meta.name,
version: latest,
description: meta.description,
license: version.license,
homepage: meta.homepage,
repository: meta.repository?.url,
downloads: downloads.downloads,
unpackedSize: version.dist?.unpackedSize,
lastPublished: meta.time[latest],
};
}
Fetching both endpoints in parallel with Promise.all keeps the response snappy. We format the download count with locale-aware number formatting and convert bytes to human-readable sizes.
Feature 5: node_modules Size Scanning
The feature everyone secretly wants:
pkgjson size
Scanning node_modules...
Top 10 largest packages:
┌────┬──────────────────────────┬───────────┐
│ # │ Package │ Size │
├────┼──────────────────────────┼───────────┤
│ 1 │ @next/swc-darwin-arm64 │ 78.2 MB │
│ 2 │ typescript │ 42.1 MB │
│ 3 │ @swc/core-darwin-arm64 │ 38.7 MB │
│ 4 │ esbuild │ 9.4 MB │
│ 5 │ webpack │ 5.8 MB │
└────┴──────────────────────────┴───────────┘
Total node_modules size: 312.4 MB (1,247 packages)
How It Works
We recursively walk node_modules, summing file sizes per top-level package:
const { readdirSync, statSync } = require('fs');
function scanNodeModules(nmPath) {
const packages = {};
function walk(dir, pkgName) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full, pkgName);
} else {
packages[pkgName] = (packages[pkgName] || 0) + statSync(full).size;
}
}
}
for (const entry of readdirSync(nmPath, { withFileTypes: true })) {
if (entry.name.startsWith('@')) {
// scoped packages
const scopePath = path.join(nmPath, entry.name);
for (const sub of readdirSync(scopePath, { withFileTypes: true })) {
const pkgName = `${entry.name}/${sub.name}`;
walk(path.join(scopePath, sub.name), pkgName);
}
} else if (entry.isDirectory() && !entry.name.startsWith('.')) {
walk(path.join(nmPath, entry.name), entry.name);
}
}
return Object.entries(packages)
.sort(([, a], [, b]) => b - a)
.map(([name, size]) => ({ name, size }));
}
The tricky part is handling scoped packages (@scope/package) correctly — they live in a subdirectory within node_modules. We also skip hidden directories (like .cache) and handle nested node_modules in older npm versions.
For large projects, the scan can take a few seconds, so we show a spinner using ora to keep the user informed.
Putting It All Together
The CLI is wired up with commander:
const { program } = require('commander');
program.name('pkgjson').description('Inspect package.json from the terminal');
program.command('show <field>').description('Pretty-print a field').action(showField);
program.command('deps').description('List all dependencies').action(listDeps);
program.command('outdated').description('Check for outdated packages').action(checkOutdated);
program.command('info <package>').description('Query npm registry').action(getInfo);
program.command('size').description('Scan node_modules size').action(scanSize);
program.parse();
Each subcommand maps to a focused module. The whole thing is under 400 lines of code.
Lessons Learned
Building this CLI reinforced a few things:
The npm registry API is generous. No auth required for public package metadata. The
https://registry.npmjs.org/{pkg}endpoint returns everything you need.Concurrency matters. Sequential HTTP requests for 50+ packages is unacceptable. Batching with
Promise.allSettledand a concurrency cap gives you speed without rate-limiting issues.Semver is harder than it looks. Range specifiers like
^,~,>=, and||make naive string comparison useless. Use thesemverpackage — it handles every edge case.Terminal UX matters. Colors, tables, spinners, and progress indicators turn a functional tool into a pleasant one. Libraries like
chalk,cli-table3, andoramake this easy.Walking up directories is essential. Users run commands from subdirectories all the time. Finding the nearest
package.jsonby walking up fromcwdis a small feature that prevents constant frustration.
Try It Out
npm install -g pkgjson-cli
pkgjson deps
pkgjson outdated
pkgjson size
The source is on GitHub: github.com/chengyixu/pkgjson-cli. Contributions welcome.
pkgjson-cli is MIT-licensed. Built with Node.js, commander, chalk, cli-table3, ora, and semver.
Top comments (0)