Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save chengyixu/883975bbc9f664aaddf66bc88add614a to your computer and use it in GitHub Desktop.

Select an option

Save chengyixu/883975bbc9f664aaddf66bc88add614a to your computer and use it in GitHub Desktop.
Building CLI Tools in TypeScript: From Script to Product - a comprehensive guide to building production-ready CLI tools with TypeScript, Commander.js, and Zod

Building CLI Tools in TypeScript: From Script to Product

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.


1. The Decision Framework: Script vs. CLI vs. Service

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.


2. Project Setup: The Modern TypeScript CLI Skeleton

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 global depaudit command. It points to the compiled JavaScript entry point.
  • "files": ["dist/"] -- Only publish compiled output. Users should never run tsc themselves.
  • "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/"]
}

3. Entry Point: Shebang and Async Bootstrap

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.

Why Not Top-Level Await?

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.


4. Argument Parsing with Commander: Commands, Flags, and Validation

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 --cwd is a string. "table" constrains --format to a string union.
  • --no-production: Commander automatically creates a negated boolean. When the user passes --no-production, options.production is false. The default (no flag) is true.
  • .parseAsync(): Always use the async version. Synchronous .parse() will not wait for your action handler's promises, meaning errors get swallowed.

Decision: Commander vs. Yargs vs. Citty

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.


5. Core Logic: Async Iteration with Structured Concurrency

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.


6. Configuration Loading: The Cascade Pattern

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.

Config File Location Convention

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.


7. Output Formatting: Structured Output as a Feature

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 1

This is the difference between a tool and a product: a tool shows you something. A product integrates into your pipeline.

Color Output and Piped Detection

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.


8. Testing CLI Tools: Three Layers

Testing a CLI tool requires three separate layers. Each has different setup and assertions.

Layer 1: Unit Tests for Logic

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");
  });
});

Layer 2: Integration Tests for CLI Behavior

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();
  });
});

Layer 3: Snapshot or E2E Tests

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

Testing Checklist

  • 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 json and --format table output paths
  • Test the --no-production flag includes devDependencies

9. Publishing to npm: The Checklist

Before npm publish, verify every item on this list. Each one has caused a real production incident.

Pre-publish checklist:

  1. "files" in package.json includes only dist/ -- This prevents source maps, test files, and config from leaking into the published package.
  2. "bin" points to the compiled .js file, not the .ts source.
  3. npm pack --dry-run shows only the files you expect.
  4. npx depaudit works after npm link -- This catches module resolution errors that node dist/index.js would miss.
  5. package.json has "engines" set -- Users on Node 18 should see a clear error, not a cryptic syntax error.
  6. README shows the exact install command and a five-second getting-started example.
  7. npm publish --tag beta first -- Test the install path before tagging latest.
# 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 beta

10. Decision Matrix: When to Extract a CLI Tool

Not 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.


11. The Full Audit Command

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:

  1. process.exitCode = 1 instead of process.exit(1). Setting exitCode lets Node.js finish flushing stdout/stderr before exiting. process.exit() kills the process immediately, which can truncate output.

  2. Version range stripping (replace(/^[\^~>=<]+/, "")). A package.json entry like "^2.1.0" is a range, not a version. The semver.diff() function needs exact versions. Strip the range operator before comparison.

  3. process.stdout.write() instead of console.log(). console.log() adds a trailing newline and goes to stdout by default, but it can be redirected. Explicit stdout.write() is more predictable, especially for machine-readable output.


Summary

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment