· bun / typescript / cli

Cách viết CLI bằng TypeScript với Bun và Commander

Bun + Commander là lựa chọn tốt nhất để viết TypeScript CLI: chạy TS trực tiếp, không cần build, và xuất binary độc lập. Hướng dẫn từng bước kèm code thực tế.

Bởi

1.793 từ · 9 phút đọc

Nếu bạn cần một TypeScript CLI trong năm 2026, Bun và Commander là lựa chọn đúng đắn. Bun chạy TypeScript trực tiếp — không cần bước biên dịch, khởi động nhanh — và Commander.js là thư viện parse argument được tải nhiều nhất, gấp đôi đối thủ gần nhất. Bài viết này dẫn bạn từ bun init đến một binary độc lập có thể phân phối được.

Bài viết này dành cho ai

Bạn đã viết TypeScript script trước đây. Nhưng chưa đóng gói nó thành một CLI hoàn chỉnh — với subcommand, --help, và binary người dùng có thể cài global. Đây chính xác là những gì bài viết này hướng dẫn.

Tại sao chọn Bun

CLI TypeScript trên Node cần một bước transpile trước khi chạy: ts-node, tsx, hoặc một build script xuất ra dist/cli.js. Bun bỏ qua toàn bộ bước đó. bun cli.ts chạy ngay lập tức. Không cần trick trong tsconfig, không mất thời gian chờ ts-node load.

Ba lý do khiến Bun là lựa chọn mặc định cho CLI:

Native TypeScript. Không có transpiler trong dev loop. Viết, lưu, chạy. Bun còn đi kèm shell API $ và built-in fetch nếu cần — không phải import thêm gì.

Khởi động nhanh. Bun khởi động lạnh nhanh hơn đáng kể so với CLI trên Node chạy qua ts-node hoặc tsx — xem Bun vs Node để biết benchmark và phương pháp đo. Khoảng chênh lệch này rõ nhất trong workflow tương tác, nơi người dùng liên tục gọi CLI.

Biên dịch thành binary độc lập. bun build --compile đóng gói code và Bun runtime vào một file duy nhất. Máy đích không cần runtime, không cần ship node_modules. Binary mang theo toàn bộ Bun runtime — Bun thẳng thắn thừa nhận trong docs: “Bun’s binary is still way too big.” Go CLI thì nhỏ hơn nhiều. Nếu kích thước quan trọng với bạn, đây là đánh đổi cần cân nhắc ở TypeScript vs Go.

Nếu bạn đang so sánh Bun và Node cho scripting nói chung, Bun vs Node có bức tranh đầy đủ hơn. Bài này tập trung riêng vào CLI.

Tại sao chọn Commander

Commander.js đạt ~413 triệu lượt tải mỗi tuần (tháng 5/2026). Yargs đứng sau với ~194 triệu. Oclif — framework được Heroku CLI dùng — đạt ~318 nghìn. Commander có lượt tải gấp đôi đối thủ gần nhất.

Những gì Commander v14 cung cấp:

  • Boolean flags, value options, required options, variadic arguments, negatable booleans
  • Subcommand với nested .action() handlers
  • --help--version được tạo tự động
  • TypeScript types đi kèm sẵn — không cần cài @types/commander (bundled từ v8)
  • Không phụ thuộc runtime
  • Strict theo mặc định: option không khai báo bị throw ngay

Một lưu ý về version trước khi bắt đầu. Commander v15 đang trong pre-release (tháng 5/2026) và chỉ hỗ trợ ESM. Nó yêu cầu Node.js v22.12+ cho CommonJS interop. Bun xử lý ESM natively nên sẽ hoạt động, nhưng dùng pre-release cho production tooling là rủi ro không cần thiết. Ghim vào v14.0.3. Version này được cập nhật bảo mật ít nhất đến tháng 5/2027.

Bước 1 — Khởi tạo project

Bạn cần cài Bun. Nếu chưa:

curl -fsSL https://bun.sh/install | bash

Rồi tạo project:

mkdir mycli && cd mycli
bun init -y
bun add commander@^14.0.3

bun init tạo ra package.json, tsconfig.json tối giản và file entry. Chỉ vậy thôi. Commander là dependency duy nhất.

Bước 2 — Viết CLI command đầu tiên

Thay hoặc tạo cli.ts:

#!/usr/bin/env bun
import { Command } from 'commander';

const program = new Command();

program
  .name('greet')
  .description('A minimal CLI to greet people')
  .version('1.0.0');

program
  .argument('<name>', 'person to greet')
  .option('-l, --loud', 'shout the greeting')
  .action((name, options) => {
    const msg = `Hello, ${name}!`;
    console.log(options.loud ? msg.toUpperCase() : msg);
  });

program.parse();

Chạy thử:

bun run cli.ts World
# Hello, World!

bun run cli.ts World --loud
# HELLO, WORLD!

bun run cli.ts --help
# Usage: greet [options] <name>
#
# Arguments:
#   name        person to greet
#
# Options:
#   -l, --loud  shout the greeting
#   -V, --version  output the version number
#   -h, --help  display help for command

Một vài điểm cần chú ý. .argument('<name>', 'mô tả') khai báo positional argument bắt buộc — dấu ngoặc nhọn là bắt buộc, dấu ngoặc vuông là tùy chọn. .option('-l, --loud', 'mô tả') đăng ký boolean flag. Value options trông như .option('-p, --port <number>', 'port', '3000') — argument thứ ba là giá trị mặc định.

program.parse() không có argument thì đọc từ process.argv. Trong Bun, mảng này có layout giống Node: index 0 là bun, index 1 là đường dẫn script, rồi mới đến argument thực tế. Commander xử lý đúng ngay từ đầu.

Shebang #!/usr/bin/env bun ở đầu file cho phép chạy trực tiếp trên Unix sau khi chmod +x cli.ts. Windows bỏ qua shebang — binary đã compile (Bước 5) mới là cách đúng trên Windows.

Bước 3 — Thêm subcommand

Hầu hết CLI thực tế cần subcommand: mycli init, mycli build, mycli deploy. Commander định tuyến dựa trên positional argument đầu tiên:

#!/usr/bin/env bun
import { Command } from 'commander';

const program = new Command();

program
  .name('devtool')
  .description('Developer productivity CLI')
  .version('1.0.0');

program.command('init')
  .description('Scaffold a new project')
  .argument('<name>', 'project name')
  .option('--template <type>', 'starter template', 'minimal')
  .action((name, options) => {
    console.log(`Creating ${name} with template: ${options.template}`);
  });

program.command('build')
  .description('Build for production')
  .option('--minify', 'enable minification')
  .action((options) => {
    console.log(`Building${options.minify ? ' (minified)' : ''}…`);
  });

program.parse();
bun run cli.ts init my-project --template react
# Creating my-project with template: react

bun run cli.ts build --minify
# Building (minified)…

bun run cli.ts --help
# Usage: devtool [options] [command]
#
# Commands:
#   init <name>    Scaffold a new project
#   build          Build for production

Mỗi subcommand là một Commander instance độc lập gắn vào root. Option khai báo trong subcommand không ảnh hưởng đến subcommand khác hay root. Nếu bạn cần option dùng chung, khai báo nó trên program root trước khi đăng ký subcommand.

Với CLI lớn hơn, nơi mỗi subcommand có file riêng, Commander hỗ trợ external subcommand files qua .command('serve', 'start the server') — argument thứ hai báo Commander tìm file <program>-serve.ts. Pattern này giúp mọi thứ gọn gàng khi bạn có 10+ subcommand.

Bước 4 — Đóng gói để phân phối qua npm

Để CLI của bạn cài được qua npm install -g hoặc bun install -g, thêm field bin vào package.json:

{
  "name": "mycli",
  "version": "1.0.0",
  "bin": {
    "mycli": "./cli.ts"
  },
  "dependencies": {
    "commander": "^14.0.3"
  }
}

Kiểm tra local:

bun install -g .
mycli --help

Khi người dùng cài package, package manager tạo một shim chạy file entry qua shebang. Cách này hoạt động với người dùng đã cài Bun. Với binary chạy được mọi nơi — không cần Bun trên máy đích — thì compile mới là cách đúng.

Bước 5 — Biên dịch thành binary độc lập

bun build --compile đóng gói TypeScript của bạn cùng Bun runtime thành một file thực thi:

# Build cơ bản
bun build ./cli.ts --compile --outfile mycli

# Cross-compile cho Linux từ macOS
bun build ./cli.ts --compile --target=bun-linux-x64 --outfile mycli-linux

# Production: minify + sourcemap + bytecode (startup nhanh gấp 2×)
bun build ./cli.ts --compile --minify --sourcemap --bytecode --outfile mycli

Các target có sẵn: bun-linux-x64, bun-linux-arm64, bun-darwin-x64, bun-darwin-arm64, bun-windows-x64.

Chạy trực tiếp — không cần cài Bun:

./mycli World --loud
# HELLO, WORLD!

Flag --bytecode precompile JavaScript bytecode và mang lại khoảng 2× cải thiện startup trên nền Bun vốn đã nhanh. Dùng cho release build. Bỏ qua trong development khi bạn đang iterate trực tiếp trên file .ts.

Sáu điểm cần lưu ý

1. allowExcessArguments thay đổi từ v13

Commander v13.0.0 đổi allowExcessArguments thành false. Trước version đó, argument thừa bị bỏ qua im lặng. Các tutorial cũ gọi program.action((options) => ...) mà không khai báo argument sẽ báo lỗi khó hiểu sau khi upgrade.

Cách xử lý: khai báo argument rõ ràng bằng .argument(). Nếu bạn thực sự cần nhận arbitrary extra args, opt in lại bằng .allowExcessArguments(true).

2. Kích thước binary có giới hạn tối thiểu

Bun runtime được đóng gói toàn bộ vào mỗi binary — không có flag nào để giảm bớt. Nếu bạn phân phối cho người dùng cuối vốn quen với file nhỏ, hoặc cần binary gọn như Go, hãy đọc TypeScript vs Go trước khi quyết định.

3. Windows tự thêm .exe

bun build --compile --target=bun-windows-x64 --outfile mycli tự tạo ra mycli.exe. Viết --outfile mycli, không phải --outfile mycli.exe. Cái sau cho bạn mycli.exe.exe.

4. Shebang không hoạt động trên Windows

#!/usr/bin/env bun bị Windows bỏ qua. Để phân phối cho người dùng Windows, binary đã compile (bun build --compile) mới là cách đúng — không phải phương pháp shebang từ Bước 2.

5. Commander v15 vẫn đang pre-release

Commander v15 chỉ hỗ trợ ESM và đang pre-release (tháng 5/2026). Bun xử lý ESM natively, nhưng ship trên thư viện pre-release là gánh nặng hỗ trợ không đáng. Dùng v14. Version này được maintain và tiếp tục được cập nhật.

6. Layout của process.argv trong Bun

process.argv[0]bun, process.argv[1] là đường dẫn script. Giống Node. program.parse() không có argument xử lý đúng điều này. Bạn không cần program.parse(process.argv) hay slice thủ công.

Khi nào chọn Yargs hoặc Oclif

Yargs phù hợp khi bạn cần async middleware chain. Yargs có API .middleware() chạy trước action handler — hữu ích cho authentication check, config loading, hoặc logging cần hoàn thành trước khi command chạy. Tính năng còn lại tương đương Commander. TypeScript types đến qua @types/yargs, không bundled sẵn. Bundle nặng hơn một chút.

Oclif hợp lý khi bạn đang xây dựng một platform — CLI mà các team khác mở rộng bằng plugin. Heroku CLI và Salesforce CLI đều dùng Oclif. Oclif sinh boilerplate, áp đặt cấu trúc project, và có documented plugin API. Đánh đổi: ~1.400× ít lượt tải hàng tuần hơn Commander, API dựa trên class cần nhiều scaffolding hơn, và kiến trúc quá phức tạp cho bất kỳ CLI nào bạn tự làm một mình. Nếu muốn ship hôm nay, hãy dùng Commander.

Để viết CLI bằng Go thay vì TypeScript, Bun vs Deno bao gồm so sánh TypeScript runtime, và TypeScript vs Go đi thẳng vào đánh đổi giữa hai ngôn ngữ.

Tài liệu tham khảo

NguồnURL
Bun standalone executableshttps://bun.sh/docs/bundler/executables
Bun release noteshttps://github.com/oven-sh/bun/releases
Commander.js releaseshttps://github.com/tj/commander.js/releases
Commander.js README (v14)https://github.com/tj/commander.js
npmtrends: commander vs yargs vs oclifhttps://npmtrends.com/commander-vs-oclif-vs-yargs
endoflife.date/bunhttps://endoflife.date/bun