1

Pitch: 7 lições de um CLI que edita app.json, Podfile e Gradle sem estragar o projeto de ninguém

Se você já escreveu um script que "só adiciona uma linha" num arquivo de configuração de outra pessoa, sabe que essa linha nunca é só uma linha. O arquivo tem comentários, um fim de linha diferente, um formato que o seu código não esperava, e quem rodou o script descobre o estrago dias depois.

A gente escreveu um CLI de setup de push para apps React Native. Ele instala pacotes e mexe em app.json, Podfile, nos dois build.gradle e no AppDelegate de projetos que não são nossos. Abaixo estão as lições que valem para qualquer ferramenta que edita arquivos de projeto: codemod, gerador, instalador de plugin. Cada uma vem com o caso real que nos ensinou e uma dica para quem for fazer o mesmo.

Lição 1: deixe o projeto responder, não a pessoa

O caminho fácil seria perguntar "seu app usa Expo?". Preferimos ler o package.json: com expo, um fluxo; com react-native e sem expo, outro; sem nenhum dos dois, o CLI para com exit code 1.

flowchart TD
  A[Lê o package.json] --> B{Tem expo?}
  B -- sim --> C[Fluxo Expo]
  B -- não --> D{Tem react-native?}
  D -- sim --> E[Fluxo React Native CLI]
  D -- não --> F[Para com exit code 1]

Para o seu CLI: toda pergunta que o próprio projeto responde é uma chance a menos de a pessoa errar. Pergunte só o que não está escrito em lugar nenhum.

Lição 2: quando não dá para saber, não chute

Para casar a versão dos pacotes com o SDK do Expo, o CLI usa npx expo install em vez de npm install, e lê a versão do expo instalado. Antes da instalação, ele só confia no range do package.json quando o range aponta uma versão (~57.0.26, ^53.0.0). Com >=52 <58 ou latest, ele não adivinha: segue com um aviso dizendo que não sabe.

Para o seu CLI: um aviso honesto ("não consegui ler a versão") custa uma linha. Uma suposição errada custa uma tarde de quem está do outro lado.

Lição 3: JSON.parse + JSON.stringify não é uma edição, é uma reescrita

O jeito óbvio de adicionar um item ao app.json é ler, mexer no objeto e salvar. Veja o que isso faz com um arquivo inocente:

JSON.stringify(
  JSON.parse('{"version": 1.0, "id": 12345678901234567890, "name": "Caf\\u00e9"}'),
  null,
  2
)
// {
//   "version": 1,
//   "id": 12345678901234567000,
//   "name": "Café"
// }

O 1.0 virou 1, o inteiro grande perdeu precisão e o escape virou outro texto. Também somem as chaves repetidas, e mudam a indentação, o fim de linha (CRLF ou LF) e o BOM.

A solução foi inserir um único pedaço de texto no lugar certo da lista plugins, copiando o layout dos itens ao redor. Todo o resto do arquivo fica byte a byte como estava.

Para o seu CLI: se o arquivo é de outra pessoa, trate-o como texto e mude o mínimo. Uma checagem útil para quem for fazer isso: depois de inserir, faça o parse do resultado e compare com o original mais o item novo; se der diferente, não escreva.

Lição 4: a ferramenta que você chama também edita arquivos

Uma surpresa no caminho: o próprio npx expo install adiciona plugins ao app.json e reescreve o arquivo com o writer dele. Isso perde comentários e muda fins de linha e números.

A saída foi guardar o arquivo antes de chamar o Expo: os bytes, se ele era um link simbólico e as permissões. Se algo mudou, o CLI devolve tudo e avisa ("Expo rewrote app.json on its own").

Depois veio um segundo susto, do npm. O npx expo install às vezes termina a instalação e para quando react ou react-native precisam mudar de versão, sem chegar a adicionar os plugins. Hoje o CLI roda o mesmo comando uma segunda vez nesse caso e avisa que fez isso.

Para o seu CLI: antes de chamar outra ferramenta, fotografe os arquivos que ela pode tocar e compare depois. Não dá para confiar no que você não controla.

Lição 5: na dúvida, o passo é da pessoa

app.config.js e app.config.ts são código, então o CLI nunca os edita. Ele imprime a linha exata para a pessoa adicionar e termina com exit code 2, que quer dizer "ficou um passo manual". O mesmo vale para:

  • JSON com comentário ou vírgula sobrando;
  • arquivo somente leitura;
  • qualquer JSON que ele não consiga estender sem mudar outra coisa.

A regra que mais nos ajudou: o CLI nunca diz "pronto" quando não tem certeza. Se o arquivo de config faz ...config, o texto mostra que o plugin está lá, mas não garante que ele chega ao final. Nesse caso o CLI pede para conferir.

Para o seu CLI: separe três saídas, porque CI e humanos tratam cada uma de um jeito: deu certo (0), recusei (1) e fiz o que pude, mas falta um passo seu (2).

Lição 6: em arquivo nativo, marque, guarde e escreva de uma vez

No fluxo React Native CLI, cada mudança entra entre marcadores. No android/app/build.gradle fica assim:

// espresso-notify:begin google-services
apply plugin: 'com.google.gms.google-services'
// espresso-notify:end

Além disso, o CLI:

  • só mexe num arquivo que tem a forma do template do React Native; um arquivo diferente vira passo manual;
  • antes da primeira escrita, faz uma cópia .bak com data e hora ao lado do arquivo;
  • escreve num arquivo temporário e depois renomeia, para nunca deixar um arquivo pela metade;
  • mantém o fim de linha que o arquivo já tinha e nunca apaga uma linha da pessoa.

Os marcadores são o que permite rodar de novo com segurança: o CLI sabe exatamente o que é dele.

O AppDelegate.swift ensinou mais uma. O FirebaseApp.configure() derruba o app na abertura quando o GoogleService-Info.plist não está no projeto. Esse arquivo vem do Firebase de quem usa, e o CLI não tem como criá-lo. Por isso a chamada entra protegida:

if Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil {
  FirebaseApp.configure()
}

O app abre antes de o arquivo existir e passa a usar o Firebase quando ele chega.

Para o seu CLI: nunca deixe o projeto num estado em que ele não abre só porque falta um passo que a pessoa ainda vai fazer.

Lição 7: rodar duas vezes é o teste mais barato que existe

Todo passo é idempotente: a segunda execução não toca em arquivo nenhum e diz isso. O --dry-run mostra cada mudança sem escrever nada.

Em 23/10 rodamos o CLI em apps novos do template React Native CLI: React Native 0.87.1 no simulador iOS 27 e num emulador Android 16, e React Native 0.79.7 no emulador Android. Em cada um:

  • o dry run não mudou nada;
  • a execução real adicionou só as linhas entre os marcadores;
  • a segunda execução não mudou nada;
  • o app abriu e pediu a permissão de notificação.

O código é Node puro, sem nenhuma dependência (os prompts usam node:readline). Tudo que toca o mundo (pasta, spawn, streams, fs, path) entra por injeção. Assim os testes simulam o Windows com path.win32 sem precisar de uma máquina Windows.

Para o seu CLI: "rodar de novo não muda nada" é um teste curto que, na nossa experiência, pega muito bug de edição de arquivo.

Dois detalhes que custaram caro

  • Windows: o npx sobe pelo cmd.exe do sistema, nunca por um achado no PATH, e com argumentos fixos. A pasta do projeto entra só como diretório de trabalho, nunca dentro do comando, então pasta com espaço e acento funciona.
  • Segredos: o CLI recusa uma chave secreta (sk_...) e nunca grava a chave pública em disco; só imprime o trecho de código com ela.

Sobre o projeto

O CLI é o assistente de setup do SDK React Native do Espresso Notify, um serviço de push para iOS, Android e web com dashboard e API. É grátis até 10 mil aparelhos. Para quem quiser ver o resultado das lições acima:

npx @espresso-notify/react-native@latest setup --dry-run

O --dry-run mostra o que ele faria sem mudar nada. A documentação, com cada passo e os códigos de saída, está aqui:

E uma pergunta para quem já automatizou edição de arquivo de projeto: vocês preferem inserir texto, como fizemos, ou usar um parser que preserva formato? Que Podfile ou Gradle "fora do template" vocês já encontraram por aí? Respondo qualquer dúvida nos comentários.

Carregando publicação patrocinada...
1

Sobre inserir texto ou usar parser: pro app.json tem um meio-termo que é o jsonc-parser, a lib que o VS Code usa pros settings. O modify dele devolve só as edições de texto necessárias e o applyEdits aplica, então comentário, indentação e o resto do arquivo ficam como estavam, e você não precisa copiar o layout dos vizinhos na mão. Pra Podfile e Gradle não conheço nada equivalente que seja confiável, aí marcador e texto parecem o caminho mesmo.

Uma coisa que eu olharia é o .bak ao lado do arquivo. Em projeto que faz git add . ele vai parar no commit, e um build.gradle.bak dentro de android/app costuma confundir quem chega depois. Talvez valha jogar os backups numa pasta só e avisar no final pra adicionar no .gitignore.

1

Eu manteria a edição mínima nos formatos reconhecidos e mostraria um diff nos demais. O que acontece se alguém editar um bloco entre os marcadores antes de rodar o CLI de novo?

1

Boa pergunta. O marcador funciona como uma assinatura de "já passei aqui": se o CLI encontra o espresso-notify:begin no arquivo, ele considera o passo feito e não toca no bloco. Então o que você editar entre os marcadores fica como você deixou na próxima execução. O CLI nunca reescreve nem "corrige" o que está lá dentro.

Isso vem de uma regra mais geral do writer: um patch só pode adicionar linhas. Antes de gravar, ele confere se o texto novo contém todas as linhas antigas, na mesma ordem. Se não contém, não grava nada.

Sobre o diff, concordo, e é bem perto do que fizemos:

  • o --dry-run imprime exatamente as linhas que seriam adicionadas, sem escrever nada;
  • um arquivo nativo fora do formato do template fica como está, e o CLI imprime o passo para você aplicar.

Você já usou alguma ferramenta que mostra o diff e pede confirmação linha a linha? Fiquei curioso sobre como ela trata arquivo com CRLF.