DEV Community

Wilson Xu
Wilson Xu

Posted on

Inspect Any package.json from the Terminal — Build a Query CLI

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.json without 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
Enter fullscreen mode Exit fullscreen mode

Or try it without installing:

npx pkgjson-cli --help
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Output:

{
  "start": "node server.js",
  "build": "webpack --mode production",
  "test": "jest --coverage",
  "lint": "eslint src/"
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
┌─────────────────────┬──────────┬──────────┐
│ 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)
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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);
  });
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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],
  };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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 }));
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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:

  1. The npm registry API is generous. No auth required for public package metadata. The https://registry.npmjs.org/{pkg} endpoint returns everything you need.

  2. Concurrency matters. Sequential HTTP requests for 50+ packages is unacceptable. Batching with Promise.allSettled and a concurrency cap gives you speed without rate-limiting issues.

  3. Semver is harder than it looks. Range specifiers like ^, ~, >=, and || make naive string comparison useless. Use the semver package — it handles every edge case.

  4. Terminal UX matters. Colors, tables, spinners, and progress indicators turn a functional tool into a pleasant one. Libraries like chalk, cli-table3, and ora make this easy.

  5. Walking up directories is essential. Users run commands from subdirectories all the time. Finding the nearest package.json by walking up from cwd is a small feature that prevents constant frustration.

Try It Out

npm install -g pkgjson-cli
pkgjson deps
pkgjson outdated
pkgjson size
Enter fullscreen mode Exit fullscreen mode

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)