Como Criar uma CLI Profissional com Node.js e TypeScript
A maioria das CLIs internas de times de engenharia começa como um script solto com process.argv[2]. Funciona por duas semanas. Depois alguém passa uma flag errada, o script quebra silenciosamente, e ninguém sabe por quê.
Uma CLI profissional resolve isso: parsing tipado de argumentos, mensagens de erro claras, help gerado automaticamente, exit codes corretos e distribuição via npm. Este post monta uma do zero, com decisões justificadas em cada etapa.
A stack e por que essas escolhas
Existem três bibliotecas dominantes para parsing de argumentos em CLIs Node.js. A escolha depende da complexidade da sua ferramenta:
| Critério | commander | yargs | citty (unjs) |
|---|---|---|---|
| Subcomandos nativos | Sim, com API fluente | Sim, mas API mais verbosa | Sim, leve |
| Tipagem TypeScript | Tipos incluídos desde v9 | @types/yargs separado | Tipos nativos |
| Tamanho (install size) | ~180 KB | ~800 KB | ~30 KB |
| Geração de help | Automática | Automática | Automática |
| Ecossistema/adoção | npm, Vite, Prisma CLI | webpack, mocha | Nuxi, giget |
| Composição com prompts | Manual (combine com inquirer) | Manual | Manual |
Se a CLI tem menos de 3 subcomandos e zero interatividade, citty resolve com menos dependências. Se tem 5+ subcomandos, validação complexa e precisa de ecossistema maduro, commander é a escolha pragmática. yargs funciona, mas o install size e a API menos fluente tornam difícil justificar quando commander existe.
Este post usa commander + chalk + ora (spinner) + zod (validação de input). A CLI de exemplo será um scaffold: ferramenta que cria estrutura de diretórios para projetos a partir de templates.
Setup do projeto com TypeScript e build para CJS
mkdir scaffold-cli && cd scaffold-cli
npm init -y
npm install commander chalk ora zod
npm install -D typescript @types/node tsx
npx tsc --init
O tsconfig.json precisa de atenção em dois pontos que causam dor de cabeça em CLIs:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"declaration": true,
"sourceMap": true,
// skipLibCheck evita conflitos entre @types de dependências transitivas
"skipLibCheck": true
},
"include": ["src/**/*"]
}
No package.json, o campo bin é o que transforma um módulo npm em CLI executável:
{
"name": "scaffold-cli",
"version": "1.0.0",
"bin": {
"scaffold": "./dist/index.js"
},
"files": ["dist"],
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"prepublishOnly": "npm run build"
}
}
O campo files garante que só o diretório dist vai para o registry. Sem ele, node_modules, src e tudo mais vão junto, inflando o pacote.
Entrypoint com shebang e programa principal
#!/usr/bin/env node
// src/index.ts
// O shebang acima é obrigatório: sem ele, sistemas Unix não sabem
// que este arquivo deve rodar com Node.js quando chamado diretamente.
import { Command } from "commander";
import { createCommand } from "./commands/create.js";
import { listCommand } from "./commands/list.js";
const program = new Command();
program
.name("scaffold")
.description("Cria estrutura de diretórios para projetos")
.version("1.0.0");
// Cada subcomando vive em arquivo próprio para manter o entrypoint limpo
program.addCommand(createCommand);
program.addCommand(listCommand);
program.parse();
Dois detalhes que parecem cosméticos mas importam em produção: o .version() habilita --version automaticamente, e o program.parse() sem argumentos lê de process.argv por padrão.
Subcomando com validação via Zod
O subcomando create recebe nome do projeto e template. A validação acontece com Zod porque commander só valida presença de argumentos, não formato ou regras de negócio.
// src/commands/create.ts
import { Command } from
---
Leia o artigo completo em [https://www.vivodecodigo.com.br/backend/cli-profissional-nodejs-typescript-commander-chalk](https://www.vivodecodigo.com.br/backend/cli-profissional-nodejs-typescript-commander-chalk)