mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
916 lines
49 KiB
Bash
Executable File
916 lines
49 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Helpers compartilhados pelos scripts do kit. Sourced, não executado direto.
|
|
set -euo pipefail
|
|
|
|
COMPOSE="docker-compose.prod.yml"
|
|
COMPOSE_TRAEFIK="docker-compose.traefik.yml"
|
|
COMPOSE_NPM="docker-compose.npm.yml"
|
|
|
|
# Proxy reverso desta instalação. Vem do .env (load_env), com default 'caddy' —
|
|
# ou seja, toda instalação que já existe continua exatamente como está.
|
|
#
|
|
# caddy → o kit sobe o próprio Caddy nas portas 80/443 (VPS "cru")
|
|
# traefik → a VPS JÁ tem um Traefik nessas portas (Hostinger, Coolify,
|
|
# Dokploy...). Entra o override, que desliga o Caddy e publica o app
|
|
# por labels. Ver o cabeçalho de docker-compose.traefik.yml.
|
|
# npm → a VPS JÁ tem um Nginx Proxy Manager nessas portas (não lê labels
|
|
# Docker — o roteamento é manual, na UI dele). Entra o override, que
|
|
# desliga o Caddy e garante o `app` na rede/IP que o Proxy Host
|
|
# espera. Ver o cabeçalho de docker-compose.npm.yml.
|
|
#
|
|
# Todo `docker compose` do kit passa por aqui: com proxy externo, um comando sem
|
|
# o override subiria o Caddy e ele iria bater de frente com o proxy da hospedagem.
|
|
dc() {
|
|
case "${REVERSE_PROXY:-caddy}" in
|
|
traefik) docker compose -f "$COMPOSE" -f "$COMPOSE_TRAEFIK" "$@" ;;
|
|
npm) docker compose -f "$COMPOSE" -f "$COMPOSE_NPM" "$@" ;;
|
|
*) docker compose -f "$COMPOSE" "$@" ;;
|
|
esac
|
|
}
|
|
|
|
# A mesma lista de -f, como texto, para as mensagens que ensinam o comando ao
|
|
# dono. Se a mensagem omitisse o override numa instalação com proxy externo, o
|
|
# próprio dono derrubaria o site seguindo a instrução do kit.
|
|
dc_files() {
|
|
case "${REVERSE_PROXY:-caddy}" in
|
|
traefik) printf -- '-f %s -f %s' "$COMPOSE" "$COMPOSE_TRAEFIK" ;;
|
|
npm) printf -- '-f %s -f %s' "$COMPOSE" "$COMPOSE_NPM" ;;
|
|
*) printf -- '-f %s' "$COMPOSE" ;;
|
|
esac
|
|
}
|
|
|
|
# ── A rede externa por onde o proxy de fora alcança o app ────────────────────
|
|
# O nome que o docker compose dá ao projeto quando ninguém passa -p: basename do
|
|
# diretório, minúsculo, só [a-z0-9_-] — E com os `_`/`-` do INÍCIO aparados
|
|
# (NormalizeProjectName faz TrimLeft). Sem essa aparada, uma pasta como
|
|
# `/root/_deskcomm` faz o kit calcular `_deskcomm` enquanto os contêineres
|
|
# carregam `deskcomm`: a instalação deixa de se reconhecer e passa a se tratar
|
|
# como intrusa. Medido contra o docker compose v2.38.2 em `_deskcomm`,
|
|
# `-deskcomm`, `_-_crm` e `_123` — todos divergiam.
|
|
nome_do_projeto_compose() { # nome_do_projeto_compose <diretório>
|
|
local n
|
|
n="$(basename "$1" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_-')"
|
|
printf '%s' "${n#"${n%%[!_-]*}"}"
|
|
}
|
|
|
|
nome_do_projeto_atual() {
|
|
printf '%s' "${COMPOSE_PROJECT_NAME:-$(nome_do_projeto_compose "${PROJECT_DIR:-$PWD}")}"
|
|
}
|
|
|
|
# ── Quem é o DONO deste projeto Docker ───────────────────────────────────────
|
|
#
|
|
# Duas cópias do repo na mesma VPS — o clone de produção e um de teste ao lado —
|
|
# recebem o MESMO nome de projeto compose: o docker o deriva do basename do
|
|
# diretório, e `/root/DeskcommCRM` e `/root/apagar6/DeskcommCRM` dão os dois
|
|
# `deskcommcrm`. Os contêineres são UM conjunto só; os `.env` são dois. Cada
|
|
# `up -d` recria o parque com as credenciais da SUA árvore, e a outra fica
|
|
# falando com um transporte que não a reconhece mais.
|
|
#
|
|
# Não é hipótese. Numa VPS real o clone de teste recriou o contêiner do WhatsApp
|
|
# com a chave dele às 13:30; o app foi recriado da árvore de produção às 14:47,
|
|
# com outra chave; e por TRÊS DIAS toda chamada ao WAHA respondeu 401 — nenhum
|
|
# número conectava, nenhuma mensagem entrava, e o painel só dizia "não foi
|
|
# possível verificar a conexão".
|
|
#
|
|
# O `flock` do agent.sh não protege disso: ele é por DIRETÓRIO, então as duas
|
|
# árvores pegam locks diferentes enquanto disputam os mesmos contêineres. A
|
|
# trava tem de ser pelo que elas de fato compartilham — o projeto Docker.
|
|
#
|
|
# O sinal é o próprio Docker: todo contêiner criado pelo compose carrega o label
|
|
# `com.docker.compose.project.working_dir` com a árvore que o criou.
|
|
donos_do_projeto_em_execucao() { # → um diretório por linha, sem repetir
|
|
docker ps -a \
|
|
--filter "label=com.docker.compose.project=$(nome_do_projeto_atual)" \
|
|
--format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null \
|
|
| grep -v '^$' | sort -u
|
|
}
|
|
|
|
# Imprime as árvores ALHEIAS que ainda são instalações VIVAS; sai 0 quando existe
|
|
# ao menos uma. Sem contêiner no ar não há dono, e uma instalação nova assume
|
|
# legitimamente — por isso o silêncio aqui é "pode seguir", não "não sei".
|
|
#
|
|
# "Viva" é o filtro que impede este guarda de nascer vermelho em quem não fez
|
|
# nada de errado: quem MOVEU a instalação de pasta deixa contêineres apontando
|
|
# para um caminho que não existe mais. Esse não é um rival disputando o parque —
|
|
# é o endereço antigo desta mesma instalação, e recusar ali travaria as
|
|
# atualizações para sempre, num log que ninguém lê. Só conta como rival a árvore
|
|
# que ainda está no disco COM um compose: aquela de onde um segundo cron
|
|
# realmente consegue rodar `up -d`.
|
|
projeto_pertence_a_outra_arvore() {
|
|
local dir vivas=""
|
|
while IFS= read -r dir; do
|
|
[ -n "$dir" ] || continue
|
|
[ "$dir" != "${PROJECT_DIR:-$PWD}" ] || continue
|
|
[ -f "$dir/$COMPOSE" ] || continue
|
|
vivas="${vivas}${vivas:+$'\n'}${dir}"
|
|
done <<EOF
|
|
$(donos_do_projeto_em_execucao)
|
|
EOF
|
|
[ -n "$vivas" ] || return 1
|
|
printf '%s' "$vivas"
|
|
}
|
|
|
|
# O guarda que o agent.sh e o update.sh chamam antes de tocar em contêiner.
|
|
#
|
|
# Falha FECHADA na ação (não mexe em parque alheio) e ABERTA na informação: diz
|
|
# qual árvore é a dona e como assumir de propósito. Parar calado deixaria o dono
|
|
# da VPS achando que o agente atualiza, quando ele desiste a cada 5 minutos.
|
|
#
|
|
# `DESKCOMM_ASSUMIR_PROJETO=1` é a saída para o caso legítimo — a instalação
|
|
# mudou de pasta e os contêineres ainda apontam para a antiga. É explícita de
|
|
# propósito: assumir por engano é justamente o defeito que esta função existe
|
|
# para impedir.
|
|
recusar_projeto_de_outra_arvore() { # recusar_projeto_de_outra_arvore <como reportar>
|
|
local alheias reportar="${1:-}"
|
|
alheias="$(projeto_pertence_a_outra_arvore)" || return 0
|
|
[ "${DESKCOMM_ASSUMIR_PROJETO:-}" != "1" ] || return 0
|
|
|
|
local recado
|
|
recado="os contêineres do projeto '$(nome_do_projeto_atual)' foram criados por outra cópia do repo ($(printf '%s' "$alheias" | tr '\n' ' ')) — esta aqui é $(printf '%s' "${PROJECT_DIR:-$PWD}"). Duas cópias com o mesmo nome de projeto disputam os MESMOS contêineres e cada uma os recria com o .env dela, o que derruba as conexões de WhatsApp e quebra as credenciais. Deixe apenas UMA no cron (crontab -e) ou, se esta é mesmo a instalação boa, rode com DESKCOMM_ASSUMIR_PROJETO=1"
|
|
if [ -n "$reportar" ] && command -v "$reportar" >/dev/null 2>&1; then
|
|
"$reportar" "$recado"
|
|
else
|
|
printf '%s\n' "$recado" >&2
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# A bridge que ESTE projeto reserva para o proxy externo. Um `basename` cru
|
|
# diverge numa pasta com maiúscula, ponto ou underscore inicial — e aí o kit
|
|
# cria uma rede e o compose procura outra.
|
|
rede_reservada_do_proxy() { printf '%s_proxy' "$(nome_do_projeto_atual)"; }
|
|
|
|
# O compose declara TRAEFIK_NETWORK como rede EXTERNA, e rede externa que não
|
|
# existe é recusada ANTES de o compose criar qualquer coisa — medido com o
|
|
# compose v2.38.2: `up -d` morre em "network X declared as external, but could
|
|
# not be found", sem dizer de onde saiu o nome. Descobrir isso aqui, com o nome na
|
|
# mão, é dezenas de minutos de diferença para quem está instalando. Valor escrito
|
|
# à mão no .env passa pelo mesmo crivo: erra tão fácil quanto a detecção.
|
|
#
|
|
# A rede que o instalador reserva para si é o caso em que não existir é NORMAL —
|
|
# instalação nova, ou alguém que rodou `docker network prune`. Aí a resposta é
|
|
# criar, não morrer: o nome é nosso e sabemos a forma dele.
|
|
# Ecoa: ok | criar | inexistente | driver_errado
|
|
veredito_rede_do_proxy() { # veredito_rede_do_proxy <driver encontrado> <rede> <bridge do projeto> [attachable]
|
|
local drv="${1:-}" rede="${2:-}" nossa="${3:-}"
|
|
if [ -z "$drv" ]; then
|
|
[ -n "$nossa" ] && [ "$rede" = "$nossa" ] && { printf 'criar'; return 0; }
|
|
printf 'inexistente'; return 0
|
|
fi
|
|
[ "$drv" = bridge ] && { printf 'ok'; return 0; }
|
|
# $4 = "true" quando a rede é uma overlay attachable (Swarm). Contêiner de
|
|
# compose comum entra numa dessas, então ela serve tão bem quanto uma bridge.
|
|
# Sem o attachable a recusa continua: ali o `up` morreria em
|
|
# "could not attach to network".
|
|
[ "$drv" = overlay ] && [ "${4:-}" = true ] && { printf 'ok'; return 0; }
|
|
printf 'driver_errado'
|
|
}
|
|
|
|
# Aplica o veredito acima: confere no Docker, cria a nossa quando falta, morre
|
|
# explicando quando é de outro. Mora aqui — e não no install.sh — porque o
|
|
# `dc up -d` do update.sh corre exatamente o mesmo risco: a bridge é um artefato
|
|
# como qualquer outro e some num `docker network prune`, ou no `down -v` que o
|
|
# próprio kit ensina como caminho de recomeço. Sem esta checagem a atualização
|
|
# morre com a mesma mensagem opaca do compose, e pior: o agent.sh roda o
|
|
# update.sh sozinho a cada 5 minutos, então ninguém está olhando a tela.
|
|
# Define TRAEFIK_NETWORK quando ela vem vazia — de propósito, é o mesmo default
|
|
# que o instalador grava no .env.
|
|
garantir_rede_do_proxy() {
|
|
# NPM nunca é criado por nós: a rede é sempre do stack do Proxy Manager (ou de
|
|
# quem hospeda), então não há "nossa" bridge para oferecer — só checar e, se
|
|
# sumiu (prune, down -v), morrer explicando em vez do opaco erro do compose.
|
|
if [ "${REVERSE_PROXY:-caddy}" = "npm" ]; then
|
|
local rede
|
|
rede="${PROXY_NETWORK_NAME:-proxy_network}"
|
|
docker network inspect "$rede" >/dev/null 2>&1 && return 0
|
|
die "A rede Docker '$rede' (a do Nginx Proxy Manager) não existe.
|
|
Rode 'docker network ls', identifique a rede do seu NPM (Settings > a que o
|
|
contêiner dele já está conectado) e ponha PROXY_NETWORK_NAME=<nome> no .env
|
|
antes de tentar de novo."
|
|
fi
|
|
[ "${REVERSE_PROXY:-caddy}" = "traefik" ] || return 0
|
|
local nossa drv erro
|
|
nossa="$(rede_reservada_do_proxy)"
|
|
TRAEFIK_NETWORK="${TRAEFIK_NETWORK:-traefik}"
|
|
drv="$(docker network inspect -f '{{.Driver}}' "$TRAEFIK_NETWORK" 2>/dev/null || true)"
|
|
local att
|
|
att="$(docker network inspect -f '{{.Attachable}}' "$TRAEFIK_NETWORK" 2>/dev/null || true)"
|
|
case "$(veredito_rede_do_proxy "$drv" "$TRAEFIK_NETWORK" "$nossa" "$att")" in
|
|
ok) : ;;
|
|
criar)
|
|
# O motivo vai junto porque aqui NÃO se sabe qual é: o comando está certo, e
|
|
# quem recusou foi o Docker (falta de faixa de IP livre numa VPS com muitas
|
|
# stacks é um caso conhecido). Sem repassar a resposta dele, a mensagem
|
|
# mandaria repetir à mão o comando que acabou de falhar.
|
|
if ! erro="$(docker network create "$TRAEFIK_NETWORK" 2>&1 >/dev/null)"; then
|
|
die "Não consegui criar a rede Docker '$TRAEFIK_NETWORK'. O Docker respondeu:
|
|
${erro}"
|
|
fi
|
|
c_dim " (rede '$TRAEFIK_NETWORK' criada — é por ela que o Traefik alcança o CRM)"
|
|
;;
|
|
inexistente)
|
|
die "A rede Docker '$TRAEFIK_NETWORK' não existe.
|
|
Rode 'docker network ls', identifique a rede do seu Traefik e ponha
|
|
TRAEFIK_NETWORK=<nome> no .env antes de tentar de novo."
|
|
;;
|
|
driver_errado)
|
|
# Mandar quem está em modo host "procurar a rede do seu Traefik" é mandar
|
|
# procurar o que não existe: em modo host ele não está em rede nenhuma do
|
|
# Docker. Para esse caso a saída é apagar a linha e deixar o kit decidir —
|
|
# ele cria a bridge do projeto sozinho.
|
|
die "A rede '$TRAEFIK_NETWORK' tem driver '$drv', e o app precisa
|
|
de uma bridge para o Traefik alcançar o contêiner. Se o seu Traefik roda em modo
|
|
host (é o caso quando 'docker ps' não mostra porta publicada nele), APAGUE a linha
|
|
TRAEFIK_NETWORK do .env: o kit cria e usa a rede '$nossa'.
|
|
Senão, rode 'docker network ls' e ponha a bridge certa em TRAEFIK_NETWORK no .env.
|
|
Se for uma overlay do Swarm, ela precisa ter sido criada com --attachable —
|
|
sem isso um contêiner de compose comum não consegue entrar nela."
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Cor só quando há terminal de verdade — mesma regra do install.sh (se mexer
|
|
# numa, mexa na outra). Aqui isso vale dobrado: o update.sh, que herda estas
|
|
# funções, é rodado pelo agent.sh com a saída redirecionada para arquivo
|
|
# (`> "$LOG"`) a cada 5 minutos, para sempre, em toda instalação. Era daí que
|
|
# vinha o escape ANSI que o esc() do agent.sh precisa varrer byte a byte antes
|
|
# de mandar o log no heartbeat; não emitir na origem é a correção de causa.
|
|
if [ -n "${NO_COLOR:-}" ]; then COLOR=0
|
|
elif [ -n "${FORCE_COLOR:-}" ]; then COLOR=1
|
|
elif [ -t 1 ]; then COLOR=1
|
|
else COLOR=0
|
|
fi
|
|
paint() { local code="$1"; shift; if [ "$COLOR" = 1 ]; then printf '\033[%sm%s\033[0m\n' "$code" "$*"; else printf '%s\n' "$*"; fi; }
|
|
c_red() { paint 31 "$*"; }
|
|
c_grn() { paint 32 "$*"; }
|
|
c_ylw() { paint 33 "$*"; }
|
|
c_dim() { paint 2 "$*"; }
|
|
die() { c_red "✖ $*"; exit 1; }
|
|
step() { printf '\n'; paint 1 "▶ $*"; }
|
|
|
|
# Gêmea da de install.sh (se mexer numa, mexa na outra) — ver o comentário lá
|
|
# para o defeito que ela fecha. Coberta por test-validators.sh.
|
|
resposta_sim() {
|
|
local r
|
|
r="$(printf '%s' "${1:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')"
|
|
case "$r" in s|sim|y|yes) return 0;; *) return 1;; esac
|
|
}
|
|
|
|
# Saúde do app pela rota que ele responde de verdade, não pela porta. A porta
|
|
# 3000 aceita conexão assim que o Node sobe — ANTES de o app saber se alcança
|
|
# banco, Redis e WhatsApp. Era exatamente a diferença entre o install.sh, que
|
|
# testava a porta e imprimia "Instalação concluída!" mesmo sem resposta, e o
|
|
# update.sh, que só declara sucesso com "status":"ok". Um critério, um lugar.
|
|
# Devolve DUAS linhas: o status GERAL na primeira, o corpo inteiro na segunda.
|
|
#
|
|
# A separação existe porque procurar '"status":"ok"' no JSON cru é errado, e
|
|
# erra em silêncio: `ok` é o vocabulário dos CHECKS individuais
|
|
# (ok|degraded|down), enquanto o status geral usa outro (healthy|degraded|
|
|
# unhealthy). Medido contra o app real: um `grep '"status":"ok"'` casa com o
|
|
# `checks.redis`, então um app com o BANCO FORA — status geral "unhealthy" —
|
|
# passava como saudável, desde que qualquer outro check estivesse de pé. Quem
|
|
# decide é o app, no Node que já está sendo invocado; o shell não repete a
|
|
# regra dele.
|
|
app_health_probe() {
|
|
dc exec -T app node -e \
|
|
"fetch('http://127.0.0.1:3000/api/v1/health').then(r=>r.json()).then(j=>{console.log((j&&j.data&&j.data.status)||'sem_status');console.log(JSON.stringify(j))}).catch(()=>process.exit(1))" \
|
|
2>/dev/null || echo ''
|
|
}
|
|
|
|
# wait_app_healthy [tentativas] [intervalo_s] — 0 quando o app se declara
|
|
# `healthy` ou `degraded`, 1 caso contrário. `degraded` entra de propósito:
|
|
# significa que algum serviço OPCIONAL ainda não foi configurado (o check
|
|
# devolve degraded/not_configured), e recusar a instalação por isso reprovaria
|
|
# um CRM que está de pé e atendendo. `unhealthy` é outra história — quer dizer
|
|
# check DOWN, e aí o app não serve. Ecoa o corpo lido, para quem chama poder
|
|
# mostrar o motivo em vez de só dizer que não deu.
|
|
wait_app_healthy() {
|
|
local tentativas="${1:-20}" intervalo="${2:-3}" saida='' status='' corpo='' i=0
|
|
while [ "$i" -lt "$tentativas" ]; do
|
|
saida="$(app_health_probe)"
|
|
status="$(printf '%s\n' "$saida" | head -1 | tr -d '\r')"
|
|
corpo="$(printf '%s\n' "$saida" | tail -n +2)"
|
|
case "$status" in
|
|
healthy|degraded) printf '%s' "$corpo"; return 0;;
|
|
esac
|
|
i=$((i+1))
|
|
[ "$i" -lt "$tentativas" ] && sleep "$intervalo"
|
|
done
|
|
printf '%s' "$corpo"
|
|
return 1
|
|
}
|
|
|
|
# Código de saída de quem RECUSOU antes de tocar em qualquer coisa — distinto
|
|
# de "falhei no meio" (1). O agent.sh usa isso para não desfazer uma
|
|
# atualização que nunca começou: reiniciar o container e reescrever o .env
|
|
# "voltando" de uma mudança que não houve é estrago inventado do nada.
|
|
REFUSED_RC=3
|
|
refuse() { c_red "✖ $*"; exit "$REFUSED_RC"; }
|
|
|
|
# Instalar <ref> seria voltar no tempo? 0 = sim (já está contido no HEAD),
|
|
# 1 = não, 2 = NÃO SEI. "Não sei" nunca vira "pode".
|
|
#
|
|
# O install.sh clona com `--depth 1`, e num repositório raso o
|
|
# `merge-base --is-ancestor` responde 1 (não-ancestral) para QUALQUER coisa
|
|
# fora do único commit baixado — inclusive para uma tag velha que, na história
|
|
# real, está muito atrás. Ou seja: a resposta que libera é exatamente a que o
|
|
# raso dá de graça, e `git fetch --tags` não desfaz o raso (conferido: depois
|
|
# do fetch, is-shallow-repository continua true). Por isso completamos a
|
|
# história ANTES de perguntar, e, se não der, devolvemos 2 — o chamador
|
|
# recusa. Falhar fechado é o certo num script que roda como root na máquina de
|
|
# quem não sabe consertar.
|
|
is_already_in_head() {
|
|
local ref="$1"
|
|
if [ "$(git rev-parse --is-shallow-repository 2>/dev/null || echo unknown)" = "true" ]; then
|
|
git fetch --unshallow --tags --quiet origin 2>/dev/null || true
|
|
fi
|
|
case "$(git rev-parse --is-shallow-repository 2>/dev/null || echo unknown)" in
|
|
false) : ;;
|
|
*) return 2 ;; # ainda raso, ou nem é repositório git: não dá pra saber
|
|
esac
|
|
git merge-base --is-ancestor "$ref" HEAD 2>/dev/null && return 0
|
|
return 1
|
|
}
|
|
|
|
# Carrega o .env lendo cada linha como DADO, sem `source`.
|
|
#
|
|
# O `. ./.env` interpretava o arquivo como script, e aí qualquer valor de texto
|
|
# livre virava código: `APP_NAME=Loja do João` fazia o shell tentar executar
|
|
# `do`; uma senha com `#` era truncada no que parecia comentário; uma com `$`
|
|
# era expandida e chegava corrompida. Como TODO script do kit passa por aqui,
|
|
# um nome de empresa com espaço — ou seja, quase todos — derrubava reset-mfa,
|
|
# reset-password, backup, restore e healthcheck. Justamente as ferramentas de
|
|
# emergência, que só são usadas quando já deu problema.
|
|
#
|
|
# Aceita valores com ou sem aspas: instalações antigas (sem aspas) passam a
|
|
# funcionar sem precisar reescrever o .env.
|
|
load_env() {
|
|
local file="${1:-.env}" line key val
|
|
[ -f "$file" ] || return 0
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
case "$line" in ''|'#'*) continue;; esac
|
|
case "$line" in *=*) ;; *) continue;; esac
|
|
key="${line%%=*}"; val="${line#*=}"
|
|
case "$key" in ''|*[!A-Za-z0-9_]*) continue;; esac
|
|
case "$val" in
|
|
\"*\")
|
|
val="${val:1:${#val}-2}"
|
|
# Tirar as aspas não desfaz o escape que o envq pôs lá dentro. Sem estas
|
|
# quatro trocas, `Loja P$ss` volta da releitura como `Loja P\$ss` — o
|
|
# valor chega adulterado e o erro só aparece longe daqui (medido).
|
|
#
|
|
# O sentinela \001 existe pela ORDEM: um `\\` desfeito para `\` de cara
|
|
# seria reprocessado pelas trocas seguintes, e `\\$` (barra literal
|
|
# seguida de cifrão) viraria `$`. Guardando o par escapado num byte que
|
|
# não ocorre em .env, as trocas de `\"`, `\$` e crase não o enxergam, e
|
|
# ele só volta a ser barra no fim.
|
|
val="${val//\\\\/$'\001'}"
|
|
val="${val//\\\"/\"}"
|
|
val="${val//\\\$/\$}"
|
|
val="${val//\\\`/\`}"
|
|
val="${val//$'\001'/\\}"
|
|
;;
|
|
\'*\')
|
|
# RETROCOMPATIBILIDADE — não remova. Até 2026-08 o envq gravava com
|
|
# aspas simples, e atualizar NÃO reescreve o .env: o update.sh só troca
|
|
# APP_IMAGE e APP_PULL_POLICY (:159 e :165, via set_env_var) e deixa as
|
|
# outras chaves exatamente como o install antigo as escreveu. Quem
|
|
# apagar este ramo devolve senha e connection string de toda instalação
|
|
# velha com quatro caracteres a mais, já na primeira atualização.
|
|
val="${val:1:${#val}-2}"
|
|
# O envq daquela época escrevia a aspa simples do CONTEÚDO como '\''
|
|
# (fecha o literal, escapa a aspa, reabre). Tirar as aspas de fora não
|
|
# desfaz isso: sem esta troca, uma senha com aspa volta da releitura com
|
|
# quatro caracteres a mais, e o erro só aparece longe daqui (o psql
|
|
# recusa a conexão, o login não bate) sem nada apontando para o .env.
|
|
# Achado pelo teste de round-trip.
|
|
val="${val//"'\\''"/"'"}"
|
|
;;
|
|
esac
|
|
printf -v "$key" '%s' "$val"
|
|
export "${key?}"
|
|
done < "$file"
|
|
}
|
|
|
|
# Vai pro diretório do projeto (onde está o compose) e carrega o .env.
|
|
enter_project() {
|
|
if [ -f "$COMPOSE" ]; then :;
|
|
elif [ -f "deskcommcrm/$COMPOSE" ]; then cd deskcommcrm;
|
|
else die "Não achei $COMPOSE. Rode a partir da pasta do projeto."; fi
|
|
[ -f .env ] || die "Falta o .env (rode install.sh primeiro)."
|
|
load_env .env
|
|
PROJECT_DIR="$(pwd)"
|
|
}
|
|
|
|
# ── As DUAS conexões: a do app e a do schema ─────────────────────────────────
|
|
# `SUPABASE_DB_URL` tinha dois papéis numa string só: ela vai para o `.env` dos
|
|
# contêineres (o app fala com o banco por ela) E era a mesma que rodava
|
|
# `create extension`, o `baseline.sql` e a promoção do dono.
|
|
#
|
|
# Na nuvem isso não dói — a string do pooler já vem privilegiada. Num Supabase
|
|
# PRÓPRIO dói na primeira instalação: o baseline exige o dono do banco, o app
|
|
# quer a role menor (é o que `docs/deploy-selfhost/README.md` §2 recomenda), e a
|
|
# única saída era editar o `.env` na mão entre uma etapa e outra (issue #192).
|
|
#
|
|
# Daqui em diante: quem mexe no schema (e quem faz backup/restore, que precisam
|
|
# ler tudo) passa por esta função; o `.env` continua recebendo só a do app.
|
|
# `SUPABASE_DB_ADMIN_URL` ausente OU vazia cai na de sempre — quem já instalou
|
|
# não muda de comportamento.
|
|
#
|
|
# É FUNÇÃO, e não uma atribuição no topo deste arquivo, porque o `_common.sh` é
|
|
# *sourced* ANTES do `load_env` nos dois scripts (install.sh e update.sh), e ele
|
|
# abre com `set -euo pipefail`: uma linha `X="${SUPABASE_DB_ADMIN_URL:-$SUPABASE_DB_URL}"`
|
|
# aqui morre em "variável não associada" e leva o kit inteiro junto (medido: a
|
|
# suíte de shell inteira foi a EXIT=1 com 0 casos executados). E com guarda
|
|
# (`${SUPABASE_DB_URL:-}`) seria pior: o valor CONGELA vazio e todo sítio de DDL
|
|
# passa a rodar `psql ""`. A resolução tem de acontecer na hora do uso.
|
|
#
|
|
# `:?` e não `:-`: sem NENHUMA das duas, o certo é parar com uma frase que diz o
|
|
# que fazer, não seguir para um `psql ""` que erra longe da causa. O limite é
|
|
# honesto — isto roda em substituição de comando, e um subshell não derruba o
|
|
# pai; o que a mensagem garante é que a causa apareça na tela antes do erro de
|
|
# conexão que os chamadores já tratam.
|
|
url_do_schema() {
|
|
printf '%s' "${SUPABASE_DB_ADMIN_URL:-${SUPABASE_DB_URL:?sem connection string de banco no .env — rode o install.sh}}"
|
|
}
|
|
|
|
# psql efêmero via container (não exige psql no host). Usa a conexão de schema:
|
|
# os chamadores mexem em `auth.mfa_factors` e `private.app_secrets`, fora do
|
|
# alcance de uma role de app com grants só em `public`.
|
|
psql_run() { docker run --rm -i postgres:17-alpine psql "$(url_do_schema)" -v ON_ERROR_STOP=1 "$@"; }
|
|
|
|
# ── As três imagens que NÓS publicamos ───────────────────────────────────────
|
|
# O namespace é constante e literal de propósito: ele está gravado no .env de
|
|
# toda instalação viva, e derivá-lo de variável faria o kit antigo (que já está
|
|
# no disco do cliente) e o novo montarem strings diferentes.
|
|
#
|
|
# Esta linha é a ÚNICA fonte do namespace para tudo que executa — os testes do
|
|
# kit a leem em vez de repetir a string. Quem a confere é
|
|
# `tests/unit/namespace-das-imagens.test.ts`, que assere este valor e cobra que
|
|
# `docker-compose.prod.yml`, `.env.hostgator.example` e a matriz de
|
|
# `publish-image.yml` digam o mesmo. Se você é um fork, é lá que está a lista do
|
|
# que trocar junto.
|
|
IMG_NS="ghcr.io/melgarafael"
|
|
IMG_APP="${IMG_NS}/deskcommcrm"
|
|
IMG_WORKER="${IMG_NS}/deskcomm-worker"
|
|
IMG_SCHEDULER="${IMG_NS}/deskcomm-scheduler"
|
|
|
|
# A última versão publicada (ex.: "1.2.1"), ou vazio se não deu para saber.
|
|
#
|
|
# Consulta o REMOTO, não o clone: o install.sh clona com `--depth 1`, que não
|
|
# traz tag nenhuma, então `git tag -l` local devolveria vazio e a instalação
|
|
# nasceria em `latest` sem ninguém perceber — que é justamente o defeito que
|
|
# esta função existe para consertar.
|
|
#
|
|
# Falha ABERTA de propósito: sem rede, sem git ou sem tag no remoto ela devolve
|
|
# vazio e quem chama cai no canal móvel, como era antes. Travar a instalação de
|
|
# alguém porque não deu para resolver um número de versão seria trocar um
|
|
# problema de previsibilidade por um de disponibilidade.
|
|
ultima_versao_publicada() {
|
|
local url="${1:-https://github.com/melgarafael/DeskcommCRM.git}" ref
|
|
command -v git >/dev/null 2>&1 || return 0
|
|
# `grep -v -- -` descarta PRERELEASE (v1.11.0-rc1, v1.1.1-jmpo.1 — esta última
|
|
# existe de verdade neste repo). O `--sort=-v:refname` do git põe o prerelease
|
|
# ACIMA do release final quando `versionsort.suffix` não está configurado, e
|
|
# uma instalação nova nasceria num release candidate sem ninguém pedir.
|
|
ref="$(git ls-remote --tags --refs --sort=-v:refname "$url" 'v*' 2>/dev/null \
|
|
| awk '{print $2}' | grep -v -- '-' | head -1)" || return 0
|
|
[ -n "$ref" ] || return 0
|
|
printf '%s' "${ref#refs/tags/v}"
|
|
}
|
|
|
|
# Código HTTP do manifest de uma referência nossa no GHCR, anonimamente.
|
|
# 200 = existe e é pública | 404 = não existe | 403 = pacote PRIVADO | 000 = sem rede
|
|
#
|
|
# 403 é o caso que mais engana: pacote recém-criado no GHCR nasce privado, e
|
|
# repositório público não muda isso. Enquanto ninguém trocar a visibilidade na
|
|
# mão, o `docker compose pull` de toda VPS é negado — e como `pull` de serviço
|
|
# com `image:` falha a operação inteira, a instalação morre no passo de subir.
|
|
#
|
|
# ⚠️ O DONO E O REGISTRO SAEM DO `IMG_NS`, NUNCA DE UM LITERAL. Achado por
|
|
# @galeonel no PR #605: as duas URLs abaixo tinham `melgarafael` cravado. Num
|
|
# fork que troca o `IMG_NS`, isso faz o pré-voo conferir os pacotes do UPSTREAM
|
|
# enquanto `gravar_imagens` escreve no `.env` do cliente as referências do FORK
|
|
# — a sonda mede um caminho e o usuário usa outro, que é a falha-em-verde do
|
|
# passe 5 da triagem.
|
|
#
|
|
# E o literal escapava da catraca por acidente: `namespace-das-imagens.test.ts`
|
|
# procura a string contígua `ghcr.io/melgarafael`, e a URL do token a parte em
|
|
# `ghcr.io/token?scope=repository:melgarafael/`.
|
|
ghcr_status() {
|
|
local img="$1" tag="$2" tok registry owner
|
|
registry="${IMG_NS%%/*}"
|
|
owner="${IMG_NS#*/}"
|
|
tok="$(curl -fsS --max-time 6 \
|
|
"https://${registry}/token?scope=repository:${owner}/${img}:pull&service=${registry}" 2>/dev/null \
|
|
| sed -n 's/.*"token":"\([^"]*\)".*/\1/p')" || true
|
|
if [ -z "$tok" ]; then printf '000'; return 0; fi
|
|
curl -s -o /dev/null --max-time 6 -w '%{http_code}' \
|
|
-H "Authorization: Bearer $tok" \
|
|
-H 'Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.docker.distribution.manifest.v2+json' \
|
|
"https://${registry}/v2/${owner}/${img}/manifests/${tag}" 2>/dev/null || printf '000'
|
|
}
|
|
|
|
# As TRÊS imagens existem e são públicas nesta referência?
|
|
#
|
|
# Perguntar pelas três juntas, e não só pela do app, é o ponto: `deskcomm-worker`
|
|
# e `deskcomm-scheduler` nasceram depois das releases que já existem, então
|
|
# `deskcomm-worker:1.2.1` nunca vai existir — a v1.2.1 é passado. Pinar as três
|
|
# numa versão sem conferir gravaria no .env do cliente duas referências
|
|
# impossíveis, e o kit as construiria na VPS **em silêncio**, do topo da main:
|
|
# app de uma release + worker/scheduler de outro código. Exatamente a mistura de
|
|
# versões que a doutrina existe para proibir, no caminho de primeira impressão.
|
|
trio_publicado() {
|
|
local tag="$1" i
|
|
for i in deskcommcrm deskcomm-worker deskcomm-scheduler; do
|
|
[ "$(ghcr_status "$i" "$tag")" = "200" ] || return 1
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# O .env está com pin PELA METADE? (app fixado numa versão, worker/scheduler não)
|
|
#
|
|
# Este é o estado que a transição produz e que nada denuncia. Medido em ensaio e
|
|
# depois na produção: quem executa a primeira atualização é o `update.sh` que já
|
|
# estava no disco — o antigo —, e ele só sabe gravar `APP_IMAGE`. O worker cai no
|
|
# default do compose (`:stable`, um canal MÓVEL) e o script termina dizendo
|
|
# "Atualização concluída — app no ar e saudável", sem uma palavra sobre isso.
|
|
#
|
|
# Por que importa: na release seguinte o `stable` se move, e um `up -d` qualquer
|
|
# — com `pull_policy: always`, que é o default de tag móvel — levaria o worker
|
|
# sozinho para a versão nova enquanto o app permanece na antiga. Mistura de
|
|
# versões que acontece sem ninguém pedir, e é o que o invariante 3 proíbe.
|
|
#
|
|
# Ecoa os serviços sem pin, separados por espaço. Vazio = está tudo certo.
|
|
valor_do_env() { # valor_do_env <arquivo> <chave> (sem aspas ao redor)
|
|
# O `|| true` não é decorativo: o `_common.sh` roda sob `set -euo pipefail`, e
|
|
# um `grep` que não casa sai 1 — o que, sem isto, mataria a função inteira
|
|
# justamente no caso que interessa (a chave AUSENTE). Custou dois casos verdes
|
|
# de mentira num teste antes de aparecer.
|
|
{ grep -E "^$2=" "$1" 2>/dev/null || true; } | head -1 | cut -d= -f2- | sed "s/^['\"]//; s/['\"]\$//"
|
|
}
|
|
|
|
tag_da_imagem() { # tag_da_imagem <referência> → a tag, ou vazio se não houver
|
|
local ref="${1##*/}"
|
|
case "$ref" in *:*) printf '%s' "${ref##*:}" ;; *) printf '' ;; esac
|
|
}
|
|
|
|
pin_incompleto() { # pin_incompleto [caminho do .env]
|
|
local envfile="${1:-.env}" app_ref app_tag faltando="" par chave svc img tag
|
|
[ -f "$envfile" ] || return 0
|
|
|
|
# Sem APP_IMAGE pinado não há "metade" nenhuma — é outra situação (instalação
|
|
# que nunca rodou update, ou que escolheu um canal de propósito).
|
|
app_ref="$(valor_do_env "$envfile" APP_IMAGE)"
|
|
[ -n "$app_ref" ] || return 0
|
|
app_tag="$(tag_da_imagem "$app_ref")"
|
|
case "$app_tag" in latest|main|stable|"") return 0 ;; esac
|
|
|
|
for par in "WORKER_IMAGE:worker" "SCHEDULER_IMAGE:scheduler"; do
|
|
chave="${par%%:*}"; svc="${par##*:}"
|
|
img="$(valor_do_env "$envfile" "$chave")"
|
|
if [ -z "$img" ]; then
|
|
faltando="$faltando $svc" # ausente: segue o default do compose
|
|
else
|
|
tag="$(tag_da_imagem "$img")"
|
|
case "$tag" in latest|main|stable|"") faltando="$faltando $svc" ;; esac
|
|
fi
|
|
done
|
|
printf '%s' "${faltando# }"
|
|
}
|
|
|
|
# Completa o pin AUSENTE no .env, com a versão que a imagem EM EXECUÇÃO declara.
|
|
#
|
|
# A regra que torna isto seguro: **só preenche lacuna, nunca sobrescreve valor
|
|
# explícito.** Chave ausente é omissão do `update.sh` antigo; chave presente é
|
|
# decisão de quem opera — inclusive a decisão de seguir um canal móvel de
|
|
# propósito. Um cron que corrigisse escolha alheia seria pior que o defeito.
|
|
#
|
|
# E a versão gravada é a que o contêiner JÁ está rodando (label
|
|
# `org.opencontainers.image.version` da imagem em uso), não a do app. A diferença
|
|
# importa: se o worker estiver numa versão diferente do app, gravar a do app
|
|
# MUDARIA o que roda no próximo `up -d` — possivelmente um downgrade. Gravando o
|
|
# que já está lá, a operação é congelamento puro: nada muda de comportamento
|
|
# agora, e o próximo `update.sh` alinha as três.
|
|
#
|
|
# Ecoa os serviços corrigidos, separados por espaço. Vazio = nada a fazer.
|
|
completar_pin_ausente() { # completar_pin_ausente [envfile]
|
|
local envfile="${1:-.env}" par chave svc repo img ver corrigidos=""
|
|
[ -f "$envfile" ] || return 0
|
|
# Esta guarda vale para execução não-root e não custa nada. NÃO é ela que
|
|
# protege o caso real: o cron roda como root, e root ignora `chmod`. Quem
|
|
# protege é a atomicidade do `set_env_var` (escreve num `.tmp` e faz `mv`) —
|
|
# medido com `chattr +i`, que barra até root: a escrita falha, a função sai 0
|
|
# e o `.env` original chega intacto do outro lado, com as customizações.
|
|
[ -w "$envfile" ] || return 0
|
|
|
|
for par in "WORKER_IMAGE:worker:deskcomm-worker" "SCHEDULER_IMAGE:scheduler:deskcomm-scheduler"; do
|
|
chave="${par%%:*}"; svc="$(printf '%s' "$par" | cut -d: -f2)"; repo="${par##*:}"
|
|
|
|
# LACUNA apenas. Valor explícito (mesmo em canal móvel) é intocável.
|
|
if { grep -qE "^${chave}=" "$envfile" 2>/dev/null; }; then continue; fi
|
|
|
|
img="$(docker inspect "$(nome_do_projeto_atual)-${svc}-1" --format '{{.Config.Image}}' 2>/dev/null)" || img=""
|
|
[ -n "$img" ] || continue
|
|
ver="$(docker image inspect "$img" --format '{{index .Config.Labels "org.opencontainers.image.version"}}' 2>/dev/null)" || ver=""
|
|
# `<no value>` = imagem sem o label (build local). Canal não é versão.
|
|
case "$ver" in ""|"<no value>"|latest|main|stable) continue ;; esac
|
|
|
|
set_env_var "$envfile" "$chave" "${IMG_NS}/${repo}:${ver}"
|
|
set_env_var "$envfile" "${chave%_IMAGE}_PULL_POLICY" missing
|
|
corrigidos="$corrigidos $svc"
|
|
done
|
|
printf '%s' "${corrigidos# }"
|
|
}
|
|
|
|
# Escreve no .env as três imagens da MESMA versão + o pull_policy que combina
|
|
# com a mutabilidade da tag.
|
|
#
|
|
# As três juntas porque elas sobem juntas: app numa versão e worker em `latest`
|
|
# é a matriz de compatibilidade que ninguém testou. E o pull_policy não é
|
|
# detalhe — foi medido que, com `always` e o registry sem responder para aquela
|
|
# referência, o `up -d` FALHA e o contêiner não sobe, mesmo com a imagem já no
|
|
# disco. Numa tag imutável isso não protege de nada e só amarra a subida do CRM
|
|
# do cliente à disponibilidade do GHCR.
|
|
#
|
|
# gravar_imagens .env 1.2.1 → pinado, pull_policy=missing
|
|
# gravar_imagens .env latest → canal, pull_policy=always
|
|
gravar_imagens() {
|
|
local envfile="$1" versao="$2" politica
|
|
case "$versao" in
|
|
latest|main|stable) politica="always" ;;
|
|
*) politica="missing" ;;
|
|
esac
|
|
set_env_var "$envfile" APP_IMAGE "${IMG_APP}:${versao}"
|
|
set_env_var "$envfile" APP_PULL_POLICY "$politica"
|
|
set_env_var "$envfile" WORKER_IMAGE "${IMG_WORKER}:${versao}"
|
|
set_env_var "$envfile" WORKER_PULL_POLICY "$politica"
|
|
set_env_var "$envfile" SCHEDULER_IMAGE "${IMG_SCHEDULER}:${versao}"
|
|
set_env_var "$envfile" SCHEDULER_PULL_POLICY "$politica"
|
|
}
|
|
|
|
# ── Os segredos da chamada de voz, no .env de quem já tinha instalado ────────
|
|
#
|
|
# A doutrina de packaging é literal: "bump de versão não pode exigir que o
|
|
# operador edite `.env`, compose ou qualquer arquivo à mão". A chamada de voz
|
|
# (spec 18) trouxe três chaves novas, e o serviço NÃO SOBE sem duas delas.
|
|
#
|
|
# Quem instalou antes desta versão não as tem. Sem esta função, o dia em que ele
|
|
# quisesse ligar a voz começaria por inventar dois segredos num editor de texto
|
|
# dentro de uma VPS — que é exatamente o passo que a doutrina proíbe.
|
|
#
|
|
# LACUNA APENAS, como `completar_pin_ausente`: chave já presente (mesmo vazia
|
|
# por escolha de quem operou) é intocável. Preencher só o que falta é a
|
|
# diferença entre curar e sobrescrever.
|
|
#
|
|
# ⚠️ ISTO NÃO LIGA A FEATURE. As chaves geradas ficam paradas até alguém pôr
|
|
# `voz` em COMPOSE_PROFILES: sem o profile, o compose nem cria o contêiner.
|
|
# Gerar credencial para um serviço desligado não é risco — é o que faz o
|
|
# desligado poder virar ligado sem passo manual.
|
|
completar_segredos_da_voz() { # completar_segredos_da_voz [envfile]
|
|
local envfile="${1:-.env}" criados="" chave
|
|
[ -f "$envfile" ] || return 0
|
|
# Somente-leitura (montagem read-only, permissão errada): não é erro daqui.
|
|
[ -w "$envfile" ] || return 0
|
|
|
|
for chave in WACALLS_ADMIN_USER WACALLS_ADMIN_PASSWORD WACALLS_API_TOKEN; do
|
|
# `^CHAVE=` casa inclusive a linha com valor vazio — que é presença, não
|
|
# lacuna. Só a AUSÊNCIA da linha é preenchida.
|
|
grep -qE "^${chave}=" "$envfile" && continue
|
|
if [ "$chave" = "WACALLS_ADMIN_USER" ]; then
|
|
set_env_var "$envfile" "$chave" "deskcomm"
|
|
else
|
|
set_env_var "$envfile" "$chave" "$(openssl rand -hex 32)"
|
|
fi
|
|
criados="$criados $chave"
|
|
done
|
|
|
|
printf '%s' "${criados# }"
|
|
}
|
|
|
|
# Grava (ou reescreve) uma chave no .env — sem duplicar linha se ela já existe.
|
|
# set_env_var .env APP_IMAGE ghcr.io/…:1.1.0
|
|
#
|
|
# É o que faz uma escolha SOBREVIVER ao processo que a fez. `export APP_IMAGE=…`
|
|
# vale só enquanto o script roda: o docker-compose.prod.yml lê a imagem do .env,
|
|
# então um `docker compose up -d` rodado à mão pelo dono semanas depois (comando
|
|
# documentado no README) voltaria pro APP_IMAGE gravado no install (":latest") e
|
|
# DESFARIA a atualização — app do topo da main sobre o banco da versão
|
|
# instalada, exatamente o modo de falha pelo qual o Watchtower foi descartado.
|
|
set_env_var() {
|
|
local envfile="$1" key="$2" value="$3" tmp
|
|
[ -f "$envfile" ] || return 0
|
|
tmp="${envfile}.tmp.$$"
|
|
# "|| true": com pipefail, grep -v que filtra TODAS as linhas (arquivo de uma
|
|
# linha só) sai 1 e derrubaria o script por set -e antes do append.
|
|
{ grep -vE "^${key}=" "$envfile" || true; } > "$tmp"
|
|
printf '%s=%s\n' "$key" "$value" >> "$tmp"
|
|
chmod 600 "$tmp" # o .env tem segredos: o tmp nasce com o mesmo rigor
|
|
mv "$tmp" "$envfile"
|
|
}
|
|
|
|
# Resolve o UUID de um usuário pelo e-mail (admin API do Supabase).
|
|
#
|
|
# ── O `filter` do GoTrue é BUSCA POR SUBSTRING, não expressão ────────────────
|
|
# Esta função pedia `?filter=email.eq.<email>` — sintaxe do PostgREST, que o
|
|
# GoTrue não fala. Ele trata a string inteira como termo de busca, nenhum e-mail
|
|
# contém "email.eq.", e a resposta é SEMPRE vazia. Medido em 2026-08-31 contra o
|
|
# projeto de produção, com um e-mail que existe:
|
|
#
|
|
# GET /auth/v1/admin/users?filter=email.eq.<existente> → 200 {"users":[]}
|
|
# GET /auth/v1/admin/users?filter=<existente> → 200 {"users":[<ele>]}
|
|
#
|
|
# Consequência: `reset-password.sh` morria com "Usuário '<email>' não
|
|
# encontrado" para TODO e-mail — o único caminho de recuperação de senha de uma
|
|
# instalação sem SMTP, que é o estado normal de um self-host, e o mesmo comando
|
|
# que o CLAUDE.md do kit manda usar quando a pessoa se tranca fora.
|
|
#
|
|
# ── Por que o casamento tem de ser EXATO aqui ───────────────────────────────
|
|
# Justamente por ser substring, `ana@empresa.com` casa também
|
|
# `mariana@empresa.com`. Um `head -1` cego devolveria o UUID da outra pessoa
|
|
# numa função cujo único consumidor TROCA SENHA. O padrão abaixo ancora no
|
|
# prefixo do objeto de usuário (id→aud→role→email, nessa ordem), que nenhum
|
|
# objeto aninhado de `identities` tem — e exige o e-mail inteiro, com os pontos
|
|
# escapados (em BRE `.` casa qualquer caractere, e sem escapar
|
|
# `elias.gervanno@x` casaria `eliasXgervanno@x`).
|
|
#
|
|
# Falha FECHADA: se o GoTrue mudar a ordem dos campos, o padrão não casa e a
|
|
# função devolve vazio — quem chama morre com "não encontrado", que é ruim mas
|
|
# recuperável. Devolver o UUID errado, não.
|
|
#
|
|
# ── Por que o `|| return 0` do fim não é enfeite ────────────────────────────
|
|
# `_common.sh` roda sob `set -euo pipefail`, e o consumidor resolve o UUID numa
|
|
# ATRIBUIÇÃO: `uid="$(owner_id_by_email "$EMAIL")"`. O status da atribuição é o
|
|
# da substituição, então uma função que devolve não-zero mata o script ALI — na
|
|
# linha de cima do `[ -n "$uid" ] || die "Usuário não encontrado."`, que nunca
|
|
# chega a rodar. E o `grep` devolve 1 justamente quando não casa ninguém, que é
|
|
# o caso em que a mensagem existe para falar.
|
|
#
|
|
# Medido em 2026-09-03 contra o GoTrue local v2.188.1, e-mail inexistente, as
|
|
# duas linhas reais do reset-password.sh: rc=1 e NENHUMA saída — o operador que
|
|
# erra uma letra no endereço não vê aviso nenhum, só o prompt de volta. "Não
|
|
# encontrado" era uma mensagem inalcançável. O `|| return 0` põe a decisão onde
|
|
# ela pertence: a função devolve VAZIO, e quem chama decide o que dizer.
|
|
owner_id_by_email() {
|
|
local email="$1" resp esc
|
|
resp="$(curl -fsS "${NEXT_PUBLIC_SUPABASE_URL}/auth/v1/admin/users?filter=${email}" \
|
|
-H "apikey: ${SUPABASE_SERVICE_ROLE_KEY}" \
|
|
-H "Authorization: Bearer ${SUPABASE_SERVICE_ROLE_KEY}" 2>/dev/null)" || return 0
|
|
esc="$(printf '%s' "$email" | sed 's/[.[\*^$]/\\&/g')"
|
|
printf '%s' "$resp" \
|
|
| grep -o "\"id\":\"[0-9a-f-]\{36\}\",\"aud\":\"[^\"]*\",\"role\":\"[^\"]*\",\"email\":\"${esc}\"" \
|
|
| head -1 | sed 's/^"id":"//;s/".*//' || return 0
|
|
}
|
|
|
|
# Ativa (idempotente) o cron que dispara o drain de eventos a cada minuto. SEM
|
|
# isso, nenhuma automação/webhook roda num self-host: neste kit os workers são
|
|
# lidos por cron, não por trigger→HTTP nem fila gerenciada (doutrina do
|
|
# projeto: trigger Postgres nunca faz HTTP). Chamada por install.sh e
|
|
# update.sh — re-rodar não duplica a linha do crontab.
|
|
# ── Cron: uma instalação nunca mexe na linha de outra ────────────────────────
|
|
# O filtro era `crontab -l | grep -v 'event-log-drain' | crontab -`: casava com
|
|
# a linha de QUALQUER instalação do host. Instalar uma segunda instância na
|
|
# mesma VPS apagava as duas linhas da primeira — o drain de eventos e o agente
|
|
# de atualização — em silêncio, e o dono só descobriria pelo que parou de
|
|
# acontecer. Confirmado numa VPS com produção rodando: as linhas dela seriam
|
|
# levadas por uma instalação nova em outra pasta.
|
|
#
|
|
# Agora cada linha carrega um marcador com o diretório da instalação, e o
|
|
# filtro remove só as que são dela.
|
|
# O marcador identifica a instalação E O PAPEL da linha. O papel não é enfeite:
|
|
# com um marcador só por instalação, a segunda função a rodar apagava a linha da
|
|
# primeira (o filtro remove tudo que casa com o marcador, e as duas linhas
|
|
# casavam). Medido na VPS: depois de instalar, sobrava só o agente e o CRM ficava
|
|
# SEM o drain de eventos — a automação inteira parada, em silêncio.
|
|
cron_tag() { printf '# deskcomm:%s:%s' "${PROJECT_DIR:-$PWD}" "${1:?papel da linha (drain|agent)}"; }
|
|
|
|
# Puro (testável sem tocar no crontab real): lê o crontab atual em stdin e
|
|
# imprime o novo. Tira as linhas DESTA instalação — pelo marcador, e também
|
|
# pela `assinatura` para as linhas legadas, escritas antes de o marcador
|
|
# existir, que sem isso ficariam duplicadas a cada re-execução.
|
|
cron_merge() { # cron_merge <marcador> <assinatura_legada> <linha_nova>
|
|
local marcador="$1" legado="$2" nova="$3"
|
|
{ grep -vF -e "$marcador" | grep -vF -e "$legado"; } || true
|
|
printf '%s\n' "$nova"
|
|
}
|
|
|
|
setup_event_log_drain_cron() {
|
|
command -v crontab >/dev/null 2>&1 || { c_ylw "⚠ 'crontab' não encontrado — instale o pacote 'cron' e rode de novo pra ativar as automações."; return 0; }
|
|
|
|
local secret="${INTERNAL_CRON_SECRET:-}"
|
|
[ -n "$secret" ] || secret="${INTERNAL_SECRET:-}"
|
|
[ -n "$secret" ] || { c_ylw "⚠ falta INTERNAL_SECRET/INTERNAL_CRON_SECRET — não ativei o cron das automações."; return 0; }
|
|
[ -n "${NEXT_PUBLIC_APP_URL:-}" ] || { c_ylw "⚠ falta NEXT_PUBLIC_APP_URL — não ativei o cron das automações."; return 0; }
|
|
|
|
local url_drain="${NEXT_PUBLIC_APP_URL}/api/v1/cron/event-log-drain"
|
|
local marcador; marcador="$(cron_tag drain)"
|
|
|
|
# "primeira vez" é sobre ESTA instalação, não sobre o host: com o teste antigo
|
|
# ('existe alguma linha de event-log-drain?'), uma instalação nova numa VPS
|
|
# que já roda outra se achava veterana e pulava a higienização de eventos.
|
|
local first_time=1
|
|
if crontab -l 2>/dev/null | grep -qF -e "$url_drain"; then first_time=0; fi
|
|
|
|
local cron_line="* * * * * curl -fsS -H \"Authorization: Bearer ${secret}\" \"${url_drain}\" >/dev/null 2>&1 ${marcador}"
|
|
# ⚠️ `|| true` OBRIGATÓRIO, e não é defensividade: `crontab -l` sai com status
|
|
# 1 (sem stdout, só um aviso no stderr) quando o usuário NUNCA teve crontab —
|
|
# o caso NORMAL de uma VPS recém-provisionada, que é o caso normal de quem
|
|
# instala este produto. Sob `set -o pipefail` (linha 3 deste arquivo, e
|
|
# `install.sh:12`) esse 1 vaza pelo pipe mesmo com os estágios seguintes
|
|
# bem-sucedidos — `false | true` também sai 1 —, e o `set -e` mata o
|
|
# instalador AQUI, no bloco 11, DEPOIS de a linha do cron já ter sido gravada.
|
|
# O dono vê o script morrer sem mensagem, numa instalação que na verdade
|
|
# funcionou.
|
|
#
|
|
# ACHADO DUAS VEZES, POR DUAS PESSOAS QUE NÃO SE FALARAM, NO MESMO DIA:
|
|
# @luiscgc91 (PR #683) e @rafaelbatistazz (issue #715 + PR #726), os dois
|
|
# instalando numa VPS limpa. Os dois escreveram EXATAMENTE a mesma linha. Isso
|
|
# não é redundância — é a medida de quanto o defeito doía, e a razão de este
|
|
# comentário ser longo: ele existe para a terceira pessoa não precisar
|
|
# descobrir de novo.
|
|
#
|
|
# A issue #715 descreve o sintoma como quem o viveu: o instalador para logo
|
|
# depois de "✓ chave de cifra ativa no banco", cai na tela "A instalação
|
|
# parou", e os contêineres estão SAUDÁVEIS. Rodar de novo passa — porque aí o
|
|
# crontab já não está vazio, o que faz o defeito parecer fantasma.
|
|
#
|
|
# Reproduzido com um dublê de `crontab` que sai 1 no `-l`: sem o `|| true`, a
|
|
# linha seguinte a este bloco nunca é alcançada. Vigiado por DOIS testes, de
|
|
# propósito: `tests/shell/cron-sem-crontab-previo.test.sh` mede cada função
|
|
# isolada, e o bloco `cron numa VPS sem crontab nenhum` de
|
|
# `hostgator-setup-kit/test-validators.sh` (de @rafaelbatistazz) roda AS DUAS
|
|
# no mesmo processo — como o `install.sh` faz — e confere que as duas linhas
|
|
# foram gravadas.
|
|
#
|
|
# Stdin vazio para o `cron_merge` é exatamente o que "sem crontab prévio" deve
|
|
# produzir — o comportamento não muda, só o status.
|
|
( { crontab -l 2>/dev/null || true; } | cron_merge "$marcador" "$url_drain" "$cron_line" ) | crontab -
|
|
c_grn "✓ automações ativas (cron do event-log-drain, a cada minuto)"
|
|
|
|
if [ "$first_time" = 1 ]; then
|
|
# 1ª ativação do cron (inclusive numa instalação já existente que nunca
|
|
# teve o drain rodando): pode haver eventos 'pending' antigos acumulados.
|
|
# Se o 1º drain os processasse, dispararia efeitos colaterais atrasados
|
|
# (ex.: webhook de dias/semanas atrás) — surpresa indesejada pro dono do
|
|
# CRM. Marcamos como 'done' só os realmente velhos (>7 dias); os recentes
|
|
# continuam 'pending' e processam normalmente no próximo drain.
|
|
step "Higienizando eventos pendentes antigos (1ª ativação do cron)"
|
|
psql_run -c "update event_log set status='done', updated_at=now() where status='pending' and created_at < now() - interval '7 days';" \
|
|
>/dev/null 2>&1 \
|
|
&& c_grn "✓ eventos pendentes com mais de 7 dias marcados como concluídos" \
|
|
|| c_ylw "⚠ não consegui higienizar eventos antigos — confira manualmente a tabela event_log se necessário."
|
|
fi
|
|
}
|
|
|
|
# Ativa (idempotente) o cron do agente de atualização: a cada 5 minutos ele
|
|
# avisa o app da versão instalada e, se alguém clicou em "Atualizar agora" na
|
|
# tela, roda o update.sh sozinho. É o que faz o botão da tela existir de
|
|
# verdade — sem cron, a tela mostra "atualização automática indisponível" pra
|
|
# sempre. Chamada por install.sh e update.sh (bloco 7) — re-rodar não duplica
|
|
# a linha do crontab.
|
|
setup_update_agent_cron() {
|
|
command -v crontab >/dev/null 2>&1 || { c_ylw "⚠ 'crontab' não encontrado — o botão de atualizar pela tela não vai funcionar."; return 0; }
|
|
local secret="${INTERNAL_CRON_SECRET:-${INTERNAL_SECRET:-}}"
|
|
[ -n "$secret" ] || { c_ylw "⚠ falta INTERNAL_SECRET — não ativei o agente de atualização."; return 0; }
|
|
[ -n "${NEXT_PUBLIC_APP_URL:-}" ] || { c_ylw "⚠ falta NEXT_PUBLIC_APP_URL — não ativei o agente de atualização."; return 0; }
|
|
|
|
# `cd` explícito: o agent.sh chama enter_project(), que acha o projeto pelo
|
|
# DIRETÓRIO CORRENTE. No cron o CWD é o home do dono do crontab — sem o cd,
|
|
# a linha só funciona por acidente (instalação padrão em /root/deskcommcrm) e
|
|
# morre calada a cada 5 minutos em qualquer REPO_DIR customizado ou /opt.
|
|
# A assinatura legada inclui o PROJECT_DIR: é o que distingue a linha desta
|
|
# instalação da linha de uma vizinha, que roda o mesmo agent.sh em outra pasta.
|
|
local legado="cd ${PROJECT_DIR} && bash hostgator-setup-kit/agent.sh"
|
|
local marcador; marcador="$(cron_tag agent)"
|
|
local cron_line="*/5 * * * * ${legado} >/dev/null 2>&1 ${marcador}"
|
|
# Mesmo motivo do drain acima, e é por isso que o conserto é nos DOIS: a
|
|
# primeira instalação passa pelos dois blocos na mesma rodada.
|
|
( { crontab -l 2>/dev/null || true; } | cron_merge "$marcador" "$legado" "$cron_line" ) | crontab -
|
|
c_grn "✓ atualização pela tela ativa (agente a cada 5 minutos)"
|
|
}
|
|
|
|
# Garante a chave de cifra dos segredos (webhooks/Nuvemshop) e a semeia no
|
|
# banco (private.app_secrets, migration 0041). Idempotente: reusa a chave do
|
|
# .env se existir (trocá-la invalidaria dados já cifrados); gera se ausente e
|
|
# appenda ao .env. Chamada por install.sh e update.sh APÓS aplicar o baseline.
|
|
ensure_encryption_key() {
|
|
local envfile="${1:-.env}"
|
|
local key="${NUVEMSHOP_OAUTH_ENCRYPTION_KEY:-}"
|
|
if [ -z "$key" ] && [ -f "$envfile" ]; then
|
|
key="$(grep -E '^NUVEMSHOP_OAUTH_ENCRYPTION_KEY=' "$envfile" | head -1 | cut -d= -f2- | tr -d "'\"" || true)"
|
|
fi
|
|
if [ -z "$key" ]; then
|
|
key="$(openssl rand -hex 32)"
|
|
printf '\nNUVEMSHOP_OAUTH_ENCRYPTION_KEY=%s\n' "$key" >> "$envfile"
|
|
c_grn "✓ chave de cifra dos segredos gerada e gravada no .env"
|
|
fi
|
|
export NUVEMSHOP_OAUTH_ENCRYPTION_KEY="$key"
|
|
|
|
# Semeia no banco — é de lá que as funções de cifra leem (Supabase não
|
|
# permite configurar a chave via parâmetro de banco).
|
|
psql_run -c "insert into private.app_secrets (name, value) values ('nuvemshop_oauth_key', '${key}') on conflict (name) do update set value = excluded.value, updated_at = now();" \
|
|
>/dev/null 2>&1 \
|
|
&& c_grn "✓ chave de cifra ativa no banco (segredos de webhook são guardados cifrados)" \
|
|
|| c_ylw "⚠ não consegui semear a chave de cifra no banco — segredos de webhook não poderão ser salvos até rodar update.sh de novo."
|
|
}
|