Ir para o conteúdo
Backend

Como Criar uma CLI Profissional com Node.js e TypeScript

Marcos Soares
Atualizado em 
12 minutos de leitura
Ilustracao 3D de terminal monolitico de vidro fosco com luz esmeralda representando criacao de CLI com Node.js
Ouça este artigo
0:00Como 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ériocommanderyargscitty (unjs)
Subcomandos nativosSim, com API fluenteSim, mas API mais verbosaSim, leve
Tipagem TypeScriptTipos incluídos desde v9@types/yargs separadoTipos nativos
Tamanho (install size)~180 KB~800 KB~30 KB
Geração de helpAutomáticaAutomáticaAutomática
Ecossistema/adoçãonpm, Vite, Prisma CLIwebpack, mochaNuxi, giget
Composição com promptsManual (combine com inquirer)ManualManual

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

Bash
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:

JSON
{
  "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:

JSON
{
  "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

TYPESCRIPT
#!/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.

TYPESCRIPT
// 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.

TYPESCRIPT
// 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)

TYPESCRIPT
// 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

TYPESCRIPT
// 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.

TYPESCRIPT
// 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)

TYPESCRIPT
// ERRADO: erro vai para stdout
console.log("Erro: arquivo não encontrado");
// Isso polui o output quando alguém faz: scaffold list > templates.txt
TYPESCRIPT
// 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

TYPESCRIPT
// ERRADO: Ctrl+C durante operação longa deixa arquivos pela metade
await scaffoldProject(input); // Se interrompido, diretório fica incompleto
TYPESCRIPT
// 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:

Bash
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-cli

Para testes automatizados, execute o entrypoint diretamente com node ou tsx:

Bash
# 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 --json

Se 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

Bash
# 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 public

Apó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:

  1. --help gera output útil para cada subcomando
  2. --version retorna a versão correta do package.json
  3. Erros de input retornam exit code 1 com mensagem em stderr
  4. --json (ou equivalente) existe para subcomandos que listam dados
  5. SIGINT não deixa estado parcial no filesystem
  6. O campo files no package.json exclui src, tests, node_modules
  7. O shebang #!/usr/bin/env node está 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.

Marcos Soares

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.