Capstone: construir um CLI tool TypeScript end-to-end
⏱ 18 min·⭐ 80 XP
Pré-requisitos (0/1)0%
- ⬜📦 Monorepo profissional: pnpm workspaces + Turbo + shared configs(TypeScript Profissional)
Recomendamos completar os pré-requisitos antes de seguir, mas nada te impede de continuar.
O projeto
🗺️ A ordem de construção que evita retrabalho
1 · Lógica pura primeirosem terminal
Função que recebe dado e devolve resultado. Testável sem simular processo — e é onde está o valor.
ENVOLVER▼
2 · Validar a entradaesquema na borda
A linha de comando é entrada externa: valor inválido vira mensagem clara, não comportamento estranho lá dentro.
FORMATAR▼
3 · Separar a saídatexto, JSON, tabela
Formatação isolada permite acrescentar formato sem tocar na lógica — e é o que torna a ferramenta encadeável.
EMPACOTAR▼
4 · Empacotar como executávelponto de entrada e delimitação
Só a pasta compilada é publicada; teste e fixture não viajam para quem instala.
PUBLICAR▼
5 · Ajuda e código de saídacontrato com o shell
Sair com código diferente de zero em erro é o que permite usar a ferramenta em script — e quase todo mundo esquece.
Vamos construir ffv-count: CLI que conta palavras, linhas e caracteres de arquivos (estilo wc) mas com output JSON/CSV e validação tipada. Projeto pequeno o suficiente pra terminar numa tarde, grande o suficiente pra exercitar tudo que a trilha ensinou.
1. Estrutura
ffv-count/
├── src/
│ ├── index.ts # entry (shebang + main)
│ ├── cli.ts # parse args com Zod
│ ├── count.ts # lógica pura
│ └── output.ts # formatters
├── test/
│ └── count.test.ts
├── package.json
├── tsconfig.json
└── README.md2. Argumentos tipados com Zod
// src/cli.ts
import { z } from 'zod';
import { parseArgs } from 'node:util';
const ArgsSchema = z.object({
files: z.array(z.string()).min(1),
format: z.enum(['json', 'csv', 'text']).default('text'),
verbose: z.boolean().default(false),
});
export type CliArgs = z.infer<typeof ArgsSchema>;
export function parseCliArgs(argv: string[]): CliArgs {
const { values, positionals } = parseArgs({
args: argv.slice(2),
options: {
format: { type: 'string', short: 'f' },
verbose: { type: 'boolean', short: 'v' },
},
allowPositionals: true,
});
const parsed = ArgsSchema.safeParse({ files: positionals, ...values });
if (!parsed.success) {
console.error('Erro nos argumentos:', parsed.error.message);
process.exit(1);
}
return parsed.data;
}3. Lógica pura + Result
// src/count.ts
import { readFile } from 'node:fs/promises';
export type FileStats = { file: string; lines: number; words: number; chars: number };
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
export async function countFile(path: string): Promise<Result<FileStats, Error>> {
try {
const content = await readFile(path, 'utf8');
return {
ok: true,
value: {
file: path,
lines: content.split('\n').length,
words: content.split(/\s+/).filter(Boolean).length,
chars: content.length,
},
};
} catch (e) {
return { ok: false, error: e as Error };
}
}Quiz rápido
No capstone, `parseArgs` do Node devolve os argumentos e o Zod os valida logo em seguida. Por que a validação não é redundante?
4. Entry com shebang + bin
#!/usr/bin/env node
// src/index.ts
import { parseCliArgs } from './cli.js';
import { countFile } from './count.js';
import { formatOutput } from './output.js';
const args = parseCliArgs(process.argv);
const controller = new AbortController();
process.on('SIGINT', () => controller.abort());
const results = await Promise.all(args.files.map(countFile));
const ok = results.filter(r => r.ok).map(r => r.value);
const errors = results.filter(r => !r.ok);
console.log(formatOutput(ok, args.format));
if (errors.length > 0) process.exit(1);package.json
{
"name": "ffv-count",
"version": "0.1.0",
"type": "module",
"bin": { "ffv-count": "./dist/index.js" },
"scripts": {
"build": "tsc",
"test": "vitest run"
},
"files": ["dist/**"]
}5. Testes + release
// test/count.test.ts
import { describe, it, expect } from 'vitest';
import { countFile } from '../src/count.js';
describe('countFile', () => {
it('conta linhas, palavras e chars', async () => {
const r = await countFile('./test/fixtures/hello.txt');
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.value.words).toBe(2);
expect(r.value.lines).toBeGreaterThan(0);
}
});
it('retorna err em arquivo inexistente', async () => {
const r = await countFile('./nope');
expect(r.ok).toBe(false);
});
}); Release: pnpm changeset → descreva mudança → commit → CI publica. Done.
✅
Você aplicou: tipos estruturais, narrowing, Zod em boundary, Result, async, tests. CLI roda, publica, tem help. Isso é TypeScript profissional em menos de 300 linhas.
Perguntas frequentes
❓ O que uma ferramenta de linha de comando precisa acertar?
Código de saída correto, mensagem de erro em fluxo de erro, saída legível por máquina quando pedida, e ajuda que resolve dúvida. Ferramenta que sempre devolve zero e escreve erro na saída padrão quebra qualquer automação que a use.
❓ Como distribuir uma ferramenta de terminal?
Como pacote com ponto de entrada executável, ou como binário único quando você quer dispensar tempo de execução instalado. O binário resolve o problema de versão na máquina do usuário — que é a maior fonte de relato de "não funciona aqui".
❓ Como testar interação de linha de comando?
Separando a lógica da camada de entrada e saída: testa-se a lógica com teste comum e a interface com execução do processo verificando saída e código de retorno. Ferramenta que mistura os dois só pode ser testada por processo, e o teste fica lento e frágil.
Fixando
Quiz rápido
Por que a lógica de contagem fica num módulo puro, separada da entrada e da formatação?
Quiz rápido
O `package.json` do capstone traz `"bin"`, `"type": "module"` e `"files"`. Qual é o papel de `"files"`?
Terminou de ler?
Marcar como concluído registra o XP, mantém sua sequência e coloca 3 cartas deste módulo na fila de revisão espaçada.
Próximos passos sugeridos
Temas deste módulo
Discussão
Carregando comentários…