Hướng Dẫn Xây Dựng Công Cụ CLI Thực Tế Với Node.js và TypeScript
Bài viết trình bày chi tiết cách xây dựng một công cụ CLI hiệu quả bằng Node.js và TypeScript, tập trung vào trải nghiệm người dùng, xử lý lỗi rõ ràng, và các thành phần quan trọng như parsing argument, interactive prompts, spinner cho tác vụ dài, và quản lý cấu hình.

Hướng Dẫn Xây Dựng Công Cụ CLI Thực Tế Với Node.js và TypeScript
Công cụ dòng lệnh (CLI) là một phần không thể thiếu trong bộ công cụ của developer hiện đại. Các công cụ phổ biến như git, docker, npm hay gh đều sử dụng CLI, và điểm chung của chúng là sự mượt mà, rõ ràng trong cách hoạt động và hiển thị thông báo lỗi. Bài viết này sẽ hướng dẫn bạn từng bước xây dựng một CLI tool dùng Node.js và TypeScript, với những tiêu chí hàng đầu về tốc độ, tính thân thiện người dùng và khả năng mở rộng.
1. Tại sao cần một công cụ CLI tốt?
Một CLI tốt cần phải:
- Khởi động nhanh: Người dùng không mất thời gian chờ đợi khi gọi lệnh.
- Xử lý lỗi rõ ràng: Thông báo lỗi trực quan, dễ hiểu.
- Giao diện dòng lệnh thân thiện: Hỗ trợ help text đầy đủ, các lựa chọn rõ ràng.
- Tự động hóa các tác vụ phức tạp mà không gây phiền phức cho người dùng.
CLI phát huy hiệu quả nhất khi nó "vô hình" trong thao tác — làm việc đúng như bạn mong đợi mà không gây rối hay rườm rà.
2. Thiết lập môi trường với TypeScript và Node.js
Để bắt đầu, bạn tạo một thư mục dự án mới, cài đặt các gói cần thiết:
mkdir my-cli && cd my-cli
npm init -y
npm install commander chalk ora execa
npm install -D typescript @types/node ts-node tsup
Trong package.json, thêm phần "bin" để đăng ký câu lệnh CLI và các script build, dev:
{
"bin": {
"mycli": "./dist/index.js"
},
"scripts": {
"build": "tsup src/index.ts --format cjs --dts",
"dev": "ts-node src/index.ts"
}
}
commander được sử dụng để parse argument, chalk tô màu cho output, ora làm spinner loading, execa chạy các câu lệnh shell.
3. Xử lý parsing argument với Commander
Một ví dụ đơn giản cấu hình commander cho các lệnh init và deploy:
#!/usr/bin/env node
import { Command } from 'commander';
import { deploy } from './commands/deploy';
import { init } from './commands/init';
const program = new Command();
program
.name('mycli')
.description('Deploy and manage your applications')
.version('1.0.0');
program
.command('init')
.description('Initialize a new project')
.option('-t, --template <template>', 'project template', 'default')
.option('--no-git', 'skip git initialization')
.action(init);
program
.command('deploy')
.description('Deploy to production')
.argument('<environment>', 'target environment (staging|production)')
.option('-f, --force', 'skip confirmation prompts')
.option('--dry-run', 'preview what would be deployed')
.action(deploy);
program.parseAsync(process.argv);
Điều này giúp CLI của bạn có hệ thống lệnh rõ ràng, dễ hiểu, và dễ mở rộng.
4. Tạo output trực quan với Chalk
Để giúp thông tin đầu ra rõ ràng, bạn có thể định nghĩa các hàm log với màu sắc riêng:
import chalk from 'chalk';
export const log = {
info: (msg: string) => console.log(chalk.blue('ℹ'), msg),
success: (msg: string) => console.log(chalk.green('✓'), msg),
warning: (msg: string) => console.log(chalk.yellow('⚠'), msg),
error: (msg: string) => console.error(chalk.red('✗'), msg),
// Dùng cho output JSON khi chạy tự động
json: (data: unknown) => {
if (process.env.CI || !process.stdout.isTTY) {
console.log(JSON.stringify(data));
}
},
};
Màu sắc giúp người dùng phân biệt tính chất thông báo nhanh hơn.
5. Spinner cho các công việc mất thời gian
Dùng ora để tạo spinner hiển thị tiến trình khi deploy hoặc build:
import ora from 'ora';
export async function deploy(environment: string, options: { force: boolean }) {
const spinner = ora('Connecting to deployment service...').start();
try {
spinner.text = 'Building application...';
await buildApp();
spinner.text = `Deploying to ${environment}...`;
const result = await deployToEnvironment(environment);
spinner.succeed(`Deployed successfully → ${result.url}`);
} catch (error) {
spinner.fail(`Deployment failed: ${error.message}`);
process.exit(1);
}
}
Điều này không những giúp UX tốt hơn mà còn tạo cảm giác chuyên nghiệp hơn.
6. Tương tác với người dùng qua prompts
Sử dụng thư viện @inquirer/prompts để hỏi thông tin người dùng, xác nhận các thao tác:
import { input, confirm, select } from '@inquirer/prompts';
export async function init(options: { template: string; git: boolean }) {
const projectName = await input({
message: 'Project name:',
default: 'my-project',
validate: (value) => {
if (!value.match(/^[a-z0-9-]+$/)) return 'Use lowercase letters, numbers, and hyphens only';
return true;
},
});
const template = await select({
message: 'Select template:',
choices: [
{ name: 'SaaS Starter (Next.js + Stripe + Auth)', value: 'saas' },
{ name: 'API Server (Express + Prisma)', value: 'api' },
{ name: 'CLI Tool (Commander + TypeScript)', value: 'cli' },
],
});
if (!options.force) {
const confirmed = await confirm({
message: `Create ${projectName} with ${template} template?`,
});
if (!confirmed) process.exit(0);
}
// Tạo dự án sau đó…
}
Giao diện tương tác giúp người dùng kiểm soát tốt hơn việc tạo hoặc thiết lập dự án.
7. Chạy các câu lệnh shell qua Execa
Bạn có thể gọi các script build, test hoặc deploy một cách hiệu quả:
import { execa } from 'execa';
async function buildApp() {
const { stdout, stderr, exitCode } = await execa('npm', ['run', 'build'], {
cwd: process.cwd(),
stderr: 'pipe',
stdout: 'pipe',
});
if (exitCode !== 0) {
throw new Error(`Build failed:\n${stderr}`);
}
return stdout;
}
// Stream output thời gian thực
async function streamBuild() {
const proc = execa('npm', ['run', 'build']);
proc.stdout?.pipe(process.stdout);
proc.stderr?.pipe(process.stderr);
await proc;
}
execa cung cấp API mạnh mẽ và dễ dùng hơn so với child_process mặc định.
8. Quản lý file cấu hình
Để giữ các tùy chọn như API token hay môi trường mặc định, bạn nên sử dụng file cấu hình JSON trong thư mục home người dùng:
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';
interface Config {
apiUrl: string;
token?: string;
defaultEnvironment: string;
}
const CONFIG_PATH = join(process.env.HOME!, '.myclirc');
export function readConfig(): Partial<Config> {
if (!existsSync(CONFIG_PATH)) return {};
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
}
export function writeConfig(config: Partial<Config>) {
const existing = readConfig();
writeFileSync(CONFIG_PATH, JSON.stringify({ ...existing, ...config }, null, 2));
}
// Ví dụ sử dụng
// mycli config set token sk-abc123
// mycli deploy production // dùng token từ config
Cách này giúp người dùng không phải nhập lại nhiều lần các giá trị cố định.
9. Xử lý lỗi thân thiện
Thiết lập handler lỗi cấp cao để ghi nhận lỗi chưa được bắt và hiển thị hợp lý:
process.on('unhandledRejection', (error: Error) => {
log.error(error.message);
if (process.env.DEBUG) console.error(error.stack);
process.exit(1);
});
class CLIError extends Error {
constructor(message: string, public exitCode = 1) {
super(message);
}
}
throw new CLIError('Authentication failed. Run `mycli login` first.');
Thông báo lỗi nên rõ ràng, giúp người dùng dễ hiểu hướng sửa chữa.
10. Đóng gói và xuất bản
Sau khi hoàn thành, bạn build dự án bằng tsup, test bằng npm link rồi xuất bản lên npm:
npm run build
npm link
mycli --version
npm publish --access public
Người dùng chỉ cần cài đặt global:
npm install -g mycli
Kết luận
Sự khác biệt giữa một CLI được sử dụng rộng rãi với một CLI bị bỏ quên chính là: khởi động nhanh, thông báo lỗi rõ ràng, và tài liệu hướng dẫn đầy đủ. Nếu bạn làm tốt 3 yếu tố này, bạn đã đi được 80% quãng đường để tạo ra công cụ dòng lệnh được yêu thích.
Đối với các nhà phát triển muốn có giải pháp CLI kèm backend SaaS đầy đủ, các bộ template như Whoff Agents Ship Fast Skill Pack sẽ rất hữu dụng, cung cấp pattern, API template và script triển khai sẵn có.
Bài viết liên quan

Phần mềm
Anthropic ra mắt Claude Opus 4.7: Nâng cấp mạnh mẽ cho lập trình nhưng vẫn thua Mythos Preview
16 tháng 4, 2026

Công nghệ
Qwen3.6-35B-A3B: Quyền năng Lập trình Agentic, Nay Đã Mở Cửa Cho Tất Cả
16 tháng 4, 2026

Công nghệ
Spotify thắng kiện 322 triệu USD từ nhóm pirate Anna's Archive nhưng đối mặt với bài toán thu hồi
16 tháng 4, 2026
