DEV Community

Alex Chen
Alex Chen

Posted on

Building a CLI Tool with Node.js: From Zero to npm

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"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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"
}
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 6: Publish to npm

# Login (first time only)
npm login

# Dry run (validates everything without publishing)
npm publish --dry-run

# Publish!
npm publish
Enter fullscreen mode Exit fullscreen mode
# Anyone can now install:
npm install -g jsonfmt

# And use:
jsonfmt data.json --write --sort
Enter fullscreen mode Exit fullscreen mode

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)'));
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)