ข้ามไปเนื้อหาหลัก

Category: guide

Node.js CLI Tools — commander, inquirer, chalk, ora

สร้าง CLI tool ด้วย Node.js: parse args ด้วย commander, prompts ด้วย inquirer, colors ด้วย chalk, spinner ด้วย ora

· อ่านประมาณ 4 นาที

สารบัญ

Setup Project

mkdir my-cli && cd my-cli
npm init -y

# TypeScript + Execution
npm install --save-dev typescript tsx @types/node
npm install commander chalk ora inquirer
// package.json
{
  "name": "my-cli",
  "bin": { "my-cli": "./dist/index.js" },
  "type": "module",
  "scripts": {
    "dev": "tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Commander.js — Argument Parsing

import { program } from 'commander';

program
  .name('my-cli')
  .description('CLI tool description')
  .version('1.0.0');

// Command
program
  .command('convert <input>')          // required arg
  .description('Convert a file')
  .option('-o, --output <path>', 'Output path', './output')
  .option('-f, --format <type>', 'Format: html|pdf|md', 'html')
  .option('-v, --verbose', 'Show verbose output')
  .action(async (input, options) => {
    console.log('Input:', input);
    console.log('Output:', options.output);
    console.log('Format:', options.format);
    console.log('Verbose:', options.verbose);
  });

// Sub-command
program
  .command('init [directory]')         // optional arg
  .description('Initialize project in directory')
  .option('--no-git', 'Skip git init')
  .action(async (directory = '.', options) => {
    const dir = directory ?? '.';
    // ...
  });

program.parse();

Chalk — Terminal Colors

import chalk from 'chalk';

// Colors
console.log(chalk.green('✓ Success'));
console.log(chalk.red('✗ Error'));
console.log(chalk.yellow('⚠ Warning'));
console.log(chalk.blue('ℹ Info'));

// Styles
console.log(chalk.bold('Bold text'));
console.log(chalk.dim('Dimmed text'));
console.log(chalk.underline('Underlined'));

// Combinations
console.log(chalk.bold.green('Bold green'));
console.log(chalk.bgRed.white('White on red'));

// Template literal
console.log(chalk`{bold.blue INFO} {gray Processing...}`);

// Hex color
console.log(chalk.hex('#ff6b6b')('Custom color'));

Helper Functions

import chalk from 'chalk';

const log = {
  success: (msg: string) => console.log(`${chalk.green('✓')} ${msg}`),
  error:   (msg: string) => console.error(`${chalk.red('✗')} ${chalk.red(msg)}`),
  warn:    (msg: string) => console.log(`${chalk.yellow('⚠')} ${chalk.yellow(msg)}`),
  info:    (msg: string) => console.log(`${chalk.blue('ℹ')} ${msg}`),
  dim:     (msg: string) => console.log(chalk.dim(msg)),
};

log.success('File converted successfully');
log.error('Permission denied');
log.warn('Output directory already exists');

Ora — Spinners

import ora from 'ora';

// Basic spinner
const spinner = ora('Converting file...').start();

try {
  await doLongTask();
  spinner.succeed('Conversion complete');
} catch (err) {
  spinner.fail('Conversion failed');
}

// Change text during task
const spinner = ora('Downloading...').start();
for (let i = 0; i <= 100; i += 10) {
  spinner.text = `Downloading... ${i}%`;
  await delay(100);
}
spinner.succeed('Downloaded!');

// Custom colors and symbols
const spinner = ora({
  text: 'Processing...',
  color: 'cyan',
  spinner: 'dots2', // or 'line', 'star', 'bouncingBar', etc.
}).start();

Inquirer — Interactive Prompts

import inquirer from 'inquirer';

const answers = await inquirer.prompt([
  {
    type: 'input',
    name: 'projectName',
    message: 'Project name:',
    default: 'my-project',
    validate: (input) => input.length > 0 || 'Project name cannot be empty',
  },
  {
    type: 'list',
    name: 'format',
    message: 'Output format:',
    choices: ['html', 'pdf', 'markdown'],
    default: 'html',
  },
  {
    type: 'checkbox',
    name: 'features',
    message: 'Select features:',
    choices: [
      { name: 'TypeScript', value: 'ts', checked: true },
      { name: 'ESLint', value: 'lint' },
      { name: 'Prettier', value: 'format' },
      { name: 'Tests (Vitest)', value: 'test' },
    ],
  },
  {
    type: 'confirm',
    name: 'init',
    message: 'Run npm install?',
    default: true,
  },
  {
    type: 'password',
    name: 'apiKey',
    message: 'API key:',
    mask: '*',
  },
]);

console.log(answers.projectName);   // string
console.log(answers.format);        // 'html' | 'pdf' | 'markdown'
console.log(answers.features);      // string[]
console.log(answers.init);          // boolean

Exit Codes

process.exit(0); // success
process.exit(1); // general error
process.exit(2); // misuse of CLI

Commander ออก exit 1 อัตโนมัติเมื่อ parse error


การอ่าน stdin

import { createInterface } from 'node:readline';

// อ่านทีละบรรทัด
const rl = createInterface({ input: process.stdin });

for await (const line of rl) {
  console.log('Line:', line);
}
// อ่านทั้งหมดเป็น string
async function readStdin(): Promise<string> {
  const chunks: Buffer[] = [];
  for await (const chunk of process.stdin) {
    chunks.push(chunk);
  }
  return Buffer.concat(chunks).toString('utf-8');
}

// ใช้:
const input = await readStdin();

Full Example: File Converter CLI

#!/usr/bin/env node
// src/index.ts

import { program } from 'commander';
import chalk from 'chalk';
import ora from 'ora';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';

program
  .name('converter')
  .description('Convert markdown files to HTML or PDF')
  .version('1.0.0');

program
  .command('convert <input>')
  .option('-o, --output <dir>', 'Output directory', 'output')
  .option('-f, --format <type>', 'Output format (html|pdf)', 'html')
  .action(async (input: string, options: { output: string; format: string }) => {
    if (!existsSync(input)) {
      console.error(chalk.red(`✗ File not found: ${input}`));
      process.exit(1);
    }

    await mkdir(options.output, { recursive: true });

    const spinner = ora(`Converting ${input}...`).start();

    try {
      const content = await readFile(input, 'utf-8');
      const outputName = path.basename(input, '.md') + `.${options.format}`;
      const outputPath = path.join(options.output, outputName);

      await writeFile(outputPath, `<html>${content}</html>`); // simplified
      spinner.succeed(chalk.green(`Converted → ${outputPath}`));
    } catch (err) {
      spinner.fail(chalk.red(`Failed: ${(err as Error).message}`));
      process.exit(1);
    }
  });

program.parse();

# ทดสอบ local
npm run build
npm link

# ใช้งาน global
my-cli convert file.md

# unlink
npm unlink my-cli

# publish
npm publish --access public