DEV Community

AW
AW

Posted on

How to Build and Ship a Node.js CLI Tool: From Zero to npm

Here is a step-by-step guide to building, testing, and publishing a production-quality Node.js CLI tool on npm — with TypeScript, automated testing, and CI in mind.

I've published two Node.js CLI tools to npm this year. Both are open source, both have tests, both are gzipped smaller than the average JPEG. And both taught me that shipping a CLI tool is surprisingly straightforward once you have the right setup.

This tutorial walks through everything I learned: project structure, TypeScript configuration, argument parsing, testing strategies, and publishing. By the end, you'll have a production-quality CLI you can ship to npm.

What we're building

A simple but useful converter CLI — the kind of tool every developer needs at some point. It accepts commands like:

mycli json yaml package.json
mycli ts 1748226460
mycli b64 encode "hello world"
Enter fullscreen mode Exit fullscreen mode

Each converter is a self-contained module, so adding new ones takes minutes.

Step 1: Project setup

Start with the basics:

mkdir mycli && cd mycli
npm init -y
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

npm install --save-dev typescript @types/node
npx tsc --init
Enter fullscreen mode Exit fullscreen mode

Your tsconfig.json should target Node.js 18+ and use ESM-compatible output:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "outDir": "./bin",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"]
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Project structure

The structure that scales well:

mycli/
├── bin/              # compiled output
├── src/
│   ├── converters/   # one file per converter
│   │   ├── json-yaml.ts
│   │   ├── timestamp.ts
│   │   ├── base64.ts
│   │   └── ...
│   ├── cli.ts        # argument parsing + routing
│   └── index.ts      # exports for programmatic use
├── tests/            # test files
├── package.json
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

The key insight: each converter exports a simple function with a consistent signature. This makes them testable independently and trivially extensible.

// src/converters/timestamp.ts
export interface TimestampResult {
  iso: string;
  utc: string;
  locale: string;
  seconds: number;
  milliseconds: number;
}

export function convertTimestamp(input: string): TimestampResult {
  const ms = input === "now" ? Date.now() : parseInt(input) * 1000;
  const date = new Date(ms);
  return {
    iso: date.toISOString(),
    utc: date.toUTCString(),
    locale: date.toLocaleString(),
    seconds: Math.floor(ms / 1000),
    milliseconds: ms,
  };
}
Enter fullscreen mode Exit fullscreen mode

Step 3: CLI entry point

The CLI entry point lives in src/cli.ts. It parses arguments and routes to the right converter:

#!/usr/bin/env node
import { convertTimestamp } from "./converters/timestamp.js";
import { convertColor } from "./converters/color.js";
// ... etc

const [command, subcommand, ...args] = process.argv.slice(2);

switch (command) {
  case "ts":
    console.log(convertTimestamp(args[0] ?? "now"));
    break;
  case "color":
    console.log(convertColor(args[0]));
    break;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

In package.json, point the bin field to the compiled entry point:

{
  "bin": {
    "mycli": "./bin/cli.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

Use "type": "module" in package.json for ESM, and make sure your tsconfig outputs ESM-compatible JavaScript.

Step 4: Testing

CLI tools need three kinds of tests:

Unit tests for converters. These are the simplest and most valuable:

// tests/timestamp.test.ts
import { describe, it, expect } from "vitest";
import { convertTimestamp } from "../src/converters/timestamp.js";

describe("timestamp converter", () => {
  it("converts a Unix timestamp to ISO", () => {
    const result = convertTimestamp("1748226460");
    expect(result.iso).toBe("2025-05-26T10:27:40.000Z");
  });

  it("handles 'now' keyword", () => {
    const before = Date.now();
    const result = convertTimestamp("now");
    const after = Date.now();
    expect(result.milliseconds).toBeGreaterThanOrEqual(before);
    expect(result.milliseconds).toBeLessThanOrEqual(after);
  });

  it("throws on empty input", () => {
    expect(() => convertTimestamp("")).toThrow();
  });
});
Enter fullscreen mode Exit fullscreen mode

Edge case tests for input validation:

it("rejects garbage input", () => {
  expect(() => convertTimestamp("not-a-number")).toThrow();
});

it("handles very large timestamps", () => {
  const result = convertTimestamp("4102444800"); // year 2100
  expect(result.iso).toContain("2100");
});
Enter fullscreen mode Exit fullscreen mode

Integration tests for the CLI itself:

it("CLI outputs valid JSON for timestamp command", async () => {
  const { stdout } = await exec("node bin/cli.js ts 1748226460");
  const parsed = JSON.parse(stdout);
  expect(parsed.iso).toBeDefined();
});
Enter fullscreen mode Exit fullscreen mode

Running 70 tests with vitest takes under 2 seconds. This gives you confidence to add converters without breaking existing ones.

Step 5: Handling the tricky parts

Input validation. Always validate before converting. A converter that silently returns NaN or undefined is worse than one that throws:

if (!input || input.trim() === "") {
  throw new Error(
    "timestamp: expected a Unix timestamp (number) or 'now', got empty input"
  );
}
Enter fullscreen mode Exit fullscreen mode

Format auto-detection. The color converter needs to accept hex, RGB, and HSL — and return all three. Parse the input to figure out which format it is, then convert to the other two. This is where most of the interesting logic lives.

Cross-platform compatibility. A test that passes on macOS might fail on Windows. Use os.EOL instead of \n, avoid shell-specific features, and test on both platforms if you can.

Step 6: Publishing to npm

Before your first publish:

  1. Check name availabilitynpm view <name> to see if it's taken
  2. Set up .gitignore — exclude node_modules/, bin/ (if you rebuild on install), and *.tsbuildinfo
  3. Add a README — explain what it does, show usage examples, include badges for version, downloads, and bundle size

Publishing flow:

npm run build
npm test
npm version patch  # or minor, or major
npm publish
Enter fullscreen mode Exit fullscreen mode

Use a granular access token for CI/CD (the old npm token create approach, not passkey auth which doesn't work for automation).

Step 7: What to track after shipping

After publishing, monitor:

  • Downloadsnpm view <name> downloads for weekly trends
  • Issues — watch your GitHub repo for bug reports and feature requests
  • Bundle size — use bundlephobia.com to track size over time

The first week will likely see a trickle of downloads (my first tool got ~45/week, the second ~33/week). That's normal. Growth comes from articles like this one, Show HN posts, and word of mouth.

The results

After publishing, I had:

  • 5.5 kB gzipped bundle — smaller than a single ad on most converter websites
  • 70 passing tests — covering every converter, edge case, and CLI behavior
  • Zero runtime dependencies — installs instantly, no network calls at runtime
  • Cross-platform — works on macOS, Linux, and Windows

The tool works offline. Data never leaves your machine. And it's pipeable, scriptable, and composable with the rest of your toolchain.

Key takeaways

  1. CLI tools are underrated. They're faster, more private, and more composable than any website. If you find yourself visiting the same converter site more than once a week, you should build a CLI for it.

  2. Start with tests. Each converter is a pure function — input in, output out. These are the easiest tests you'll ever write, and they give you confidence to iterate quickly.

  3. Ship early. Your first version doesn't need every converter under the sun. Ship with 3-4 converters, get feedback, then add more. The npm publish cycle takes 30 seconds.

  4. Polish the README. Your README is your marketing page. Show examples, include a comparison table, add badges. It makes the difference between someone trying your tool and scrolling past.

The full source code is available at github.com/amitwaks/anyconv if you want to see the complete implementation. And the published package is on npm at anyconv.

Now go build something. Your terminal is waiting.

Top comments (0)