Word Count: ~3,100 | May 9, 2026
Every developer has a folder called scripts/. Inside: a dozen .ts files that solve real problems but will never be shared. They lack argument parsing, error handling, tests, and distribution. A script becomes a product when another developer can npm install -g it and have it work on the first try.
TypeScript has matured into a first-class CLI language. Node.js 24 ships with native TypeScript support via type stripping. The ecosystem around CLI construction -- argument parsing, output formatting, configuration management, testing -- has converged on production-tested patterns. This article walks through building a CLI tool that another engineer would pay for, not just one that runs on your machine.
We will build a tool called depaudit that audits npm dependency freshness across a project and outputs a ranked report. Every pattern shown is extracted from real production CLIs with thousands of GitHub stars.
Before writing a single line, decide what you are building. The wrong abstraction costs more than no abstraction.
| Characteristic | Script | CLI Tool | Service / API |
|---|---|---|---|
| Invocation | npx tsx myscript.ts |
npx mytool --flag |
HTTP request |
| Output | console.log |
Structured (JSON, table, color) | JSON/Protobuf |
| Error handling | Throw and crash | Exit codes + stderr messages | HTTP status codes |
| Config | Hardcoded or .env |
CLI flags + config file cascade | Environment + secret store |
| Distribution | Copy-paste | npm registry, Homebrew, apt | Docker, K8s, serverless |
| Users | You | Your team or the public | Applications and services |
If your script will be run by someone else on a machine you do not control, it is a CLI tool. Treat it like one from the start.
The tool we are building lands squarely in the CLI column: it reads a package.json, checks each dependency against the npm registry, and outputs a formatted report. A developer runs it in CI or locally. No server, no persistence, no authentication.
A production CLI starts with a specific directory structure and build configuration.
depaudit/
package.json
tsconfig.json
src/
index.ts # Entry point
cli.ts # Argument parsing + command routing
commands/
audit.ts # Core audit logic
lib/
npm.ts # npm registry client
format.ts # Output formatters
config.ts # Configuration loading
tests/
audit.test.ts
cli.test.ts
Here is the package.json that wires everything together:
{
"name": "depaudit",
"version": "1.0.0",
"description": "Audit npm dependency freshness across a project",
"type": "module",
"main": "./dist/index.js",
"bin": {
"depaudit": "./dist/index.js"
},
"files": ["dist/"],
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"test": "vitest",
"prepublishOnly": "npm run build"
},
"dependencies": {
"commander": "^13.0.0",
"chalk": "^5.4.0",
"semver": "^7.7.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/semver": "^7.5.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
},
"engines": {
"node": ">=22.0.0"
}
}Key decisions here:
"type": "module"-- Always use ESM for new CLI tools. CJS is legacy. Every dependency you want to use in 2026 supports ESM."bin"-- This is how npm creates the globaldepauditcommand. It points to the compiled JavaScript entry point."files": ["dist/"]-- Only publish compiled output. Users should never runtscthemselves."engines"-- Be explicit about Node.js version. Node 22 is current LTS and has native type stripping.
The tsconfig.json is compact but precise:
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"declaration": true,
"sourceMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src/"],
"exclude": ["tests/"]
}The entry point (src/index.ts) is the executable that bin points to. Two requirements: a POSIX shebang and a safe top-level async bootstrap.
#!/usr/bin/env node
import { run } from "./cli.js";
run(process.argv).catch((error) => {
if (error instanceof Error) {
process.stderr.write(`Error: ${error.message}\n`);
}
process.exit(1);
});The shebang #!/usr/bin/env node tells the operating system to execute this file with Node.js. Without it, depaudit on the command line would fail with a syntax error.
The .catch() at the top level is the only place you should call process.exit(). Everywhere else, throw errors and let them bubble up. This pattern gives you a single choke point for error formatting and exit codes.
Node.js supports top-level await in ESM, but CLI tool entry points should avoid it. The reason: if your await expression throws, Node.js emits an unhandledRejection event and exits with code 0 (success), which is the worst possible combination for CI pipelines. The explicit .catch() + process.exit(1) pattern guarantees the correct exit code every time.
Commander is the standard library for CLI argument parsing in the Node.js ecosystem. It handles commands, subcommands, flags, positional arguments, help text generation, and error messages -- all of which you would otherwise write by hand.
Here is src/cli.ts, which defines the command structure:
import { Command } from "commander";
import chalk from "chalk";
import { auditCommand } from "./commands/audit.js";
import { loadConfig } from "./lib/config.js";
interface CLIOptions {
cwd: string;
format: "table" | "json" | "csv";
severity: "all" | "major" | "minor" | "patch";
registry: string;
production: boolean;
}
export async function run(argv: string[]): Promise<void> {
const program = new Command();
program
.name("depaudit")
.description("Audit npm dependency freshness across a project")
.version("1.0.0")
.option("-d, --cwd <path>", "Project directory", process.cwd())
.option(
"-f, --format <type>",
"Output format",
"table"
)
.option(
"-s, --severity <level>",
"Minimum severity to report",
"minor"
)
.option(
"-r, --registry <url>",
"npm registry URL",
"https://registry.npmjs.org"
)
.option(
"--no-production",
"Include devDependencies in audit"
)
.action(async (options: CLIOptions) => {
const config = await loadConfig(options.cwd);
await auditCommand({ ...options, config });
});
await program.parseAsync(argv);
}Commander worth highlighting:
.option()type coercion: Commander infers types from defaults.process.cwd()returns a string, so--cwdis a string."table"constrains--formatto a string union.--no-production: Commander automatically creates a negated boolean. When the user passes--no-production,options.productionisfalse. The default (no flag) istrue..parseAsync(): Always use the async version. Synchronous.parse()will not wait for your action handler's promises, meaning errors get swallowed.
| Library | Best For | Trade-off |
|---|---|---|
| Commander | Multi-command CLIs with subcommands (git-style) |
Slightly more boilerplate for single-command tools |
| Yargs | Single-command CLIs with complex positional args | Heavier bundle, less intuitive multi-command API |
| Citty | Zero-dependency, minimal CLIs | Smaller ecosystem, fewer built-in validations |
For depaudit, Commander is the right choice. We want depaudit check, depaudit fix, depaudit init as future commands, and Commander's subcommand system handles this naturally.
The audit command fetches package metadata from the npm registry. A naive implementation would fetch all packages in parallel with Promise.all(), which risks rate limiting on large projects. A sequential implementation would be too slow. The correct pattern is structured concurrency with a concurrency cap.
// src/lib/npm.ts
import { request } from "node:https";
import { TextDecoder } from "node:util";
interface PackageMetadata {
name: string;
"dist-tags": { latest: string };
versions: Record<string, { version: string }>;
time: Record<string, string>;
}
const decoder = new TextDecoder();
export async function getPackageMetadata(
name: string,
registry: string
): Promise<PackageMetadata> {
const url = new URL(`/${name}`, registry);
return new Promise((resolve, reject) => {
const req = request(
url,
{
method: "GET",
headers: { Accept: "application/json" },
timeout: 10_000,
},
(res) => {
if (res.statusCode !== 200) {
reject(
new Error(`Failed to fetch ${name}: HTTP ${res.statusCode}`)
);
return;
}
const chunks: Uint8Array[] = [];
res.on("data", (chunk: Uint8Array) => chunks.push(chunk));
res.on("end", () => {
const body = decoder.decode(Buffer.concat(chunks));
resolve(JSON.parse(body) as PackageMetadata);
});
}
);
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error(`Timeout fetching ${name}`));
});
req.end();
});
}
export async function fetchAllPackages(
packages: string[],
registry: string,
concurrency = 5
): Promise<Map<string, PackageMetadata>> {
const results = new Map<string, PackageMetadata>();
const queue = [...packages];
async function worker(): Promise<void> {
while (queue.length > 0) {
const name = queue.shift()!;
try {
results.set(name, await getPackageMetadata(name, registry));
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error";
results.set(name, {
name,
"dist-tags": { latest: "unknown" },
versions: {},
time: {},
_error: message,
} as PackageMetadata & { _error: string });
}
}
}
await Promise.all(
Array.from({ length: concurrency }, () => worker())
);
return results;
}This is a worker pool pattern. It maintains exactly concurrency in-flight requests at all times without external libraries. When one worker finishes a request, it immediately picks up the next package from the queue. This is more efficient than chunking with Promise.all() because fast requests do not wait for slow ones within the same batch.
The concurrency = 5 default is deliberate. The npm registry has no documented rate limit for metadata endpoints, but many corporate registries (Verdaccio, Artifactory, Nexus) throttle at 5-10 concurrent requests. Five is a safe default that works everywhere.
Notice the error handling: individual fetch failures do not crash the entire audit. Failed packages get an _error property and the report flags them. The user still gets results for the packages that succeeded.
Production CLI tools need configuration from multiple sources with a clear precedence. The cascade pattern resolves this:
CLI flags > Environment variables > Config file > Defaults
// src/lib/config.ts
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
export interface DepauditConfig {
ignore: string[];
maxAge: {
major: number; // days
minor: number;
patch: number;
};
}
const DEFAULTS: DepauditConfig = {
ignore: [],
maxAge: {
major: 30,
minor: 90,
patch: 180,
},
};
export async function loadConfig(cwd: string): Promise<DepauditConfig> {
const fileConfig = await loadConfigFile(cwd);
const envConfig = loadEnvConfig();
return deepMerge(DEFAULTS, fileConfig, envConfig);
}
async function loadConfigFile(
cwd: string
): Promise<Partial<DepauditConfig>> {
const configPath = resolve(cwd, ".depauditrc.json");
try {
const raw = await readFile(configPath, "utf-8");
return JSON.parse(raw) as Partial<DepauditConfig>;
} catch {
return {};
}
}
function loadEnvConfig(): Partial<DepauditConfig> {
const config: Record<string, unknown> = {};
if (process.env.DEPAUDIT_IGNORE) {
config.ignore = process.env.DEPAUDIT_IGNORE.split(",").map((s) =>
s.trim()
);
}
return config as Partial<DepauditConfig>;
}
function deepMerge<T extends Record<string, unknown>>(
...sources: Partial<T>[]
): T {
return sources.reduce((merged, source) => {
for (const [key, value] of Object.entries(source)) {
if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
typeof merged[key] === "object" &&
merged[key] !== null
) {
(merged as Record<string, unknown>)[key] = deepMerge(
merged[key] as Record<string, unknown>,
value as Record<string, unknown>
);
} else {
(merged as Record<string, unknown>)[key] = value;
}
}
return merged;
}, {} as Record<string, unknown>) as T;
}The deepMerge utility is intentionally written inline rather than pulled from a library. It is 18 lines and handles the nested maxAge object correctly. Adding lodash.merge (2.6MB unpacked) for this is unnecessary dependency weight. Production CLIs should be lean -- every dependency is a supply chain risk.
The .depauditrc.json filename follows the XDG-inspired convention used by tools like ESLint, Prettier, and Renovate. A .depauditrc.json in the project root signals to collaborators that this is a project-level tool configuration, not personal preference. For user-level configuration, ~/.config/depaudit/config.json is the appropriate path.
A CLI tool that only prints human-readable tables is a dead end for automation. Every output path must support machine-readable formats from day one.
// src/lib/format.ts
import chalk from "chalk";
export interface AuditEntry {
name: string;
current: string;
latest: string;
daysBehind: number;
severity: "critical" | "major" | "minor" | "patch" | "ok";
error?: string;
}
export function formatTable(entries: AuditEntry[]): string {
const rows = entries.map((e) => [
e.name,
e.current,
e.latest,
`${e.daysBehind}d`,
severityLabel(e.severity),
e.error ?? "",
]);
const widths = [0, 0, 0, 0, 0, 0];
const header = ["Package", "Current", "Latest", "Behind", "Severity", ""];
for (const row of [header, ...rows]) {
for (let i = 0; i < row.length; i++) {
widths[i] = Math.max(widths[i], row[i].length);
}
}
const pad = (text: string, width: number): string =>
text + " ".repeat(width - text.length);
const headerRow = header
.map((h, i) => chalk.bold(pad(h, widths[i])))
.join(" ");
const bodyRows = rows
.map(
(row, idx) =>
pad(row[0], widths[0]) +
" " +
pad(row[1], widths[1]) +
" " +
chalk.green(pad(row[2], widths[2])) +
" " +
pad(row[3], widths[3]) +
" " +
severityColor(entries[idx].severity)(
pad(row[4], widths[4])
)
)
.join("\n");
return `${headerRow}\n${bodyRows}`;
}
export function formatJSON(entries: AuditEntry[]): string {
return JSON.stringify(
{
generatedAt: new Date().toISOString(),
summary: {
total: entries.length,
critical: entries.filter((e) => e.severity === "critical").length,
major: entries.filter((e) => e.severity === "major").length,
healthy: entries.filter((e) => e.severity === "ok").length,
},
packages: entries.map(({ name, current, latest, daysBehind, severity }) => ({
name,
current,
latest,
daysBehind,
severity,
})),
},
null,
2
);
}
function severityLabel(level: AuditEntry["severity"]): string {
const labels: Record<string, string> = {
critical: "CRIT",
major: "MAJOR",
minor: "MINOR",
patch: "PATCH",
ok: "OK",
};
return labels[level];
}
function severityColor(
level: AuditEntry["severity"]
): (text: string) => string {
const colors: Record<string, (text: string) => string> = {
critical: chalk.bgRed.white,
major: chalk.red,
minor: chalk.yellow,
patch: chalk.dim,
ok: chalk.green,
};
return colors[level];
}The JSON output includes a summary object with counts, which lets CI scripts write assertions like:
depaudit --format json | jq '.summary.critical == 0' || exit 1This is the difference between a tool and a product: a tool shows you something. A product integrates into your pipeline.
Chalk automatically disables colors when stdout is piped (e.g., depaudit | less). This is essential -- ANSI escape codes in a file or pipe will corrupt the output. If you are not using Chalk, check process.stdout.isTTY before emitting ANSI codes.
Testing a CLI tool requires three separate layers. Each has different setup and assertions.
These are standard Vitest tests on exported functions. No process spawning, no argument parsing.
// tests/audit.test.ts
import { describe, it, expect } from "vitest";
import { compareVersions } from "../src/commands/audit.js";
describe("compareVersions", () => {
it("detects a major version gap", () => {
const result = compareVersions("1.2.3", "3.0.0");
expect(result.severity).toBe("major");
expect(result.daysBehind).toBeGreaterThan(0);
});
it("returns ok when versions match", () => {
const result = compareVersions("2.5.1", "2.5.1");
expect(result.severity).toBe("ok");
expect(result.daysBehind).toBe(0);
});
it("handles pre-release versions", () => {
const result = compareVersions("2.0.0-beta.1", "2.0.0");
expect(result.severity).toBe("ok");
});
});These test the full argument-parsing-to-output pipeline without network calls.
// tests/cli.test.ts
import { describe, it, expect, vi } from "vitest";
import { run } from "../src/cli.js";
describe("depaudit CLI", () => {
it("exits with code 1 on missing package.json", async () => {
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never);
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
await run(["node", "depaudit", "--cwd", "/nonexistent"]);
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
stderrSpy.mockRestore();
});
});Run the actual compiled binary against a fixture project. This is the only test that catches shebang issues, missing dist/ files, and packaging problems.
# tests/e2e.sh
npm run build
node dist/index.js --cwd tests/fixtures/sample-project --format json > /tmp/depaudit-output.json
cat /tmp/depaudit-output.json | jq -e '.summary.total > 0' || exit 1- Unit tests cover version comparison edge cases (prereleases, ranges, missing versions)
- Integration tests cover CLI flag parsing and error exit codes
- E2E test runs the compiled binary against a real fixture
- Test both
--format jsonand--format tableoutput paths - Test the
--no-productionflag includes devDependencies
Before npm publish, verify every item on this list. Each one has caused a real production incident.
Pre-publish checklist:
"files"inpackage.jsonincludes onlydist/-- This prevents source maps, test files, and config from leaking into the published package."bin"points to the compiled.jsfile, not the.tssource.npm pack --dry-runshows only the files you expect.npx depauditworks afternpm link-- This catches module resolution errors thatnode dist/index.jswould miss.package.jsonhas"engines"set -- Users on Node 18 should see a clear error, not a cryptic syntax error.- README shows the exact install command and a five-second getting-started example.
npm publish --tag betafirst -- Test the install path before tagginglatest.
# Pre-publish verification
npm run build
npm pack --dry-run # Review file list
npm link # Install globally from local
depaudit --version # Verify binary works
npm unlink -g depaudit
npm publish --tag betaNot every script deserves a package.json. Here is the framework for deciding:
| Signal | Action |
|---|---|
| Run by 2+ people on different machines | Extract to CLI |
| Used in CI/CD pipeline | Extract to CLI |
| Takes command-line arguments | Extract to CLI |
| Output is consumed by another tool | Extract to CLI (add --json flag) |
| Run only by you, ad-hoc, argument-free | Leave as script |
| Tightly coupled to a specific repo's structure | Leave as script or use a project-local .bin/ |
A signal that trumps all others: someone asked you how to install it. The moment another developer wants to use your tool, it needs a name, a version, and a distribution channel.
Pulling everything together, here is the complete audit command that wires configuration, network fetching, version comparison, and formatted output:
// src/commands/audit.ts
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import semver from "semver";
import { fetchAllPackages } from "../lib/npm.js";
import { formatTable, formatJSON, AuditEntry } from "../lib/format.js";
import type { DepauditConfig } from "../lib/config.js";
import type { CLIOptions } from "../cli.js";
interface AuditOptions extends CLIOptions {
config: DepauditConfig;
}
interface PackageJson {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
}
export async function auditCommand(options: AuditOptions): Promise<void> {
const pkgPath = resolve(options.cwd, "package.json");
let pkg: PackageJson;
try {
const raw = await readFile(pkgPath, "utf-8");
pkg = JSON.parse(raw);
} catch {
throw new Error(
`No package.json found at ${pkgPath}. Run depaudit in a Node.js project.`
);
}
const deps = pkg.dependencies ?? {};
const devDeps = options.production ? {} : (pkg.devDependencies ?? {});
const allDeps = { ...deps, ...devDeps };
const names = Object.keys(allDeps).filter(
(n) => !options.config.ignore.includes(n)
);
if (names.length === 0) {
process.stdout.write("No dependencies to audit.\n");
return;
}
const metadata = await fetchAllPackages(names, options.registry);
const entries: AuditEntry[] = [];
for (const [name, meta] of metadata) {
const currentVersion = allDeps[name].replace(/^[\^~>=<]+/, "");
const latestVersion = meta["dist-tags"]?.latest ?? "unknown";
entries.push(compareVersions(currentVersion, latestVersion, meta));
}
entries.sort((a, b) => b.daysBehind - a.daysBehind);
switch (options.format) {
case "json":
process.stdout.write(formatJSON(entries) + "\n");
break;
case "table":
process.stdout.write(formatTable(entries) + "\n");
break;
case "csv":
process.stdout.write(formatCSV(entries));
break;
}
const hasCritical = entries.some((e) => e.severity === "critical");
if (hasCritical) {
process.exitCode = 1;
}
}
export function compareVersions(
current: string,
latest: string,
meta?: { time?: Record<string, string> }
): AuditEntry {
if (current === "unknown" || latest === "unknown") {
return {
name: "",
current,
latest,
daysBehind: 0,
severity: "ok",
};
}
const diff = semver.diff(current, latest);
const releaseDate = meta?.time?.[latest];
const daysBehind = releaseDate
? Math.floor((Date.now() - new Date(releaseDate).getTime()) / 86_400_000)
: 0;
let severity: AuditEntry["severity"] = "ok";
if (diff === "major") severity = "major";
else if (diff === "minor") severity = "minor";
else if (diff === "patch") severity = "patch";
return {
name: "",
current,
latest,
daysBehind,
severity,
};
}
function formatCSV(entries: AuditEntry[]): string {
const header = "name,current,latest,daysBehind,severity\n";
const rows = entries
.map((e) => `${e.name},${e.current},${e.latest},${e.daysBehind},${e.severity}`)
.join("\n");
return header + rows + "\n";
}Three details in this code that production CLIs get right and hobby scripts get wrong:
-
process.exitCode = 1instead ofprocess.exit(1). SettingexitCodelets Node.js finish flushing stdout/stderr before exiting.process.exit()kills the process immediately, which can truncate output. -
Version range stripping (
replace(/^[\^~>=<]+/, "")). Apackage.jsonentry like"^2.1.0"is a range, not a version. Thesemver.diff()function needs exact versions. Strip the range operator before comparison. -
process.stdout.write()instead ofconsole.log().console.log()adds a trailing newline and goes to stdout by default, but it can be redirected. Explicitstdout.write()is more predictable, especially for machine-readable output.
A CLI tool is a script with standards. The standards are not complicated -- argument parsing, error handling, structured output, configuration, tests, distribution -- but each one must be present. Skip one and you have a script that works on your machine and nowhere else.
The patterns in this article form a template. Next time you write a script that another developer will use, start from this skeleton. The extra thirty minutes of setup pays back every time someone types npx your-tool --help and it just works.
Decision summary:
| Question | Answer |
|---|---|
| Argument parser | Commander (multi-command) or Yargs (single-command) |
| Module system | ESM ("type": "module") |
| Output formats | Table (human) + JSON (machine) from day one |
| Entry point | Shebang + .catch() exit handler, no top-level await |
| Config | Cascade: CLI flags > env vars > config file > defaults |
| Concurrency | Worker pool with configurable cap |
| Testing | Unit (Vitest) + Integration (CLI) + E2E (compiled binary) |
| Error handling | process.exitCode = 1 for soft exit; throw for hard failures |
| Distribution | npm with "bin" + "files": ["dist/"] |
| Dependencies | Minimal. Every dependency is a supply chain risk. |
The complete depaudit source code is available on GitHub. Install it with npm install -g depaudit and run depaudit --help to get started.