Building a CLI Tool with Node.js: From Zero to npm
A step-by-step guide to building, testing, and publishing a command-line tool.
Why Build a CLI?
- Solves YOUR problem (automate repetitive tasks)
- Portfolio piece (shows you can ship complete tools)
- Potential income (people pay for useful CLIs)
- Fun to build (instant gratification — no UI needed)
What We'll Build
A jsonfmt tool that formats JSON files in place:
$ cat messy.json
{"name":"Alex","age":30,"skills":["js","python"]}
$ jsonfmt messy.json
$ cat messy.json
{
"name": "Alex",
"age": 30,
"skills": [
"js",
"python"
]
}
Step 1: Project Setup
mkdir jsonfmt && cd jsonfmt
npm init -y
# Install dependencies
npm install commander chalk fs-extra
# Install dev dependencies
npm install -D jest typescript @types/node tsup
// package.json
{
"name": "jsonfmt",
"version": "1.0.0",
"description": "Format JSON files beautifully",
"type": "module", // ESM imports!
"bin": {
"jsonfmt": "./dist/cli.js"
},
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup src/index.ts src/cli.js --format esm --dts",
"dev": "node --watch src/cli.js",
"test": "jest"
},
"engines": { "node": ">=16" },
"license": "MIT"
}
Step 2: Core Logic
// src/index.ts
import { readFile, writeFile } from 'fs-extra';
import chalk from 'chalk';
export interface FormatOptions {
indent?: number;
sort?: boolean;
validate?: boolean;
}
const DEFAULTS: Required<FormatOptions> = {
indent: 2,
sort: false,
validate: true,
};
/**
* Format a JSON string with options.
*/
export function formatJson(text: string, options: FormatOptions = {}): string {
const opts = { ...DEFAULTS, ...options };
let parsed = JSON.parse(text);
if (opts.sort) {
parsed = sortObject(parsed);
}
return JSON.stringify(parsed, null, opts.indent) + '\n';
}
/**
* Format a JSON file in-place.
*/
export async function formatFile(
filePath: string,
options: FormatOptions = {}
): Promise<{ before: number; after: number }> {
const content = await readFile(filePath, 'utf8');
const formatted = formatJson(content, options);
await writeFile(filePath, formatted, 'utf8');
return {
before: content.length,
after: formatted.length
};
}
/** Recursively sort object keys */
function sortObject(obj: unknown): unknown {
if (Array.isArray(obj)) {
return obj.map(sortObject);
}
if (obj !== null && typeof obj === 'object') {
return Object.keys(obj)
.sort()
.reduce<Record<string, unknown>>((acc, key) => {
acc[key] = sortObject((obj as Record<string, unknown>)[key]);
return acc;
}, {});
}
return obj;
}
/** Validate JSON without throwing */
export function validateJson(text: string): { valid: boolean; error?: string } {
try {
JSON.parse(text);
return { valid: true };
} catch (err) {
return { valid: false, error: (err as Error).message };
}
}
Step 3: CLI Interface
// src/cli.ts
#!/usr/bin/env node
import { Command } from 'commander';
import chalk from 'chalk';
import { formatFile, validateJson, formatJson } from './index.js';
const program = new Command()
.name('jsonfmt')
.description('Format JSON files beautifully')
.version('1.0.0');
program
.argument('[files...]', 'JSON files to format')
.option('-i, --indent <number>', 'Indentation spaces', '2')
.option('-s, --sort', 'Sort keys alphabetically', false)
.option('-w, --write', 'Write file in-place', false)
.option('-c, --check', 'Check if formatted (exit code 1 if not)', false)
.option('--stdin', 'Read from stdin', false)
.action(async (files, opts) => {
try {
// Stdin mode
if (opts.stdin || !files.length) {
let input = '';
for await (const chunk of process.stdin) {
input += chunk;
}
const validation = validateJson(input);
if (!validation.valid) {
console.error(chalk.red(`Invalid JSON: ${validation.error}`));
process.exit(1);
}
console.log(formatJson(input, {
indent: parseInt(opts.indent),
sort: opts.sort
}));
return;
}
// File mode
let unformattedCount = 0;
for (const file of files) {
const validation = validateJson(await import('fs/promises').then(fs => fs.readFile(file, 'utf8')));
// Simplified: read and check
const result = await formatFile(file, {
indent: parseInt(opts.indent),
sort: opts.sort,
});
if (opts.check) {
// Just check, don't write
if (result.before !== result.after) {
console.log(chalk.yellow(file));
unformattedCount++;
}
} else if (opts.write) {
console.log(chalk.green(`✓ ${file}`));
} else {
// Print to stdout
const output = formatJson(
await import('fs/promises').then(fs => fs.readFile(file, 'utf8')),
{ indent: parseInt(opts.indent), sort: opts.sort }
);
console.log(output);
}
}
if (opts.check && unformattedCount > 0) {
process.exit(1); // Exit code 1 = not formatted
}
} catch (err) {
console.error(chalk.red(`Error: ${(err as Error).message}`));
process.exit(1);
}
});
program.parse();
Step 4: Tests
// tests/index.test.ts
import { describe, it, expect } from '@jest/globals';
import { formatJson, validateJson, formatFile } from '../src/index.js';
import { writeFile, unlink } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
describe('formatJson', () => {
it('formats minified JSON', () => {
const input = '{"a":1,"b":2}';
const expected = '{\n "a": 1,\n "b": 2\n}\n';
expect(formatJson(input)).toBe(expected);
});
it('respects indent option', () => {
const input = '{"a":1}';
expect(formatJson(input, { indent: 4 })).toBe('{\n "a": 1\n}\n');
});
it('sorts keys when option is set', () => {
const input = '{"z":1,"a":2,"m":3}';
const result = JSON.parse(formatJson(input, { sort: true }));
expect(Object.keys(result)).toEqual(['a', 'm', 'z']);
});
});
describe('validateJson', () => {
it('returns valid for correct JSON', () => {
expect(validateJson('{"a":1}')).toEqual({ valid: true });
});
it('returns error for invalid JSON', () => {
const result = validateJson('not json');
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('formatFile', () => {
it('formats a file in place', async () => {
const filePath = join(tmpdir(), `test-${Date.now()}.json`);
await writeFile(filePath, '{"x":1}');
const result = await formatFile(filePath);
expect(result.before).toBeLessThan(result.after);
const content = await import('fs/promises').then(fs => fs.readFile(filePath, 'utf8'));
expect((await content).includes('\n')).toBe(true);
await unlink(filePath);
});
});
Step 5: Build & Test Locally
# Build
npm run build
# Test
npm test
# Link globally (for local testing)
npm link
# Now you can use it anywhere:
jsonfmt package.json --check --write
echo '{"messy":true}' | jsonfmt --stdin
Step 6: Publish to npm
# Login (first time only)
npm login
# Dry run (validates everything without publishing)
npm publish --dry-run
# Publish!
npm publish
# Anyone can now install:
npm install -g jsonfmt
# And use:
jsonfmt data.json --write --sort
Pro Tips for CLI Development
Colors Make Everything Better
import chalk from 'chalk';
console.log(chalk.green('✓ Success'));
console.log(chalk.red('✗ Error'));
console.log(chalk.yellow('⚠ Warning'));
console.log(chalk.blue('ℹ Info'));
console.log(chalk.bold('Important!'));
console.log(chalk.dim('(debug info)'));
Progress Indicators
import ora from 'ora';
const spinner = ora('Processing files...').start();
// ... do work ...
spinner.text = `Processed ${i}/${total} files`;
spinner.succeed(`Done! Processed ${total} files`);
spinner.fail('Something went wrong');
User-Friendly Config Files
// Support .jsonfmtrc config files
import { readFileSync, existsSync } from 'fs';
import { resolve, join } from 'path';
function loadConfig(cwd: string) {
const paths = [
join(cwd, '.jsonfmtrc'),
join(cwd, '.jsonfmtrc.json'),
join(cwd, '.jsonfmtrc.yaml'),
];
for (const p of paths) {
if (existsSync(p)) {
return JSON.parse(readFileSync(p, 'utf8'));
}
}
return {}; // No config found, use defaults
}
Tab Completion
# Add to package.json "scripts":
# "postinstall": "tabtab install --name jsonfmt"
# Then users get auto-completion after installing!
$ jsonfmt [TAB]
--help --indent --sort --write --check --stdin
The Complete File Structure
jsonfmt/
├── src/
│ ├── index.ts # Core logic (exportable)
│ └── cli.ts # CLI interface
├── tests/
│ └── index.test.ts # Tests
├── package.json
├── tsconfig.json
├── README.md # Critical!
└── LICENSE
What Makes a Great CLI?
| Feature | Example |
|---|---|
| Does ONE thing well |
jq for JSON, rg for grep |
| Sensible defaults | Don't make me configure everything |
| Fast | Start time < 200ms |
| Helpful errors | Tell me what went wrong AND how to fix it |
| Pipe-friendly | Works with stdin/stdout |
| Good docs | README with examples within 30 seconds |
| Auto-complete | Tab completion saves typing |
What CLI tool would make your life easier? Maybe build it this weekend.
Follow @armorbreak for more Node.js guides.
Top comments (0)