Tutoriais · Plugins
Boas práticas e armadilhas
Sem dependências em runtime, pasta de dados, logs, idempotência e as armadilhas que mais derrubam plugins.
Nesta página
Sem dependências em runtime
O plugin precisa funcionar só com os arquivos que você publicou, num Node “puro”. Nenhuma origem de instalação roda o seu build nem entrega node_modules completo: a origem npm instala o pacote numa pasta temporária, com --ignore-scripts, e copia só a pasta do pacote; git, zip e folder copiam exatamente o que estiver lá.
- Use o que o Node traz:
fetchglobal,node:readline,node:fs/promises,node:net,node:tls,node:cryptoe, no Node 22+,node:sqlite. - Escreveu em TypeScript ou precisa de uma biblioteca? Compile/empacote antes de publicar e versione ou publique o resultado (por exemplo
dist/): é o que o plugin ClickUp faz, com odist/compilado no repositório, para que um import via git funcione sem nenhum passo de build. - Não use scripts de instalação (
preinstall,install,postinstall): eles não rodam na instalação pelo app, e o Marketplace avisa quando encontra algum. - Declare o Node mínimo em
engines; ocommand: "node"vem doPATHdo usuário.
async function getJson(url, token) {
const res = await fetch(url, {
headers: { authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(10_000), // nunca espere I/O sem timeout
})
if (!res.ok) throw new Error(`HTTP ${res.status} em ${new URL(url).pathname}`) // sem query, sem token
return res.json()
}Estado só em ADE_PLUGIN_DATA_DIR
A pasta do plugin (userData/plugins/<id>) é descartável: reimportar ou atualizar apaga a pasta inteira e copia a nova. Qualquer arquivo que o seu sidecar escreva ali some na próxima atualização. O lugar correto para estado, cache e temporários é ADE_PLUGIN_DATA_DIR — o app cria a pasta antes de iniciar o processo e a reimportação não mexe nela.
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
const dataDir = process.env.ADE_PLUGIN_DATA_DIR ?? '.ade-data'
export async function loadState() {
try {
return JSON.parse(await readFile(join(dataDir, 'state.json'), 'utf8'))
} catch {
return { cards: {} } // primeira execução, ou arquivo ilegível: recomeça do zero
}
}
export async function saveState(state) {
await mkdir(dataDir, { recursive: true })
const tmp = join(dataDir, 'state.json.tmp')
await writeFile(tmp, JSON.stringify(state), 'utf8')
await rename(tmp, join(dataDir, 'state.json')) // atômico: nunca um arquivo pela metade
}- Escrita atômica (arquivo temporário +
rename): um crash ou umSIGTERMno meio da escrita não deixa um JSON truncado. - Arquivo ilegível ≠ erro fatal. Recomece do zero e deixe a idempotência (abaixo) evitar o trabalho repetido.
- O fallback
?? '.ade-data'só serve para rodar o sidecar à mão, fora do app.
Logs
- Use mensagens
{ type: 'log', level, message }. Só JSON de protocolo no stdout; texto solto ali vira loginfo, e o stderr virawarn. - Cada mensagem é cortada em 500 caracteres e o painel guarda as últimas 50: escreva mensagens curtas, com o essencial (o que, qual id, qual resultado).
- Não registre a cada evento. Um sidecar tagarela enche o painel e esconde o que importa. Logue transições e falhas.
- Nunca registre segredos — nem o objeto de configurações inteiro, nem URLs com token na query (repare que o
getJsonacima só nomeia o caminho). - Use
errorpara uma falha que o usuário precisa ver (token inválido) e siga vivo.
Idempotência e replays
O sidecar vai reiniciar: ao salvar configurações, no botão Reiniciar, ao abrir o app, e depois de um crash (com backoff). Cada início traz um hello novo e um board.snapshot de cada missão com quadro. Por outro lado, o que acontece enquanto o processo está parado não é reenviado. Projete para os dois lados:
- Trate o snapshot como “reconciliar”, não como “tudo é novo”: compare com o que já foi feito (um hash por card, o último
atprocessado) e só aja no que mudou. - Prefira chamadas idempotentes no serviço externo (
PUTpor id em vez dePOST). - Um
card.noterepetido aparece repetido na atividade do card — registre no estado o que já anotou. - Uma ação disparada por configuração precisa de trava persistida.
import { createHash } from 'node:crypto'
/** Hash só do que importa para o espelho — o mesmo card, o mesmo hash. */
const hashOf = (card) =>
createHash('sha256')
.update(JSON.stringify([card.title, card.description, card.columnId]))
.digest('hex')
export async function onCard(card, state, sync) {
const seen = state.cards[card.id]
const hash = hashOf(card)
if (seen?.hash === hash) return // replay do snapshot / evento repetido: nada a fazer
await sync(card) // ideal: uma chamada idempotente (PUT por id, não POST)
state.cards[card.id] = { hash }
}Ciclo de vida sem sustos
- Saia em até 5 s ao receber
shutdown. Salvar configurações também reinicia o sidecar: trabalho longo em andamento é cortado, então grave o progresso no estado. - Não use
process.exit(1)para um erro recuperável (rede fora do ar, token errado). Cada saída inesperada conta para o limite de 5 em 10 minutos, depois do qual a integração viraerrore para de tentar. Capture, envie umerrore espere. - Nunca espere I/O sem timeout e nunca deixe uma exceção sem tratamento.
- Envie
readycedo. Sem ele, o app espera 10 s e segue mesmo assim, com um aviso no log. - Filtre com
eventso que não precisa receber — e lembre que[]significa todos, não nenhum.
Armadilhas mais comuns
| Armadilha | O que acontece | Faça assim |
|---|---|---|
| type: "select" em settings | O manifesto é recusado (PLUGIN_INVALID_MANIFEST). | Use string e valide. |
| Depender de node_modules | O plugin chega ao usuário sem as dependências. | Empacote em um único arquivo e publique o resultado. |
| Escrever dentro da pasta do plugin | Sumiu na próxima atualização. | Grave em ADE_PLUGIN_DATA_DIR. |
| Agir a cada hello | A ação repete em cada restart e em cada tentativa após um crash. | Trava persistida. |
| Erro de digitação em contributes | Ignorado sem erro: a contribuição não existe. | Confira o resumo de contribuições após importar. |
| events: [] achando que é “nenhum” | Recebe todos os eventos. | Liste os eventos desejados, ou mission.finished se quiser quase nada. |
| process.exit(1) em erro recuperável | Backoff e, ao passar de 5 saídas em 10 min, estado error. | Capture, envie error, continue. |
| private: true no package.json | npm publish recusa o pacote. | Remova a chave antes de publicar. |
| Logar as configurações | Segredos ficam visíveis no painel de log. | Logue só o que precisa, sem valores. |