Uma esteira de limpeza em três camadas, os bugs da primeira execução e o dia em que o script apagou os node_modules errados
Numa noite de outubro o Mac começou a engasgar. Nada dramático: o Spotlight demorava, um npm install parou no meio, uma build do Xcode reclamou de espaço. Rodei o comando que todo mundo roda nessa hora:
df -h /System/Volumes/Data
/dev/disk3s5 460Gi 417Gi 5.6Gi 99%
Sobravam 5,6 GB num SSD de 460 GB. Em APFS isso é perigoso por um motivo prático: o sistema precisa de espaço livre para swap, snapshots, atualizações e para os arquivos temporários que qualquer ferramenta de desenvolvimento cria sem pedir licença. Com 1% livre, qualquer coisa pode ser a gota que trava a máquina.
O detalhe constrangedor é que eu já tinha uma rotina de limpeza agendada. Um job rodava mo clean --dry-run a cada hora e me mandava uma notificação dizendo quanto espaço poderia ser liberado. Ele cumpria o papel dele com disciplina. O problema é que um dry-run não apaga nada. A esteira rodava, o aviso chegava, e o resultado prático era zero, porque ninguém tomava a decisão de executar.
Este artigo conta como saí de 5,6 GB para 43 GB livres em uma noite, sem reinstalar o sistema e sem perder trabalho. Conta também o que deu errado no caminho, porque deu. A primeira execução real do script moveu para a Lixeira os node_modules de catorze projetos que eu estava usando. Isso está na seção sobre falhas, com o log.
A tese é simples. Manter o SSD de um laptop de desenvolvimento saudável não exige uma ferramenta mágica nem um produto comercial. Exige três camadas que conversam entre si:
mo clean).mo_clean_full.sh).Três nomes aparecem o tempo todo daqui em diante:
mo): uma CLI open source de limpeza para macOS. O subcomando mo clean remove caches genéricos de apps e do sistema..plist em ~/Library/LaunchAgents que diz ao launchd o que rodar, quando, e dentro da sessão do seu usuário.Fica de fora deste texto: ferramentas comerciais de limpeza, backup externo e qualquer promessa de portabilidade. O script usa caminhos da minha máquina e serve como referência para adaptar, não para copiar e rodar.
Tudo o que aparece aqui saiu de arquivos reais: o script, a biblioteca compartilhada, o plist do LaunchAgent e os logs de duas execuções com --apply (/tmp/apply.log e /tmp/apply2.log, de 11 de outubro de 2026). Quando um número não vem de log, eu digo de onde veio.
Antes de apagar qualquer coisa, fiz uma varredura com du -sh nos suspeitos de sempre. A tabela abaixo é a captura daquele momento:
| Local | Tamanho | O que tem lá |
|---|---|---|
~/Library | 124 GB | apps, caches, logs |
~/Projects | 26 GB | código, node_modules, builds |
~/Library/Developer/CoreSimulator | 18 GB | simuladores iOS |
~/Library/Developer/Xcode | 17 GB | DerivedData, Archives, DeviceSupport |
~/.codex/worktrees | 13 GB | 30 worktrees de agente, 20 sem alteração há mais de 7 dias |
~/Library/Developer/XcodeBuildMCP | 13 GB | cache do XcodeBuildMCP, servidor MCP (ferramentas para agentes de IA) do Xcode |
~/Movies | 8,5 GB | material antigo |
~/.npm | 7,5 GB | cache do npm (e, como descobri depois, do npx) |
~/Library/Caches/pypoetry | 1,2 GB | wheels e índices do Poetry |
~/Library/Caches/ms-playwright | 1,1 GB | Chromium, Firefox e WebKit do Playwright |
/tmp/lemon-* | 658 MB | smoke tests, locks, auditorias, renders one-shot |
~/Library/Caches/ms-playwright-mcp | 505 MB | browsers do Playwright MCP |
~/Library/Caches/CocoaPods | 286 MB | specs e pods |
Três linhas merecem atenção especial, porque são as que crescem sem ninguém perceber.
Worktrees de agentes de IA. Cada vez que um agente como o Codex trabalha numa tarefa isolada, ele pode abrir um git worktree: uma cópia completa do repositório, com .git, artefatos de análise, lockfiles e testes. Isso é ótimo para isolamento. O custo aparece semanas depois, quando ninguém lembra que aquelas pastas existem. Eram 30 diretórios e 13 GB. Vinte deles não eram modificados havia mais de uma semana.
~/.npm. Todo npm install alimenta o cache. Ele acelera reinstalações, só que quase ninguém limpa e ele cresce indefinidamente. Eram 7,5 GB. Esse número escondia uma surpresa que só entendi depois da segunda execução.
Simuladores iOS e Xcode. Somados, CoreSimulator, Xcode e XcodeBuildMCP passavam de 48 GB. Boa parte disso é recriável sob demanda: o Xcode baixa símbolos de novo, recompila DerivedData, recria simuladores.
Repare no padrão. O que pesa numa máquina de desenvolvimento são caches de ferramentas de desenvolvimento e artefatos de automação. Fotos, documentos e vídeos pesam pouco perto disso. Esse ponto decide qual ferramenta resolve qual parte do problema.
Antes do código, a arquitetura:
mo cleanMole é uma ferramenta de linha de comando open source para limpeza e manutenção de macOS. O subcomando mo clean varre caches de apps, logs, Lixeira, metadados do Finder, caches de navegadores, armazenamento em nuvem e apps de escritório. Uso a versão 1.26.0:
mo --version
# Mole version 1.26.0
Nas execuções registradas nos meus logs, mo clean --dry-run estimou entre 3,47 GB e 9,65 GB de espaço potencial, dependendo do dia. É um bom primeiro andar. Cobre o que eu esqueceria e faz isso com cuidado, inclusive com uma whitelist explícita. A saída mais recente mostra parte dela:
✓ Whitelist: 20 core patterns active
→ /Users/andersonlimadev/Library/Caches/ms-playwright*
→ /Users/andersonlimadev/.cache/huggingface*
→ /Users/andersonlimadev/.gradle/caches/*
→ /Users/andersonlimadev/.ollama/models/*
→ /Users/andersonlimadev/Library/Caches/pypoetry/virtualenvs*
→ /Users/andersonlimadev/Library/Caches/JetBrains*
...
Essa whitelist é a razão de existir da Camada 2. O Mole protege de propósito caches de ferramentas de desenvolvimento, modelos locais e ambientes virtuais, porque um produto genérico não sabe se você vai precisar deles amanhã. Ele também não conhece worktrees de agentes, não mexe em simuladores e não faz npm cache clean. Cada uma dessas escolhas é razoável para quem distribui uma ferramenta para milhares de pessoas. Na minha máquina, são justamente os maiores alvos.
Um detalhe que influenciou o desenho: mo clean tem --dry-run, mas a execução real é interativa. Não existe --apply. Por isso o meu script sempre chama o Mole em dry-run e deixa a execução real para eu rodar manualmente, olhando o que ele propõe.
mo_clean_full.shÉ um script bash de cerca de 920 linhas que cobre o que a Camada 1 não cobre e mantém o mesmo espírito: nada é apagado sem --apply. Ele chama o Mole como primeira categoria e segue com as outras dezoito. Esta é a numeração real do código hoje:
| # | Categoria (--only/--skip) | Alvo | Regra de segurança |
|---|---|---|---|
| 1 | mo | mo clean --dry-run | Sempre dry-run; a execução real é manual. |
| 2 | worktrees | ~/.codex/worktrees/* | Só diretórios sem modificação há mais de 7 dias. |
| 3 | tmp_artifacts | /tmp/lemon-*, /tmp/vaidefut-* e afins | Allowlist de padrões e idade mínima de 24 h. |
| 4 | npm | ~/.npm | npm cache clean --force, depois _cacache e _logs. |
| 5 | pnpm | ~/Library/pnpm | pnpm store prune antes de remover. |
| 6 | pypoetry | ~/Library/Caches/pypoetry | poetry cache clear --all pypi antes de remover. |
| 7 | playwright | ~/Library/Caches/ms-playwright* | Pula se houver processo Playwright ou Chromium ativo. |
| 8 | xcode_derived | Xcode/DerivedData/* | Só projetos com mais de 30 dias. |
| 9 | xcode_devices | Xcode/iOS DeviceSupport/* | Só versões com mais de 90 dias. |
| 10 | sim_runtimes | Simuladores em estado Shutdown | xcrun simctl delete; Booted é preservado. |
| 11 | trash | Conteúdo de ~/.Trash e /Volumes/*/.Trashes | Em --apply, esvazia sem perguntar. |
| 12 | node_modules | ~/Projects/**/node_modules | Delegado ao script legado. Volto a ele adiante. |
| 13 | build_artifacts | .next, .expo, dist | Delegado ao script legado. |
| 14 | homebrew | /opt/homebrew/Cache | brew cleanup -s --prune=all. |
| 15 | quicklook | Cache de thumbnails do Quick Look | qlmanage -r cache; o sistema reconstrói. |
| 16 | diagnostic_reports | DiagnosticReports | Só arquivos com mais de 30 dias. |
| 17 | time_machine_local | Snapshots locais do APFS | tmutil thinlocalsnapshots; não toca o backup externo. |
| 18 | pip | ~/Library/Caches/pip, ~/.cache/pip | pip3 cache purge antes de remover. |
| 19 | silos_* | Volume externo /Volumes/lemon-ssd | Nunca apaga node_modules; pula se houver VM Colima rodando. |
Os limites de idade são variáveis de ambiente (WORKTREE_STALE_DAYS, DERIVED_STALE_DAYS, DEVSUPPORT_STALE_DAYS, DIAG_STALE_DAYS, TMP_STALE_HOURS, SSD_STALE_DAYS), então dá para apertar ou afrouxar sem editar o arquivo.
A tabela tem uma história escondida. A primeira execução com --apply terminou na categoria 13: as categorias 14 a 18 ainda não existiam. Elas aparecem pela primeira vez no log da segunda execução, três horas depois. A categoria 19, do volume externo, veio depois disso. O script cresceu conforme o diagnóstico mostrava onde o espaço estava.
Um LaunchAgent roda o script a cada hora, sempre em dry-run. Ao final, o script dispara uma notificação nativa com um de três textos:
🟢 Lemon · SSD limpo Nenhum alvo encontrado — sistema já enxuto
🟡 Lemon · SSD (DRY) ~N alvos detectados. Rode com --apply para limpar.
🟢 Lemon · SSD limpo ~N alvos removidos · ~X MB liberados
O terceiro texto só aparece quando eu rodo --apply. Essa é a parte que considero mais importante do desenho: a máquina detecta e avisa, e quem decide apagar sou eu. O job nunca roda --apply sozinho.
Vale também dizer o que não é automático. Desinstalar apps, mexer em ~/Movies e decidir se um worktree antigo ainda tem valor continuam sendo decisões humanas. O script lista e mede. Não decide.
O objetivo aqui não é mostrar as 920 linhas. É mostrar as decisões que tornam o script seguro de rodar numa máquina com trabalho de verdade dentro.
Os trechos são excertos e não rodam soltos. Funções como log, ok, warn, skip, header e have são helpers de log e de verificação de comando definidos em lib/_common.sh. As dependências externas se instalam pelo Homebrew: brew install terminal-notifier e, para o Mole, o comando indicado no README do projeto.
Quase todo guia de bash recomenda começar com set -euo pipefail. Este script começa assim:
#!/usr/bin/env bash
# mo_clean_full.sh — Limpeza profunda do SSD para o projeto lemon-scripts.
set -o pipefail
# (desabilitamos -u e -e propositalmente:
# • bash 3.2 (default do macOS) falha em expansão de array vazio "${ARR[@]}"
# sob set -u — inicializamos arrays no topo e usamos guards.
# • -e abortaria a limpeza inteira caso UMA categoria falhasse (ex.: lixeira
# não existente). Cada categoria tem `|| true` e reporta erro isoladamente.)
Os dois flags removidos têm cicatriz associada. Explico cada uma na seção de falhas. Por ora, guarde a ideia: num script de manutenção com dezenove etapas independentes, uma etapa que falha não deve derrubar as outras dezoito.
O script carrega lib/_common.sh antes de qualquer coisa. Se a lib não existir, ele para com código 127 em vez de seguir sem as proteções:
THIS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB="$THIS_DIR/lib/_common.sh"
if [[ -r "$LIB" ]]; then
source "$LIB"
else
echo "[FATAL] lib/_common.sh não encontrada em $LIB" >&2
exit 127
fi
A lib define o modo de execução. Dry-run é o padrão, e só --apply muda isso:
DRY_RUN=true
FORCE=false
for arg in "$@"; do
case "$arg" in
--apply) DRY_RUN=false ;;
--force) FORCE=true ;;
--dry-run) DRY_RUN=true ;;
esac
done
A peça central é safe_rm. Toda remoção do script passa por ela (o script a chama de guard_path, um apelido mais legível):
safe_rm() {
local target="$1"
# Recusa paths perigosos (vazio, raiz, home puro, dirs de sistema)
case "$target" in
""|"/"|"$HOME"|"$HOME/"|"/Users"|"/Users/"|"/System"|"/System/"*|"/Library"|"/bin"|"/usr"|"/etc"|"/var"|".."|"."|"~"|"~/")
err "Recusando apagar path perigoso: '$target'"; return 1 ;;
esac
# Existe? (suporta glob)
if [[ ! -e "$target" ]] && ! compgen -G "$target" >/dev/null 2>&1; then
return 0
fi
local sz; sz=$(sizeof "$target")
if $DRY_RUN; then
echo -e "${YELLOW} [DRY]${NC} apagaria: $target ${BOLD}(${sz})${NC}"
else
_account "$target"
rm -rf "$target" 2>/dev/null && echo -e "${GREEN} [DEL]${NC} $target ${BOLD}(${sz})${NC}" \
|| warn "não consegui apagar: $target"
fi
}
Três propriedades importam aqui:
rm -rf / ou rm -rf $HOME. É o clássico bug de script de limpeza, e a lista existe para ele.[DRY] apagaria: ou apaga de fato. Não há dois códigos que podem divergir._account soma o tamanho em LEMON_FREED_KB. No final, freed_report imprime o total.A contabilidade tem um limite que preciso registrar com honestidade: ela só mede o que passa por safe_rm. Quando o script delega para outro programa (npm cache clean, brew cleanup, xcrun simctl delete ou os scripts legados), esse espaço não entra na conta. Por isso o número do script e o número do df nunca batem.
A outra função da lib é confirm, e ela tem uma sutileza:
confirm() {
$FORCE && return 0
$DRY_RUN && return 1 # em dry-run nunca confirma ação real
read -rp "$(echo -e "${YELLOW}$1${NC} [s/N] ")" _ans
[[ "$_ans" =~ ^[sSyY]$ ]]
}
O nome engana: --force não significa "apagar de verdade". Significa "não perguntar". Quem decide se algo é apagado é --apply. As quatro combinações ficam assim:
sem --force | com --force | |
|---|---|---|
| dry-run (padrão) | confirm responde "não"; relatório parcial | relatório completo, nada apagado |
--apply | pergunta categoria por categoria | apaga sem perguntar |
Em dry-run sem --force, a maioria das categorias nem chega a listar o que apagaria. Com --force, a categoria entra no bloco de remoção e safe_rm imprime [DRY] apagaria:, porque DRY_RUN continua verdadeiro. Por isso o LaunchAgent roda com --dry-run --force. Parece contraditório, mas é a única combinação que produz um relatório completo sem apagar nada. A célula de baixo à direita é a perigosa, e vai aparecer de novo na seção de falhas.
--skip e --onlyRodar dezenove categorias para testar uma só seria lento e arriscado. O script aceita dois seletores, repetíveis, nas formas --skip npm e --skip=npm:
SKIP=()
ONLY=()
i=1
while (( i <= $# )); do
arg="${!i}"
case "$arg" in
--apply|--force|--dry-run) ;; # já tratado pela lib
--skip=*) SKIP+=("${arg#*=}") ;;
--only=*) ONLY+=("${arg#*=}") ;;
--skip) (( i++ )); SKIP+=("${!i:-}") ;;
--only) (( i++ )); ONLY+=("${!i:-}") ;;
*) : ;; # ignora silencioso
esac
(( i++ ))
done
is_skipped() {
local cat="$1"
for s in "${SKIP[@]}"; do [[ "$s" == "$cat" ]] && return 0; done
if (( ${#ONLY[@]} > 0 )); then
local found=0
for o in "${ONLY[@]}"; do [[ "$o" == "$cat" ]] && found=1; done
(( found == 0 )) && return 0 # ONLY setado e cat não está → pula
fi
return 1
}
A semântica tem duas regras. SKIP é exclusão absoluta: o que está nele nunca roda. ONLY é uma lista de permissão: quando existe, tudo o que não está nela é pulado. Se uma categoria aparece nas duas, SKIP vence, porque é verificado primeiro.
Na prática, os comandos que mais uso são estes:
bash mo_clean_full.sh --force # relatório completo (dry-run)
bash mo_clean_full.sh --only npm --apply # limpa só o cache do npm
bash mo_clean_full.sh --apply --force --skip playwright
O terceiro é exatamente o comando das duas execuções reais. Pulei o Playwright porque havia testes rodando naquela noite.
if ! is_skipped worktrees; then
header "2) Worktrees Codex órfãos (>${WORKTREE_STALE_DAYS:-7}d sem alteração)"
WT_ROOT="$HOME/.codex/worktrees"
if [[ ! -d "$WT_ROOT" ]]; then
skip "diretório $WT_ROOT não existe"
else
WT_STALE_DAYS="${WORKTREE_STALE_DAYS:-7}"
WT_ORPHANS=()
while IFS= read -r d; do WT_ORPHANS+=("$d"); done < <(find "$WT_ROOT" -maxdepth 1 -mindepth 1 -type d -mtime "+$WT_STALE_DAYS" 2>/dev/null | sort)
if (( ${#WT_ORPHANS[@]} == 0 )); then
ok "nenhum worktree órfão encontrado"
else
log "Encontrados ${#WT_ORPHANS[@]} worktrees sem modificação há >${WT_STALE_DAYS}d"
total_sz=0
for wt in "${WT_ORPHANS[@]}"; do
sz=$(du -sk "$wt" 2>/dev/null | awk '{print $1+0}')
total_sz=$(( total_sz + sz ))
log " - $(basename "$wt") ($(du -sh "$wt" 2>/dev/null | awk '{print $1}'))"
done
log "Total estimado: ~$(( total_sz / 1024 )) MB"
if confirm "Remover ${#WT_ORPHANS[@]} worktrees orfãos?"; then
for wt in "${WT_ORPHANS[@]}"; do guard_path "$wt"; done
ok "Worktrees órfãos processados"
fi
fi
fi
fi
A heurística é deliberadamente burra: find -mtime +7 no primeiro nível de ~/.codex/worktrees. Ela não pergunta ao git se o worktree tem commits não enviados. Aceito esse risco porque, no meu fluxo, worktree de agente é descartável por definição: o que importa vira branch e PR. Se no seu fluxo não é assim, esse é o lugar para acrescentar um git -C "$wt" status --porcelain antes de apagar.
Na primeira execução, a categoria encontrou 20 worktrees e estimou cerca de 7.102 MB. Os maiores:
[ .. ] - 8bf6 (1.3G)
[ .. ] - e69e (1.3G)
[ .. ] - eea3 (1.3G)
[ .. ] - 5c57 (1.1G)
[ .. ] - 7aa0 (855M)
[ .. ] - adb7 (841M)
[ .. ] - 5150 (157M)
Sete dos vinte tinham 0 B. Eram cascas vazias de execuções que nunca chegaram a fazer checkout. Três horas depois, a segunda execução não encontrou nenhum órfão. Hoje há 9 worktrees no diretório, todos recentes.
O laço while IFS= read -r no lugar de mapfile não é estilo. É o primeiro bug da lista de falhas.
SHUTDOWN_DEVS=()
while IFS= read -r u; do
[ -z "$u" ] && continue
SHUTDOWN_DEVS+=("$u")
done < <(xcrun simctl list devices 2>/dev/null \
| awk '/Shutdown/ {
s=$0
gsub(/[()]/," ",s)
n=split(s, f, " ")
for (i=1;i<n;i++) if (f[i] != "" && f[i+1]=="Shutdown") { print f[i]; break }
}' \
| sort -u)
xcrun simctl list devices imprime linhas como iPhone 15 Pro (UDID) (Shutdown). O awk troca parênteses por espaços, quebra a linha em campos e pega o campo imediatamente anterior a Shutdown, que é o UDID. Simuladores Booted nunca entram na lista. Na primeira execução foram 10 UDIDs removidos com xcrun simctl delete; o Xcode recria simuladores quando você pede um.
# Importante: NÃO chamamos safe_rm em ~/.Trash em si — o diretório ~/.Trash é
# "system-protected" (Finder/mavis-trash recusam removê-lo). Esvaziamos o CONTEÚDO.
if ! is_skipped trash; then
header "11) Lixeira (~/.Trash/* + /Volumes/*/.Trashes/*)"
TRASH_SZ=$(sizeof_safe "$HOME/.Trash")
log "Tamanho ~/.Trash: $TRASH_SZ"
if $DRY_RUN; then
log "[DRY] esvaziaria conteúdo de ~/.Trash + /Volumes/*/.Trashes"
else
shopt -s nullglob dotglob 2>/dev/null || true
count=0
for item in "$HOME/.Trash"/*; do
[ -e "$item" ] || continue
rm -rf "$item" 2>/dev/null && count=$((count + 1))
done
if have sudo; then
for vol_trash in /Volumes/*/.Trashes; do
[ -d "$vol_trash" ] || continue
sudo rm -rf "$vol_trash"/* 2>/dev/null || true
done
fi
ok "Lixeira esvaziada ($TRASH_SZ liberados, $count itens)"
fi
fi
Essa versão é a terceira. A primeira pedia confirmação interativa. A segunda passava o próprio ~/.Trash para safe_rm, que falhou nas duas execuções reais com a mesma mensagem:
[ WARN ] não consegui apagar: /Users/andersonlimadev/.Trash
[ OK ] Lixeira esvaziada
Repare na segunda linha: o script reportou sucesso logo depois de falhar. Era um ok incondicional. A versão atual itera o conteúdo com nullglob e dotglob (para pegar arquivos ocultos e não quebrar com Lixeira vazia) e conta os itens de verdade. Essa falha tem um papel importante na falha 5, mais adiante.
O comentário do código cita o mavis-trash: um shim que, no ambiente de um agente de IA que eu uso, substitui rm por "mover para a Lixeira via Finder". Ele também vai importar na falha 5.
_send_notification() {
local title="$1" body="$2" kind="${3:-ok}"
local sender="com.lemon.scripts.mo-clean-full"
local icon="$ICON_1X"
[[ -r "$ICON_2X" ]] && icon="$ICON_2X"
if command -v terminal-notifier >/dev/null 2>&1 && [[ -r "$icon" ]]; then
if terminal-notifier \
-title "$title" -message "$body" -sender "$sender" \
-appIcon "$icon" -group "lemon-mo-clean-full" \
-activate "com.apple.Terminal" >/dev/null 2>&1; then
log "Notificação via terminal-notifier ($kind)"
return 0
fi
fi
if command -v osascript >/dev/null 2>&1; then
local t="${title//\"/\\\"}" b="${body//\"/\\\"}"
if osascript -e "display notification \"$b\" with title \"$t\" subtitle \"Lemon mo clean full\"" >/dev/null 2>&1; then
log "Notificação via osascript ($kind)"
return 0
fi
fi
warn "Nenhum notificador disponível"
return 1
}
terminal-notifier permite ícone próprio, identidade de remetente e agrupamento (-group), então uma notificação nova substitui a anterior em vez de empilhar 24 avisos por dia. Se ele não estiver instalado, osascript entrega uma notificação simples. Se nenhum funcionar, o script registra no log e segue. O log atual tem 12 notificações entregues via terminal-notifier.
Um detalhe do fallback: osascript recebe o texto dentro de uma string AppleScript. As substituições ${title//\"/\\\"} escapam aspas para que um título com " não quebre o comando.
No macOS, o jeito nativo de agendar trabalho de usuário é o launchd, não o cron. O job vive em ~/Library/LaunchAgents/com.lemon.scripts.mo-clean-hourly.plist. Mostro a parte que importa (o arquivo real tem comentários e um bloco de variáveis de ambiente):
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.lemon.scripts.mo-clean-hourly</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/andersonlimadev/Projects/lemon_scripts/macos/mo_clean_full.sh</string>
<string>--dry-run</string>
<string>--force</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin:/opt/homebrew/sbin</string>
<key>LANG</key>
<string>en_US.UTF-8</string>
</dict>
<key>WorkingDirectory</key>
<string>/Users/andersonlimadev/Projects/lemon_scripts/macos</string>
<key>StartInterval</key>
<integer>3600</integer>
<key>RunAtLoad</key>
<false/>
<key>KeepAlive</key>
<false/>
<key>Nice</key>
<integer>5</integer>
<key>ProcessType</key>
<string>Background</string>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>StandardOutPath</key>
<string>/Users/andersonlimadev/Projects/lemon_scripts/logs/mo_clean_hourly.launchd.out</string>
<key>StandardErrorPath</key>
<string>/Users/andersonlimadev/Projects/lemon_scripts/logs/mo_clean_hourly.launchd.err</string>
</dict>
</plist>
Cada chave tem um motivo:
| Chave | Valor | Por quê |
|---|---|---|
StartInterval | 3600 | Uma vez por hora basta para pegar worktrees e artefatos antes que se acumulem. |
RunAtLoad | false | A primeira execução espera uma hora. O login não fica mais lento. |
KeepAlive | false | É um job que roda e termina, não um serviço. |
Nice + ProcessType | 5 + Background | Prioridade baixa. A limpeza não compete com o editor nem com a build. |
LimitLoadToSessionType | Aqua | Só carrega numa sessão gráfica de usuário, onde a Central de Notificações existe. |
EnvironmentVariables.PATH | Homebrew primeiro | O launchd não lê o seu .zshrc. Sem isso, mo, terminal-notifier e brew não são encontrados. |
O PATH explícito merece ênfase. É o erro mais comum de quem migra um script do terminal para o launchd: no terminal tudo funciona porque o shell interativo carregou o Homebrew; no launchd o ambiente é mínimo. A documentação da Apple sobre agentes e daemons do launchd descreve essas chaves, e man launchd.plist tem a referência completa.
O ciclo completo de uma execução fica assim:
Para instalar, carregar e conferir:
cp com.lemon.scripts.mo-clean-hourly.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.lemon.scripts.mo-clean-hourly.plist
launchctl list | grep com.lemon.scripts
25258 0 com.lemon.scripts.mo-clean-hourly
A segunda coluna é o código de saída da última execução. Zero significa que a última rodada terminou bem. Em versões recentes do macOS, launchctl load e unload são considerados legados e a Apple recomenda launchctl bootstrap gui/$(id -u) <plist> e bootout. Os dois funcionam; uso load por hábito.
O cron funciona no macOS, mas não sabe nada sobre sessões. Um job de cron roda fora da sessão gráfica, então a notificação, que é o ponto da Camada 3, depende de truques. O launchd resolve isso com uma chave (LimitLoadToSessionType) e ainda aplica prioridade de processo e classe de background, que o cron não conhece. Num laptop que alterna entre bateria, sono e trabalho pesado, isso faz diferença.
Esta é a seção que justifica o artigo. Nenhum dos problemas abaixo foi hipotético.
mapfile não existe no bash do macOSO macOS ainda vem com bash 3.2.57 em /bin/bash, por questão de licença (as versões 4 e 5 são GPLv3). Conferi na máquina:
/bin/bash --version | head -1
# GNU bash, version 3.2.57(1)-release (arm64-apple-darwin25)
mapfile (ou readarray) só existe a partir do bash 4. A primeira versão do script usava mapfile -t x < <(find ...) e quebrou com:
mo_clean_full.sh: line 169: mapfile: command not found
A correção é o laço clássico, compatível com qualquer bash:
x=()
while IFS= read -r d; do x+=("$d"); done < <(find ... | sort)
Apliquei em todos os pontos que listam diretórios: worktrees, DerivedData, DeviceSupport, simuladores, e depois nas categorias novas. Dava para instalar bash 5 pelo Homebrew e mudar o shebang. Não fiz isso porque o LaunchAgent chama /bin/bash explicitamente, e um script de manutenção não deve depender de uma ferramenta que ele mesmo pode precisar limpar.
set -u e o array vazioCom set -u, o bash 3.2 trata "${ARR[@]}" de um array vazio como variável não definida, mesmo que você tenha declarado ARR=(). O erro aparece assim:
x[@]: unbound variable
Esse comportamento mudou no bash 4.4. Em 3.2, as saídas são proteger cada expansão com ${ARR[@]:-} (que injeta uma string vazia e exige cuidado nos laços) ou abrir mão do -u. Escolhi abrir mão e declarar todos os arrays globais no topo:
WT_ORPHANS=()
hits=()
OLD_DD=()
OLD_DS=()
SHUTDOWN_DEVS=()
O script ainda usa ${hits_builds[@]:-} num ponto da categoria 19, junto com um filtro que descarta a entrada vazia. É o tipo de inconsistência que aparece quando um arquivo cresce em várias sessões.
set -e derrubava a esteira inteiraCom set -e, qualquer comando que retorna diferente de zero encerra o script. Na primeira versão, a categoria 11 (Lixeira) passava ~/.Trash para safe_rm, a remoção falhava e o script morria ali, com exit=1, sem chegar ao resumo nem à notificação. Uma Lixeira teimosa impedia a limpeza de pip, Homebrew e Quick Look.
A correção foi remover o -e e tratar cada categoria como uma unidade isolada: cada comando externo leva || true ou um && ok || warn, e cada categoria reporta o próprio resultado. Num script de manutenção, a granularidade da falha precisa ser a categoria, não o arquivo.
A primeira versão do parser de simuladores fazia gsub(/[()]/,"") e imprimia $NF, o último campo da linha. Só que o último campo de iPhone 15 Pro (UDID) (Shutdown) é o estado. O dry-run denunciou:
[DRY] apagaria: Shutdown
Ainda bem que era dry-run. A correção, mostrada antes, percorre os campos e pega o que vem imediatamente antes de Shutdown. É o argumento mais concreto que conheço a favor de dry-run como padrão: o bug apareceu num relatório, não num rm.
node_modules para a LixeiraEsse é o erro que mais me ensinou.
As categorias 12 e 13 delegam para dois scripts antigos que eu mantinha havia anos, de antes de existir a lib. Eis o clean_node_modules.sh inteiro, sem os comentários:
BASE_DIR="${1:-$HOME/Projects}"
echo "🔍 Buscando pastas node_modules em $BASE_DIR ..."
echo
find "$BASE_DIR" -type d -name "node_modules" -prune -exec rm -rf '{}' +
echo
echo "✅ Limpeza finalizada com sucesso."
Não há dry-run, idade mínima, lista de exclusão ou safe_rm. O script novo protegia a chamada com confirm e com $DRY_RUN. Só que eu rodei com --apply --force, a célula perigosa da tabela, então confirm devolveu "sim" sem perguntar, e o script legado fez exatamente o que sabia fazer. O log da primeira execução:
║ 12) node_modules (delega para clean_node_modules.sh) ║
>> MODO --apply ATIVO (alterações REAIS serão feitas).
🔍 Buscando pastas node_modules em /Users/andersonlimadev/Projects ...
mavis-trash: moved to trash: '.../lemon-code-project/lemon-code/node_modules'
mavis-trash: moved to trash: '.../lemon-code-app/omni-gateway/node_modules'
mavis-trash: moved to trash: '.../vaiterfut-app/vaidefut-web/node_modules'
mavis-trash: moved to trash: '.../vaiterfut-app/vaiterfut-mobile-app/node_modules'
mavis-trash: moved to trash: '.../vaiterfut-mobile-app/functions/node_modules'
...
✅ Limpeza finalizada com sucesso.
Foram catorze diretórios node_modules, incluindo projetos em que eu estava trabalhando naquela semana. Nenhum deles entrou na contagem de espaço liberado, porque o script legado não passa por safe_rm.
Duas coincidências evitaram o estrago. A primeira: eu estava rodando o script de dentro de uma sessão de agente cujo PATH incluía o mavis-trash, um shim que intercepta rm e move os arquivos para a Lixeira via Finder em vez de apagar. Por isso o log diz "moved to trash". A segunda: a etapa da Lixeira falhou, como vimos, então nada foi esvaziado naquela rodada. Os node_modules continuavam recuperáveis. De qualquer forma, node_modules se recria com npm install, e o prejuízo real teria sido tempo.
A categoria 13 também falhou, de outro jeito, com uma mensagem que não esclarece nada:
║ 13) Build artifacts .next/.expo/dist (delega para clean_builds.sh) ║
(eval):1: == not found
O que mudou depois disso:
JAMAIS apagar node_modules (projetos em desenvolvimento ativo). Ela usa find ... -prune em node_modules, .venv e venv, e ainda filtra o resultado uma segunda vez por padrão de path.--skip node_modules --skip build_artifacts até reescrever as duas categorias com idade mínima e safe_rm.A lição vale para qualquer script que delega: as garantias do chamador não se transferem para o chamado. Um confirm na porta não protege o que acontece dentro de um programa que não conhece dry-run.
Uma observação sobre o shim, já que ele ajudou: o PATH do LaunchAgent não inclui o diretório do mavis-trash. Quando o launchd roda o script, rm é o /bin/rm de verdade. A rede de segurança que me salvou não existe no job agendado, o que reforça a decisão de o job nunca usar --apply.
Na primeira execução, a categoria 4 rodou npm cache clean --force, apagou _logs (3,2 MB) e reportou "Cache do npm limpo". Três horas depois, a segunda execução encontrou ~/.npm com 7,7 GB. A conta não fechava, então medi as subpastas:
du -sh ~/.npm/* | sort -h | tail -4
11M /Users/andersonlimadev/.npm/_prebuilds
197M /Users/andersonlimadev/.npm/_cacache
7.4G /Users/andersonlimadev/.npm/_npx
Quase tudo estava em ~/.npm/_npx, o diretório onde o npx guarda os pacotes que executa. npm cache clean --force limpa _cacache, não _npx. Numa máquina onde agentes e servidores MCP rodam npx -y alguma-coisa o dia inteiro, _npx vira o maior cache do npm, e o comando oficial de limpeza não toca nele.
O script ainda não trata isso, e está na lista de próximos passos. Por enquanto, a remoção manual de ~/.npm/_npx é segura no sentido de que o npx baixa de novo o que precisar, ao custo da primeira execução seguinte ser mais lenta.
Dois problemas menores que encontrei relendo o código para este artigo, sem consequência destrutiva:
ok "... removidos" depois do laço de guard_path, mesmo em dry-run. O log de dry-run de hoje contém a linha [OK] Build artifacts de projects-ssd removidos (node_modules preservados), e nada foi removido./tmp, DerivedData, DeviceSupport e simuladores. npm, Poetry, pip, Homebrew e o volume externo não entram. A mensagem "Nenhum alvo encontrado" pode aparecer com gigabytes de cache para limpar.Nenhum dos dois apaga nada. Os dois corroem a confiança no sinal, e a Camada 3 inteira depende dessa confiança. Estão na lista.
Limpar caches resolve a maior parte, mas apps instalados também ocupam espaço. Para decidir quais remover, usei o índice do Spotlight, que registra a data do último uso de cada app:
mdfind 'kMDItemLastUsedDate > $time.today(-30)'
O comando devolve os itens usados nos últimos 30 dias. Os apps fora dessa lista viram candidatos. A tabela gerada naquela noite:
| App | Tamanho | Situação |
|---|---|---|
| Xcode | 4,0 GB | Sem registro de uso (o índice pode estar incompleto) |
| IntelliJ IDEA | 3,9 GB | Sem uso em 30 dias |
| iMovie | 3,2 GB | Sem uso em 30 dias |
| GitHub Desktop | 681 MB | Sem uso em 30 dias |
| Perssua | 664 MB | Sem uso em 30 dias |
| logioptionsplus | 518 MB | Sem uso em 30 dias |
| Keynote | 488 MB | Sem uso em 30 dias |
| zoom.us | 469 MB | Sem uso em 30 dias |
| Pages | 421 MB | Sem uso em 30 dias |
| MongoDB Compass | 401 MB | Sem uso em 30 dias |
| Postman | 365 MB | Sem uso em 30 dias |
| Slack | 323 MB | Sem uso em 30 dias |
| Grok Bot | 312 MB | Sem uso em 30 dias |
| Notion | 293 MB | Duas cópias (/Applications e ~/Applications) |
A lista sugeria quase 16 GB. Removi três apps: GitHub Desktop, MongoDB Compass e iMovie. Os outros ficaram, porque "não usei em 30 dias" não significa "não vou precisar". Xcode sem registro de uso é o melhor exemplo do limite da heurística: xcodebuild e xcrun simctl usam as ferramentas do Xcode sem abrir o app, e o simulador e o DerivedData que apareceram no diagnóstico mostram que ele estava em uso.
A remoção foi feita movendo os apps para a Lixeira, o que mantém a reversão a um arraste de distância:
mavis-trash -- \
"/Applications/GitHub Desktop.app" \
"/Applications/MongoDB Compass.app" \
"/Applications/iMovie.app"
Essa parte nunca vai para o script. Um job que desinstala apps sozinho com base em data de último uso é o tipo de automação que funciona até o dia em que você precisa abrir o Keynote dez minutos antes de uma apresentação.
| Momento | Livre | Uso | Fonte |
|---|---|---|---|
| Antes | 5,6 GB | 99% | df -h |
Após o 1º --apply (19h46 às 19h50) | 25 GB | 95% | última linha de /tmp/apply.log |
| Após remover 3 apps | 36 GB | 92% | df -h |
Após o 2º --apply (23h14 às 23h16) | 43 GB | 90% | última linha de /tmp/apply2.log |
| Hoje, 12 de outubro | 43 GB | 90% | df -h |
O que cada execução mediu por conta própria:
| 1ª execução | 2ª execução | |
|---|---|---|
| Duração | 4 min 31 s | 2 min 45 s |
mo clean (estimativa, dry-run) | 3,47 GB, 77 itens | 3,86 GB, 580 itens |
| Worktrees órfãos | 20 (~7.102 MB) | 0 |
Artefatos em /tmp | 11 (~457 MB) | 23 (~193 MB) |
| Poetry | 1,2 GB | já não existia |
| pnpm store | 40 MB | já não existia |
Simuladores Shutdown | 10 | 0 |
Total medido por freed_report | ~8.830 MB | ~127 MB |
Os 8.830 MB da primeira execução batem com a soma dos itens que passaram por safe_rm: worktrees, /tmp, Poetry e pnpm. Simuladores, node_modules e o que o npm cache clean removeu ficam fora da conta, pelo motivo explicado na seção da biblioteca.
O df é a medida que importa, mas também não é uma soma simples. Em APFS, o espaço livre é do container compartilhado entre volumes, e "usado" mais "livre" raramente fecha com o total do disco. Itens movidos para a Lixeira continuam ocupando espaço até ela ser esvaziada. Não consigo atribuir cada gigabyte a uma categoria com precisão, e prefiro dizer isso a inventar uma decomposição.
O que dá para afirmar: saí de 5,6 GB para 43 GB livres em uma noite, sem reinstalar nada e sem perder código, e o job continua rodando de hora em hora.
Ferramenta pronta e ferramenta própria trabalham em série. O Mole é excelente no que se propõe, e a whitelist dele é uma decisão consciente. Uma ferramenta feita para milhares de usuários precisa ser conservadora justamente com os caches de desenvolvimento, que em máquinas como a minha são a maior parte do problema. A camada própria não substitui o Mole. Ela começa onde ele decide parar.
Dry-run por padrão encontra bugs antes que eles apaguem coisas. O parser de simuladores teria tentado apagar algo chamado "Shutdown". O dry-run mostrou isso num relatório. Toda remoção passar por uma função que sabe imprimir em vez de apagar é o que permite rodar o script toda hora sem medo.
Garantias não atravessam fronteiras de processo. O episódio dos node_modules mostrou isso de forma cara. confirm e DRY_RUN protegem o código que os consulta. Um script legado chamado de dentro não herda nada disso. Se você delega, delega também a responsabilidade de verificar o que o outro lado faz.
Escreva para o bash que a máquina tem. No macOS, isso significa bash 3.2: sem mapfile, com cuidado em set -u e arrays vazios. Pedir ao usuário que instale bash 5 resolve o seu problema e cria outro para ele.
launchd entende laptops. Sessão gráfica, prioridade baixa, classe de background, PATH explícito. O cron não foi pensado para uma máquina que fecha a tampa.
A notificação é um contrato, e contratos precisam ser verdadeiros. Ícone próprio, remetente identificável, número concreto. Se o sinal disser "nenhum alvo" quando há 7 GB de _npx esperando, eu aprendo a ignorar a notificação, e a Camada 3 deixa de existir na prática.
~/.npm/_npx como categoria própria, com idade mínima.ok "... removidos" incondicionais por mensagens que dependem de DRY_RUN.safe_rm e idade mínima, ou removê-las.colima-home/_lima no volume externo ainda servem para alguma coisa.~/Movies (8,5 GB) para o volume externo.launchctl load por launchctl bootstrap na documentação do plist.Os arquivos que sustentam este artigo, para quem quiser reproduzir:
macos/mo_clean_full.sh: o script completo (19 categorias).macos/lib/_common.sh: safe_rm, confirm, freed_report e o modo dry-run.macos/launchd/com.lemon.scripts.mo-clean-hourly.plist: o LaunchAgent.logs/mo_clean_full.log e logs/mo_clean_full.mole.out: a execução horária mais recente./tmp/apply.log e /tmp/apply2.log: as duas execuções reais com --apply, de 11 de outubro de 2026.Fontes externas:
mo clean.man launchd.plist, man tmutil, man qlmanage e xcrun simctl help para os detalhes de cada ferramenta.Produtos gratuitos e pagos para transformar ideias em uma base que você consegue executar.
13 produtos disponíveisContinue explorando tópicos similares

Como criei um sistema avançado de notificações no macOS totalmente integrado ao Claude Code, usando shell script, AppleScript e automação inteligente para aumentar produtividade, sincronizar alertas com iPhone/iPad e melhorar fluxos de desenvolvimento.

A Ilusão do MCP: Por Que a CLI Ainda é a Única Interface de Alta Performance para IAs Resumo Executivo: Enquanto o mercado se deslumbra com o Model Context Protocol MCP, desenvol...
Checklist de 47 pontos para encontrar bugs, riscos de segurança e problemas de performance antes do lançamento.
Templates testados em produção, usados por desenvolvedores. Economize semanas de setup no seu próximo projeto.
