Como Criar uma CLI Profissional com Node.js e TypeScript

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
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 --initO 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 "commander";
import { z } from "zod";
import chalk from "chalk";
import ora from "ora";
import { scaffoldProject } from "../scaffold.js";
// Schema separado do comando: reutilizável em testes e em outros entrypoints
const CreateOptionsSchema = z.object({
name: z
.string()
.min(1, "Nome do projeto não pode ser vazio")
.regex(
/^[a-z0-9-]+$/,
"Nome aceita apenas letras minúsculas, números e hífen"
),
template: z.enum(["api", "worker", "fullstack"], {
errorMap: () => ({
message: "Template deve ser: api, worker ou fullstack",
}),
}),
});
type CreateOptions = z.infer<typeof CreateOptionsSchema>;
export const createCommand = new Command("create")
.description("Cria um novo projeto a partir de um template")
.argument("<name>", "Nome do diretório do projeto")
.option("-t, --template <type>", "Template do projeto", "api")
.action(async (name: string, options: { template: string }) => {
const parsed = CreateOptionsSchema.safeParse({
name,
template: options.template,
});
if (!parsed.success) {
// Zod retorna array de issues: mostramos todas de uma vez
for (const issue of parsed.error.issues) {
console.error(chalk.red(`Erro: ${issue.message}`));
}
process.exit(1);
}
const spinner = ora(`Criando projeto ${chalk.cyan(parsed.data.name)}...`).start();
try {
await scaffoldProject(parsed.data);
spinner.succeed(
`Projeto ${chalk.cyan(parsed.data.name)} criado com template ${chalk.yellow(parsed.data.template)}`
);
} catch (error) {
spinner.fail("Falha ao criar projeto");
if (error instanceof Error) {
console.error(chalk.red(error.message));
}
process.exit(1);
}
});O process.exit(1) em caso de erro é obrigatório para CLIs que rodam em pipelines de CI/CD. Se a CLI termina com exit code 0 após um erro, o pipeline continua como se nada tivesse acontecido. Isso é fonte de bugs silenciosos que custam horas de debug, especialmente em pipelines de deploy como os descritos no post sobre DevOps.
A lógica de scaffold isolada do CLI
Separar a lógica de negócio do parsing de argumentos não é perfeccionismo: é o que permite testar a lógica sem simular process.argv.
// src/scaffold.ts
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
interface ScaffoldInput {
name: string;
template: "api" | "worker" | "fullstack";
}
// Mapa de templates para estrutura de diretórios.
// Adicionar template novo = adicionar entrada aqui, sem tocar no CLI.
const TEMPLATE_STRUCTURES: Record<ScaffoldInput["template"], string[]> = {
api: ["src", "src/routes", "src/middleware", "tests"],
worker: ["src", "src/jobs", "src/queues", "tests"],
fullstack: ["src", "src/api", "src/client", "src/shared", "tests"],
};
const BASE_FILES: Record<string, string> = {
"tsconfig.json": JSON.stringify(
{
compilerOptions: {
target: "ES2022",
module: "Node16",
moduleResolution: "Node16",
strict: true,
outDir: "./dist",
rootDir: "./src",
},
},
null,
2
),
".gitignore": "node_modules\ndist\n.env\n",
};
export async function scaffoldProject(input: ScaffoldInput): Promise<void> {
const projectDir = join(process.cwd(), input.name);
const dirs = TEMPLATE_STRUCTURES[input.template];
// recursive: true evita erro se diretório pai não existe
// e não lança se o diretório já existir
await mkdir(projectDir, { recursive: true });
for (const dir of dirs) {
await mkdir(join(projectDir, dir), { recursive: true });
}
for (const [filename, content] of Object.entries(BASE_FILES)) {
await writeFile(join(projectDir, filename), content, "utf-8");
}
}Essa separação segue o mesmo princípio de camadas bem definidas em aplicações fullstack: o entrypoint orquestra, a lógica executa.
Subcomando de listagem (output tabulado)
// src/commands/list.ts
import { Command } from "commander";
import chalk from "chalk";
const TEMPLATES = [
{ name: "api", description: "API REST com Express/Fastify", complexity: "baixa" },
{ name: "worker", description: "Background jobs com filas", complexity: "média" },
{ name: "fullstack", description: "API + client React/Vue", complexity: "alta" },
] as const;
export const listCommand = new Command("list")
.description("Lista templates disponíveis")
.option("--json", "Output em JSON (para composição com jq/pipes)")
.action((options: { json?: boolean }) => {
if (options.json) {
// JSON puro em stdout para composição com outras ferramentas
console.log(JSON.stringify(TEMPLATES, null, 2));
return;
}
console.log(chalk.bold("\nTemplates disponíveis:\n"));
const nameWidth = 12;
const descWidth = 35;
console.log(
chalk.gray(
`${"NOME".padEnd(nameWidth)}${"DESCRIÇÃO".padEnd(descWidth)}COMPLEXIDADE`
)
);
console.log(chalk.gray("-".repeat(nameWidth + descWidth + 12)));
for (const tpl of TEMPLATES) {
console.log(
`${chalk.cyan(tpl.name.padEnd(nameWidth))}${tpl.description.padEnd(descWidth)}${tpl.complexity}`
);
}
console.log();
});A flag --json é um padrão que toda CLI profissional deveria ter. Ela permite composição: scaffold list --json | jq '.[] | select(.complexity == "baixa")'. CLIs que só produzem output formatado para humanos são inutilizáveis em scripts. Esse tipo de composição entre ferramentas é o que torna CLIs com IA e CLIs de deploy realmente poderosas.
O que NÃO fazer
Anti-pattern 1: parsear process.argv manualmente
// ERRADO: parsing manual de argumentos
const args = process.argv.slice(2);
const name = args[0];
const template = args.find((a) => a.startsWith("--template="))?.split("=")[1];
if (!name) {
console.log("Passe um nome");
// Sem exit code de erro: pipeline de CI não detecta falha
}Problemas: sem help automático, sem validação de flags desconhecidas, sem tratamento de --template value (só funciona com =), sem exit code correto. Parece "menos dependência", mas o custo de manutenção é alto a partir do segundo subcomando.
// CORRETO: commander cuida do parsing, você cuida da lógica
import { Command } from "commander";
const program = new Command();
program
.argument("<name>")
.option("-t, --template <type>", "Template", "api")
.action((name, options) => {
// Argumentos já parseados e tipados
});
program.parse();Anti-pattern 2: escrever em stderr o que é stdout (e vice-versa)
// ERRADO: erro vai para stdout
console.log("Erro: arquivo não encontrado");
// Isso polui o output quando alguém faz: scaffold list > templates.txt// CORRETO: erros vão para stderr, dados vão para stdout
console.error(chalk.red("Erro: arquivo não encontrado"));
// console.log para dados que o usuário quer capturar/redirecionar
console.log(JSON.stringify(result));A regra é simples: stdout para dados consumíveis, stderr para mensagens humanas (erros, progresso, warnings). Spinners do ora já escrevem em stderr por padrão, o que é correto.
Anti-pattern 3: ignorar sinais de interrupção
// ERRADO: Ctrl+C durante operação longa deixa arquivos pela metade
await scaffoldProject(input); // Se interrompido, diretório fica incompleto// CORRETO: cleanup em caso de interrupção
import { rm } from "node:fs/promises";
const projectDir = join(process.cwd(), input.name);
const cleanup = async () => {
// Tenta remover o que foi criado parcialmente
await rm(projectDir, { recursive: true, force: true }).catch(() => {});
process.exit(130); // 128 + 2 (SIGINT): convenção Unix
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
try {
await scaffoldProject(input);
} finally {
process.removeListener("SIGINT", cleanup);
process.removeListener("SIGTERM", cleanup);
}O exit code 130 não é arbitrário: é a convenção POSIX para processos terminados por SIGINT (128 + número do sinal). Pipelines que checam exit codes dependem disso.
Testando a CLI sem instalar globalmente
Durante desenvolvimento, use npm link para criar um symlink global temporário:
npm run build
npm link
# Agora o comando está disponível globalmente
scaffold create meu-projeto --template api
scaffold list --json
# Para remover o link quando terminar
npm unlink -g scaffold-cliPara testes automatizados, execute o entrypoint diretamente com node ou tsx:
# Testa o build compilado
node dist/index.js create test-project --template worker
# Testa direto do source com tsx (mais rápido no dev loop)
npx tsx src/index.ts list --jsonSe a CLI faz parte de um monorepo com domínios bem separados, a lógica de scaffold pode viver em um pacote interno e a CLI ser apenas o entrypoint que importa dele.
Publicação no npm
# Garanta que o build está limpo
rm -rf dist && npm run build
# Verifique o que vai para o registry
npm pack --dry-run
# Deve mostrar apenas dist/** e package.json
# Publique (precisa de conta npm e npm login)
npm publish --access publicApós publicar, qualquer pessoa executa com npx scaffold-cli create meu-projeto sem instalar nada. O npx baixa, executa e descarta.
Para CLIs internas de time que não devem ir para o registry público, use um registry privado (GitHub Packages, Verdaccio) ou distribua via npm install git+https://.... Essa decisão depende do contexto: se o time tem menos de 10 pessoas e a CLI muda toda semana, git direto é mais simples. Acima disso, registry privado evita problemas de cache e versionamento.
Checklist de qualidade para CLIs
Antes de publicar, verifique:
--helpgera output útil para cada subcomando--versionretorna a versão correta dopackage.json- Erros de input retornam exit code 1 com mensagem em
stderr --json(ou equivalente) existe para subcomandos que listam dadosSIGINTnão deixa estado parcial no filesystem- O campo
filesnopackage.jsonexcluisrc,tests,node_modules - O shebang
#!/usr/bin/env nodeestá na primeira linha do entrypoint compilado
Se a CLI interage com APIs externas, adicione retry e timeout nos requests HTTP: o padrão de client HTTP resiliente se aplica diretamente.
FAQ
Preciso de commander ou posso usar o parseArgs nativo do Node.js?
O node:util.parseArgs (disponível desde Node 18.3) funciona para CLIs com até 2-3 flags simples. Ele não gera --help automaticamente, não suporta subcomandos nativamente e não valida tipos. Se a CLI vai crescer, commander economiza retrabalho. Se é um script interno de 20 linhas, parseArgs basta.
ESM ou CJS para CLIs publicadas no npm?
CJS ainda é a escolha mais segura para CLIs publicadas. O motivo: commander, chalk v5+ e ora v7+ são ESM-only, mas o Node.js resolve ESM importado de CJS sem problemas quando "type": "module" está no package.json. O problema aparece ao contrário: consumidores CJS tentando importar seu pacote ESM. Para CLIs executáveis (não bibliotecas), isso raramente é problema, porque ninguém importa uma CLI como dependência. Use ESM se todas as suas dependências suportam.
Como adicionar autocompletar (tab completion)?
O pacote commander não inclui autocompletar nativo. A solução mais direta é o tabtab ou gerar scripts de completion para bash/zsh/fish manualmente. Para CLIs internas de time, o custo de implementar autocompletar raramente se justifica. Para CLIs públicas com muitos subcomandos (estilo kubectl), vale o investimento.
tsx ou ts-node para desenvolvimento?
tsx é mais rápido no startup porque usa esbuild por baixo e não faz type-checking em runtime. ts-node faz type-checking por padrão (desligável com --transpileOnly), o que torna o dev loop mais lento. Para CLIs, onde o startup time importa, tsx é a escolha prática. O type-checking fica no tsc --noEmit do CI.
Dá para distribuir a CLI como binário standalone sem exigir Node.js?
Sim. O pkg (descontinuado, mas funcional) e o node --experimental-sea-generate (Node 20+) empacotam o runtime junto. O binário gerado fica entre 40-80 MB. Para distribuição interna, funciona. Para CLI pública, o tamanho é proibitivo: prefira npx.
Posição técnica: CLIs merecem a mesma engenharia que APIs
A tendência de tratar CLIs como "scripts glorificados" é o que produz ferramentas internas que ninguém quer usar. Uma CLI com parsing tipado, validação de input, exit codes corretos e output estruturado custa 2-3 horas a mais no setup inicial. Esse investimento se paga na primeira vez que alguém roda a ferramenta num pipeline de CI e ela falha com mensagem clara em vez de um stack trace de TypeError: Cannot read properties of undefined.
Se o time já usa TypeScript no backend e tem ferramentas de automação em JavaScript, a CLI deveria seguir o mesmo padrão de qualidade. O runtime é o mesmo, o ecossistema é o mesmo, a desculpa para fazer diferente não existe.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


