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"
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
Install dependencies:
npm install --save-dev typescript @types/node
npx tsc --init
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/**/*"]
}
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
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,
};
}
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;
// ...
}
In package.json, point the bin field to the compiled entry point:
{
"bin": {
"mycli": "./bin/cli.js"
}
}
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();
});
});
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");
});
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();
});
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"
);
}
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:
-
Check name availability —
npm view <name>to see if it's taken -
Set up .gitignore — exclude
node_modules/,bin/(if you rebuild on install), and*.tsbuildinfo - 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
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:
-
Downloads —
npm view <name> downloadsfor 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
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.
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.
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.
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)