Files

2393 lines
136 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#
# DeskcommCRM — instalador self-host para VPS (HostGator).
#
# Idempotente: pode rodar de novo sem estragar nada. Dependências no host:
# só docker, docker compose, git, openssl, curl. psql/bootstrap rodam via Docker.
#
# Uso:
# bash install.sh # interativo (pergunta o que falta)
# bash install.sh --yes # não-interativo (usa .env já preenchido)
#
set -euo pipefail
# Diretório onde este script (e _common.sh, seu irmão) vivem — capturado ANTES
# de qualquer 'cd' (step 2 pode entrar num repo clonado à parte).
KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
REPO_URL="${REPO_URL:-https://github.com/melgarafael/DeskcommCRM.git}"
# Uma constante, dois usos (o fim feliz e o fim travado) — e o comecar.sh tem a
# gêmea. Link repetido à mão vira link divergente na primeira troca.
COMUNIDADE_URL="https://lp-comunidade.automatiklabs.com.br"
REPO_DIR="${REPO_DIR:-deskcommcrm}"
COMPOSE="docker-compose.prod.yml"
COMPOSE_TRAEFIK="docker-compose.traefik.yml"
COMPOSE_NPM="docker-compose.npm.yml"
NONINTERACTIVE=0
[ "${1:-}" = "--yes" ] && NONINTERACTIVE=1
# t()/IDIOMA_CLI — ver o cabeçalho de _i18n.sh. Sourced aqui (e não só depois
# do clone) porque este script já fala com o operador antes de _common.sh
# existir no disco (banner, validadores, prompts da Fase 1 e 2).
source "$KIT_DIR/_i18n.sh"
# Este script é standalone de propósito (roda antes do clone, então não dá para
# usar o _common.sh). As duas funções abaixo são gêmeas das de lá — se mexer
# numa, mexa na outra.
dc() {
if [ "${SINGLE_SERVER:-0}" = "1" ]; then
docker compose -f "$COMPOSE" -f docker-compose.single-server.yml "$@"
return
fi
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
}
dc_files() {
if [ "${SINGLE_SERVER:-0}" = "1" ]; then
printf -- '-f %s -f %s' "$COMPOSE" docker-compose.single-server.yml
return
fi
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
}
# psql/pg_dump efêmeros. No modo single-server o Postgres só é alcançável pela
# bridge privada (supabase-db), nunca por porta pública.
pg_container() {
local -a rede=()
[ -n "${PSQL_DOCKER_NETWORK:-}" ] && rede=(--network "$PSQL_DOCKER_NETWORK")
docker run --rm ${rede[@]+"${rede[@]}"} "$@"
}
# ── Aparência ───────────────────────────────────────────────────────────────
# Cor só quando há terminal de verdade. Antes o ANSI saía sempre, inclusive
# quando a saída vai para arquivo — o agent.sh redireciona o update.sh (`>
# "$LOG"`) e o esc() de lá precisa varrer byte a byte para tirar esses escapes
# do heartbeat. Desligar na origem é a correção de causa. NO_COLOR é a
# convenção que quem roda em CI espera; FORCE_COLOR é a válvula de quem quer
# cor mesmo em pipe.
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 <código ANSI> <texto…>. Sem cor, imprime o texto cru — nunca some.
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 "▶ $*"; }
# A resposta é sim? Aceita o que gente digita de verdade: s, S, sim, SIM, y,
# yes, si/sí (o instalador também fala espanhol), com espaço em volta. Cada prompt comparava a resposta com uma string
# exata, então "S" e "sim" — a resposta certa, com a tecla errada — caíam no
# ramo do NÃO. No gate do DNS isso encerrava a instalação com uma frase que nem
# correspondia à escolha da pessoa. Gêmea da de _common.sh: se mexer numa,
# mexa na outra. Coberta por test-validators.sh.
resposta_sim() {
local r
r="$(printf '%s' "${1:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')"
case "$r" in s|sim|si|sí|sÍ|y|yes) return 0;; *) return 1;; esac
}
# ── Fases da jornada ────────────────────────────────────────────────────────
# Os passos técnicos (step) são muitos e alguns são condicionais — numerá-los
# daria um "7 de 11" que muda conforme o caminho de cada instalação. As FASES
# são estáveis: são o mapa que a pessoa acompanha para saber onde está e
# quanto falta, num processo que leva minutos e é o primeiro contato dela com
# o produto.
FASE_TOTAL=4
fase() { printf '\n'; paint 1 "━━━ $(t "Fase") $1/$FASE_TOTAL · $2"; }
# ── Marca ───────────────────────────────────────────────────────────────────
# Logo em blocos (fonte ANSI Shadow). Os blocos saem no MESMO verde do "✓" já
# usado aqui, e o relevo (═╗║╝╚╔) em dim: essa dupla lê tanto em terminal de
# fundo escuro quanto claro, sem precisar detectar o tema — uma cor de acento
# clara sumiria no branco de quem usa terminal claro.
#
# A pintura é por substituição literal de string (${x//…}), não por classe de
# caractere em sed/awk: sob LC_ALL=C essas ferramentas tratam a entrada como
# BYTES, e todos esses glifos começam com 0xE2 — uma classe [╗║…] casaria
# pedaço de █ e embaralharia o desenho na VPS de quem roda em locale C.
LOGO_COLS=71
banner() {
local cols linha ch
cols="$(tput cols 2>/dev/null || echo 80)"
case "$cols" in ''|*[!0-9]*) cols=80;; esac
printf '\n'
# Terminal estreito recebe a versão de uma linha: logo quebrado no meio é
# pior do que logo nenhum.
if [ "$COLOR" != 1 ] || [ "$cols" -lt $((LOGO_COLS + 2)) ]; then
paint 1 " DESKCOMM"
else
# Tela limpa: tira o ruído do clone/apt de cima do logo. Exige TTY de
# verdade (não basta COLOR=1): com FORCE_COLOR numa saída redirecionada, um
# "limpe a tela" no meio do arquivo é lixo que ninguém pediu.
[ -t 1 ] && printf '\033[2J\033[H'
while IFS= read -r linha; do
linha="${linha//█/$'\033[32m'█$'\033[0m'}"
for ch in ═ ╗ ║ ╝ ╚ ╔; do linha="${linha//$ch/$'\033[2m'$ch$'\033[0m'}"; done
printf ' %s\n' "$linha"
done <<'LOGO'
██████╗ ███████╗███████╗██╗ ██╗ ██████╗ ██████╗ ███╗ ███╗███╗ ███╗
██╔══██╗██╔════╝██╔════╝██║ ██╔╝██╔════╝██╔═══██╗████╗ ████║████╗ ████║
██║ ██║█████╗ ███████╗█████╔╝ ██║ ██║ ██║██╔████╔██║██╔████╔██║
██║ ██║██╔══╝ ╚════██║██╔═██╗ ██║ ██║ ██║██║╚██╔╝██║██║╚██╔╝██║
██████╔╝███████╗███████║██║ ██╗╚██████╗╚██████╔╝██║ ╚═╝ ██║██║ ╚═╝ ██║
╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝
LOGO
fi
printf '\n'
c_dim "$(t " Agentes de IA que atendem no WhatsApp, dentro do seu CRM.")"
c_dim "$(t " Open-source · roda no seu servidor · os dados são seus.")"
}
# ── Rede de segurança: nenhuma saída silenciosa ─────────────────────────────
# Antes, qualquer comando que falhasse sob `set -e` derrubava o script sem
# dizer nada (o caso real: um psql com connection string errada saía com
# código 2 dentro de uma substituição, e a pessoa só via o terminal voltar).
# Instalação é o primeiro contato com o produto: morrer mudo aqui é perder o
# usuário. Este trap garante que TODA saída != 0 explique o que fazer.
show_recovery() {
local dir="${PROJECT_DIR:-$(pwd)}"
c_red ""
c_red "═══════════════════════════════════════════════════════"
c_red " $(t "A instalação parou. Nada ficou pela metade sem conserto.")"
c_red "═══════════════════════════════════════════════════════"
printf '\n%s\n\n' "$(t "Como voltar atrás e recomeçar do zero:")"
printf ' %s\n' "cd ${dir}"
printf ' %s\n' "rm -f .env # $(t "apaga a configuração digitada")"
printf ' %s\n' "rm -f ${MARCA_INSTALACAO_NOME:-.deskcomm-instalado} # $(t "apaga o marcador desta instalação")"
printf ' %s\n' "docker compose $(dc_files) down -v # $(t "derruba o que subiu")"
printf ' %s\n' "bash ${KIT_DIR:-hostgator-setup-kit}/install.sh # $(t "começa de novo")"
printf '\n%s\n' "$(t "Se o schema chegou a ser aplicado e você quer o banco limpo de novo,")"
printf '%s\n' "$(t "abra o Supabase > SQL Editor e rode (ATENÇÃO: apaga todos os dados):")"
printf ' %s\n\n' "drop schema public cascade; create schema public;"
}
trap 'rc=$?; [ "$rc" -ne 0 ] && show_recovery; exit $rc' EXIT
# ── Validadores ─────────────────────────────────────────────────────────────
# Cada validador recebe o valor, imprime a explicação do problema em português
# e devolve != 0. Rodam ANTES de o valor entrar no .env — é o único momento em
# que a pessoa ainda pode corrigir sem desfazer nada. Um dado errado que passa
# daqui só aparece minutos depois, como erro técnico em outro lugar.
# Lê uma claim de um JWT do Supabase (chaves legadas 'eyJ...'). Só serve para
# dar mensagem de erro melhor — quem decide de verdade é a chamada HTTP real.
jwt_claim() {
local p="${1#*.}"; p="${p%%.*}"
p="${p//-/+}"; p="${p//_//}"
case $(( ${#p} % 4 )) in 2) p="${p}==";; 3) p="${p}=";; esac
printf '%s' "$p" | base64 -d 2>/dev/null \
| grep -o "\"$2\":\"[^\"]*\"" | head -1 | cut -d'"' -f4
}
# Referência do projeto (o 'abcdef' de https://abcdef.supabase.co)
sb_ref() { local u="${1#https://}"; printf '%s' "${u%%.*}"; }
v_domain() {
case "$1" in
http*) echo "$(t "Digite só o domínio, sem https:// — ex.: crm.suaempresa.com.br")"; return 1;;
*/*) echo "$(t "Digite só o domínio, sem barra nem caminho — ex.: crm.suaempresa.com.br")"; return 1;;
*.*) return 0;;
*) echo "$(t "Isso não parece um domínio (falta o ponto) — ex.: crm.suaempresa.com.br")"; return 1;;
esac
}
v_email() {
case "$1" in *@*.*) return 0;; esac
echo "$(t "E-mail inválido — precisa ter @ e um domínio, ex.: voce@suaempresa.com.br")"
return 1
}
# A cor da marca. Aceita SÓ `#` + 6 dígitos hex, e essa estreiteza é medida, não
# gosto: o `marca-emails.sh:125` reconhece exatamente essa forma e cai calado no
# verde do produto em qualquer outra (`case "$ACCENT" in \#[0-9a-fA-F]x6`),
# enquanto o `ehHexValido` de `lib/branding/rampa.ts:49` aceita mais quatro
# formas (`#abc`, `abc`, `aabbcc` além de `#aabbcc`) e pinta a tela com elas. Um
# validador frouxo produziria o pior desfecho possível: a cor do revendedor na
# TELA e o verde do produto no PRIMEIRO e-mail que o cliente dele recebe — e
# split-brain de marca ninguém percebe, porque cada metade parece certa sozinha.
#
# Vazio passa porque o campo é opcional. Na entrevista o `ask_one` sequer chama
# o validador nesse caso (ele trata `-z "$input"` antes), então o `''` aqui é
# para quem reusar a função fora dela — e para que tirar o `opcional` do campo
# amanhã não vire uma instalação travada em quem não quer cor nenhuma.
v_hex() {
case "$1" in ''|'#'[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]) return 0;; esac
echo "$(t "Use um código de cor como #7a5cd6 — cerquilha e 6 dígitos —, ou Enter para a cor do sistema")"
return 1
}
# O idioma em que o sistema abre para QUEM INSTALA e para quem ele convidar.
#
# Vai para `organizations.locale`, e não só para o usuário dono: é a organização
# que responde pelos convidados que ainda não existem — quem entra sem
# preferência própria cai no idioma da empresa (a cadeia vive em
# `lib/auth/server.ts`). Sem isto, uma clínica na Colômbia instalava em espanhol
# e via o produto inteiro em português na primeira tela, sem nada indicando onde
# trocar.
#
# Aceita o código e o número da opção, porque quem lê "1) Português" digita "1".
v_locale() {
case "$1" in
''|pt-BR|es) return 0;;
1) return 0;;
2) return 0;;
esac
echo "$(t "Escolha 1 (Português) ou 2 (Español) — ou Enter para Português")"
return 1
}
v_supabase_url() {
case "$1" in
https://*.supabase.co) ;;
# Supabase SELF-HOSTED (ex.: https://db-crm.exemplo.com.br). A prova é a
# chamada a /auth/v1/health logo abaixo, que vale para qualquer host — o
# que se dispensa aqui é só a suposição de que todo Supabase é o da nuvem.
https://*) ;;
*supabase.co*) echo "$(t "Cole a URL completa, começando com https:// — ex.: https://abcdefgh.supabase.co")"; return 1;;
*) echo "$(t "A URL precisa começar com https://. Na nuvem ela fica em Settings > API > Project URL (termina em .supabase.co); num Supabase próprio, é o endereço do seu servidor.")"; return 1;;
esac
# No single-server a URL pública é servida pelo Caddy, que só sobe DEPOIS
# deste validador. A prova disponível aqui é o gateway local do Supabase,
# publicado só em loopback.
local health_url="$1"
if [ "${SINGLE_SERVER:-0}" = "1" ]; then
case "${SUPABASE_INTERNAL_URL:-}" in
http://*|https://*) health_url="$SUPABASE_INTERNAL_URL";;
*) echo "$(t "O modo single-server exige SUPABASE_INTERNAL_URL com http:// ou https:// para validar o Supabase local.")"; return 1;;
esac
fi
local code
code="$(curl -s -o /dev/null -w '%{http_code}' -m 15 "${health_url%/}/auth/v1/health" 2>/dev/null)" || code=000
if [ "$code" = "000" ]; then
echo "$(t "Não consegui alcançar {1} — confira se o projeto existe, está ativo (projeto pausado não responde) e se o VPS tem internet." "$health_url")"
return 1
fi
return 0
}
# Confere formato + faz a chamada real que só a chave certa responde.
# $2 = papel esperado ('anon' ou 'service_role')
v_sb_key() {
local key="$1" want="$2" url="${NEXT_PUBLIC_SUPABASE_URL:-}"
case "$key" in
eyJ*)
local role ref
role="$(jwt_claim "$key" role)"; ref="$(jwt_claim "$key" ref)"
if [ -n "$role" ] && [ "$role" != "$want" ]; then
echo "$(t "Essa é a chave '{1}', e aqui eu preciso da '{2}'. Em Settings > API elas ficam uma embaixo da outra — confira qual copiou." "$role" "$want")"
return 1
fi
if [ -n "$ref" ] && [ -n "$url" ] && [ "$ref" != "$(sb_ref "$url")" ]; then
echo "$(t "Essa chave é de OUTRO projeto Supabase ({1}), e a URL que você deu é do projeto {2}. Copie as duas do mesmo projeto." "$ref" "$(sb_ref "$url")")"
return 1
fi;;
sb_publishable_*|sb_secret_*) : ;; # formato novo do Supabase — a prova é a chamada HTTP
*) echo "$(t "Isso não parece uma chave do Supabase (elas começam com 'eyJ' ou 'sb_'). Pegue em Settings > API.")"; return 1;;
esac
[ -z "$url" ] && return 0
local code
if [ "$want" = "service_role" ]; then
# Rota de administração: a anon leva 401 aqui. É o que separa uma da outra.
code="$(curl -s -o /dev/null -w '%{http_code}' -m 20 \
-H "apikey: $key" -H "Authorization: Bearer $key" \
"$url/auth/v1/admin/users?page=1&per_page=1" 2>/dev/null)" || code=000
else
# /auth/v1/settings é a rota que a anon PODE abrir. Não use /rest/v1/: ele
# responde 401 "Only the service_role API key can be used for this endpoint"
# até para a anon correta — validador que reprova o dado certo é pior que
# nenhum. Provado nesta VPS: settings dá 200 para as chaves do projeto e 401
# para lixo e para JWT de outro projeto.
code="$(curl -s -o /dev/null -w '%{http_code}' -m 20 -H "apikey: $key" "$url/auth/v1/settings" 2>/dev/null)" || code=000
fi
case "$code" in
2*) return 0;;
000) c_ylw "$(t " ⚠ não consegui checar a chave online (sem resposta do Supabase); sigo com ela.")"; return 0;;
401|403) echo "$(t "O Supabase recusou essa chave (resposta {1}). Confira se copiou a '{2}' inteira, sem espaço no fim." "$code" "$want")"; return 1;;
*) echo "$(t "Resposta inesperada do Supabase ao testar a chave ({1}). Confira a chave e o projeto." "$code")"; return 1;;
esac
}
v_anon() { v_sb_key "$1" anon; }
v_service() { v_sb_key "$1" service_role; }
v_db_url() {
case "$1" in
postgres://*|postgresql://*) ;;
*) echo "$(t "A connection string começa com postgresql:// — copie em Settings > Database > Connection string, modo URI.")"; return 1;;
esac
case "$1" in
*"[YOUR-PASSWORD]"*|*"[SUA-SENHA]"*|*"[your-password]"*)
echo "$(t "Você colou a string com o [YOUR-PASSWORD] no meio — troque isso pela senha do banco (a que você definiu ao criar o projeto).")"; return 1;;
esac
case "$1" in
*db.*.supabase.co*)
echo "$(t "Essa é a 'Direct connection' do Supabase — ela só existe em IPv6 e o VPS é IPv4, então nunca conecta.")"
echo " 👉 $(t "Volte em Settings > Database e copie a do Session pooler (o host termina em .pooler.supabase.com).")"
return 1;;
esac
# Mesma família de projeto? (usuário do pooler é 'postgres.<ref>')
local dbref="${1#*://}"; dbref="${dbref%%:*}"; dbref="${dbref#postgres.}"
# Só a NUVEM tem <ref> a comparar. E a decisão é pelo HOST, nunca pela string
# inteira: `case "$url" in *.supabase.co)` só casa quando a URL TERMINA nisso, e
# ela chega com barra final, caminho ou espaço colado — é o que o address bar do
# navegador entrega. Medido: com `https://<ref>.supabase.co/` a comparação era
# pulada e uma connection string de OUTRO projeto passava, o que instala o
# baseline.sql num banco e deixa o app falando com outro.
local sbhost="${NEXT_PUBLIC_SUPABASE_URL:-}"
sbhost="${sbhost#*://}"; sbhost="${sbhost%%/*}"
sbhost="${sbhost%%[[:space:]]*}"; sbhost="${sbhost%%:*}"
case "$sbhost" in
*.supabase.co)
if [ "$dbref" != "postgres" ] \
&& [ "$dbref" != "$(sb_ref "$NEXT_PUBLIC_SUPABASE_URL")" ]; then
echo "$(t "Essa connection string é do projeto '{1}', mas a URL que você deu é do projeto '{2}'. Precisam ser o mesmo projeto." "$dbref" "$(sb_ref "$NEXT_PUBLIC_SUPABASE_URL")")"
return 1
fi;;
esac
local out
if out="$(pg_container postgres:17-alpine psql "$1" -tAc 'select 1' 2>&1)"; then
return 0
fi
echo "$(t "Não consegui conectar no banco. O Postgres respondeu:")"
printf ' %s\n' "$(printf '%s' "$out" | head -2)"
case "$out" in
*"could not translate host name"*)
echo " 👉 $(t "Quase sempre é a senha com caractere especial: na URL ela precisa ser codificada.")"
echo " $(t "Troque @ por %40 : por %3A / por %2F ? por %3F # por %23")";;
*"password authentication failed"*)
echo " 👉 $(t "Senha do banco errada. É a senha do PROJETO (definida ao criá-lo), não a da sua conta Supabase.")"
echo " $(t "Dá pra redefinir em Settings > Database > Reset database password.")";;
*"Network is unreachable"*|*"Cannot assign requested address"*)
echo " 👉 $(t "Isso é o problema de IPv6: use a connection string do Session pooler, não a Direct connection.")";;
esac
return 1
}
v_anthropic() {
case "$1" in sk-ant-*) ;; *) echo "$(t "A chave da Anthropic começa com 'sk-ant-'. Pegue em console.anthropic.com > API Keys.")"; return 1;; esac
local code
code="$(curl -s -o /dev/null -w '%{http_code}' -m 20 https://api.anthropic.com/v1/models \
-H "x-api-key: $1" -H "anthropic-version: 2023-06-01" 2>/dev/null)" || code=000
case "$code" in
2*) return 0;;
000) c_ylw "$(t " ⚠ não consegui checar a chave online; sigo com ela.")"; return 0;;
401) echo "$(t "A Anthropic recusou essa chave (401). Confira se está ativa e se copiou inteira.")"; return 1;;
*) c_ylw "$(t " ⚠ a Anthropic respondeu {1} ao testar a chave; sigo com ela." "$code")"; return 0;;
esac
}
# A OpenRouter entrou na lista de provedores (opção [1] da pergunta de qual IA
# vai atender) sem que este validador existisse. `ask_one` despacha o validador
# pelo NOME — `"$validator" "$input"` —, então um nome inexistente vira exit 127
# e o laço repete a pergunta para sempre: a pessoa que escolhe [1] não consegue
# terminar a instalação. E no caminho `--yes` a mesma ausência vira
# "OPENROUTER_API_KEY inválido" seguido de "Corrija o .env e rode de novo" —
# instrução impossível de cumprir, porque o .env está certo.
v_openrouter() {
case "$1" in sk-or-*) ;; *) echo "$(t "A chave da OpenRouter começa com 'sk-or-'. Pegue em openrouter.ai/keys.")"; return 1;; esac
local code
code="$(curl -s -o /dev/null -w '%{http_code}' -m 20 https://openrouter.ai/api/v1/key \
-H "Authorization: Bearer $1" 2>/dev/null)" || code=000
case "$code" in
2*) return 0;;
000) c_ylw "$(t " ⚠ não consegui checar a chave online; sigo com ela.")"; return 0;;
401) echo "$(t "A OpenRouter recusou essa chave (401). Confira se está ativa e se copiou inteira.")"; return 1;;
*) c_ylw "$(t " ⚠ a OpenRouter respondeu {1} ao testar a chave; sigo com ela." "$code")"; return 0;;
esac
}
v_openai() {
[ -z "$1" ] && return 0 # opcional
case "$1" in sk-*) ;; *) echo "$(t "A chave da OpenAI começa com 'sk-'. Pegue em platform.openai.com > API keys (ou deixe em branco).")"; return 1;; esac
local code
code="$(curl -s -o /dev/null -w '%{http_code}' -m 20 https://api.openai.com/v1/models \
-H "Authorization: Bearer $1" 2>/dev/null)" || code=000
case "$code" in
2*) return 0;;
000) c_ylw "$(t " ⚠ não consegui checar a chave online; sigo com ela.")"; return 0;;
401) echo "$(t "A OpenAI recusou essa chave (401). Confira se está ativa e se copiou inteira.")"; return 1;;
*) c_ylw "$(t " ⚠ a OpenAI respondeu {1} ao testar a chave; sigo com ela." "$code")"; return 0;;
esac
}
v_password() {
[ "${#1}" -ge 8 ] && return 0
echo "$(t "Senha muito curta ({1} caracteres). Use pelo menos 8 — é a senha de admin do seu CRM." "${#1}")"
return 1
}
# ── Pergunta uma coisa, valida, e aceita 'voltar' ───────────────────────────
# Devolve 0 quando o valor foi aceito, 2 quando a pessoa pediu para voltar.
# Repete a pergunta enquanto o validador reprovar: o instalador não deixa mais
# ninguém avançar carregando um dado errado.
ask_one() {
local var="$1" prompt="$2" default="${3:-}" validator="${4:-}" secret="${5:-}" optional="${6:-}"
local cur="${!var:-}"
[ -n "$cur" ] && return 0
if [ "$NONINTERACTIVE" = 1 ]; then
if [ -n "$default" ]; then printf -v "$var" '%s' "$default"; return 0; fi
[ -n "$optional" ] && return 0
die "$(t "Falta {1} (modo --yes exige .env preenchido)." "$var")"
fi
local input
while :; do
if [ "$secret" = "secret" ]; then
if ! read -r -s -p "$prompt${default:+ [$default]}: " input; then
die "$(t "A entrada terminou antes de eu receber {1}. Rode o instalador num terminal interativo." "$var")"
fi
echo
else
if ! read -r -p "$prompt${default:+ [$default]}: " input; then
die "$(t "A entrada terminou antes de eu receber {1}. Rode o instalador num terminal interativo." "$var")"
fi
fi
[ "$input" = "voltar" ] && return 2
input="${input:-$default}"
if [ -z "$input" ]; then
[ -n "$optional" ] && { printf -v "$var" '%s' ""; return 0; }
c_red "$(t " Esse campo é obrigatório. (digite 'voltar' para refazer a pergunta anterior)")"
continue
fi
# Campo secreto não ecoa o que foi colado — a pessoa não vê se colou, então
# tende a colar de novo "pra garantir", e cola duas vezes na mesma linha
# (sem separador, vira um valor só, dobrado). Isso gera chaves/connection
# strings inválidas com erro críptico lá na frente.
if [ "$secret" = "secret" ]; then
local len=${#input} half=$(( ${#input} / 2 ))
if [ $((len % 2)) -eq 0 ] && [ "${input:0:half}" = "${input:half}" ]; then
c_red "$(t " Esse valor parece ter sido colado 2x seguidas (o campo é secreto e não mostra o que você cola). Cole uma vez só.")"
continue
fi
fi
if [ -n "$validator" ]; then
printf ' … %s\r' "$(t "conferindo")"
local msg
if ! msg="$("$validator" "$input" 2>&1)"; then
printf ' \r'
printf '\033[31m ✖ %s\033[0m\n' "$(printf '%s' "$msg" | head -1)"
printf '%s\n' "$(printf '%s' "$msg" | tail -n +2)" | grep -v '^$' || true
c_dim "$(t " (digite 'voltar' para refazer a pergunta anterior)")"
continue
fi
[ -n "$msg" ] && printf '%s\n' "$msg"
printf ' \r'
fi
if [ "$secret" = "secret" ]; then c_grn "$(t " ✓ recebido ({1} caracteres)" "${#input}")"; else c_grn " ✓"; fi
printf -v "$var" '%s' "$input"
save_partial "$var"
return 0
done
}
# O limiar NÃO é 4 GB, e a diferença importa: `MemTotal` é o que sobra depois
# do que o kernel reserva para si, sempre menos do que foi vendido. Medido num
# kernel com 8 GiB configurados: 8025284 KB, ou 95,7% — na mesma proporção uma
# VPS de 4 GiB reporta ~4.012.000 KB, 0,3% acima de 4.000.000. E quem vende "4
# GB" em GB decimais entrega 3.906.250 KB, que reporta ~3.735.000. Ou seja: com
# o corte em 4.000.000 o aviso caía em cima de quem tinha ACABADO de comprar
# exatamente a VPS recomendada — a pior hora possível para dizer a alguém que o
# servidor dele é pequeno demais. 3.500.000 KB (3,34 GiB) deixa 4 GB de fora em
# qualquer convenção e ainda pega de sobra as de 2 e 3 GB, que sofrem de verdade.
RAM_MINIMA_KB=3500000
ram_abaixo_do_recomendado() { [ "${1:-0}" -lt "$RAM_MINIMA_KB" ]; }
# Uma linha de .env com o valor entre aspas DUPLAS e `\`, `"`, `$` e crase
# escapados — o que faz nome de empresa e senha sobreviverem à releitura.
#
# O encoding tem de servir a TRÊS consumidores, cada um com um parser próprio, e
# nenhum deles é o mesmo shell: o `load_env` do _common.sh (leitura manual, por
# onde passa todo script do kit), o `env_file: .env` do docker-compose.prod.yml
# (:34 e :71) e o `source .env && curl …` que o README ensina (:143).
#
# Era aspas SIMPLES, com a aspa do conteúdo escrita como `'\''` — shell válido,
# e só. O parser de dotenv do Compose não é um shell: ele lê aquela barra como
# começo de nome de variável e recusa o ARQUIVO INTEIRO. Medido no compose
# v2.38.2 com `APP_NAME=Sant'Ana Odontologia`:
#
# failed to read .env: line 1: unexpected character "\" in variable name
# "\''Ana Odontologia'"
# config → rc=1 ; ps → rc=1 ; pull → rc=1 (o mesmo .env sem apóstrofo: rc=0)
#
# Nomes assim são comuns aqui — "Sant'Ana", "D'Ávila", "Espaço D'Or" —, APP_NAME
# é a última pergunta da entrevista e não tem validador. O desfecho era o pior
# tipo de quebra: Supabase provisionado, schema aplicado, admin criado, e TODO
# comando docker do kit morto, sem nada apontando para o .env.
#
# RESIDUAL MEDIDO, e a escolha por trás dele: o Compose desfaz `\"`, `\\` e `\$`
# dentro das aspas duplas, mas NÃO desfaz a crase escapada — um valor com crase
# chega ao contêiner com as barras (medido: `Loja \`date\` Ltda`). Escapá-la
# assim mesmo é deliberado: sem a barra, o `source .env` do README EXECUTA o que
# estiver entre crases. Caractere feio no contêiner é preço menor que execução
# de comando na máquina de quem instala. As duas outras pontas (load_env e
# source) recebem a crase intacta.
#
# Fica aqui em cima (e não junto do bloco que escreve o .env) porque o
# save_partial abaixo grava durante a ENTREVISTA, muito antes daquele bloco.
envq() { printf '%s="%s"\n' "$1" "$(printf '%s' "${2-}" | sed 's/[\\"$`]/\\&/g')"; }
# Guarda cada resposta no instante em que ela é aceita. Antes, as 12 respostas
# só viravam arquivo no FIM: quem travasse na connection string — a pergunta
# mais difícil, e a última das credenciais — perdia tudo o que já tinha digitado
# e recomeçava do zero na tentativa seguinte. Justamente quem mais precisa de
# uma segunda tentativa é quem tem menos paciência para redigitar 11 campos.
# Mesma permissão do .env (600): o conteúdo é quase o mesmo, segredos inclusive
# — a exceção é o token de CONTA, e o bloco abaixo explica por quê.
PARTIAL_FILE="${PARTIAL_FILE:-.env.partial}"
# A exceção: segredo de CONTA não entra no rascunho, nem por um instante.
#
# O `SUPABASE_ACCESS_TOKEN` abre a Management API, que cria e apaga projetos —
# por isso ele já não vai para o `.env` (não há `envq` para ele) e o README
# promete, na tabela de pré-requisitos, que "ele não fica salvo: é usado uma vez
# e some com o processo". O rascunho desmentia as duas coisas: `ask_one` chama
# `save_partial` para TODA resposta aceita, então o token ficava em `.env.partial`
# desde a pergunta até o `rm -f` que só acontece depois de o `.env` ser escrito —
# e qualquer aborto no meio (Ctrl-C, DNS errado, `die` de validação) o deixava no
# disco, para a tentativa seguinte recarregar.
#
# POR QUE PULAR A VARIÁVEL, e não um `trap` que apague o rascunho em qualquer
# saída: o trap protegeria este segredo melhor e mataria a única razão de o
# rascunho existir. Ele existe para a saída ANORMAL — é exatamente aí que o trap
# dispararia, e quem travou na connection string voltaria a redigitar as 11
# respostas anteriores. Pular tira do disco o que não pode ficar e deixa intacto
# o que o rascunho protege.
#
# O efeito colateral é deliberado e está dito na tela (ver o aviso junto do
# "✓ retomando"): ao retomar, o token é perguntado de novo. Para um segredo de
# conta esse é o comportamento certo, e o campo é opcional — Enter pula.
RASCUNHO_NAO_GUARDA=" SUPABASE_ACCESS_TOKEN "
save_partial() {
local var="$1" val="${!1-}" tmp="${PARTIAL_FILE}.tmp.$$"
case "$RASCUNHO_NAO_GUARDA" in *" $var "*) return 0 ;; esac
umask 077
{ [ -f "$PARTIAL_FILE" ] && grep -vE "^${var}=" "$PARTIAL_FILE" || true; } > "$tmp"
envq "$var" "$val" >> "$tmp"
chmod 600 "$tmp"
mv "$tmp" "$PARTIAL_FILE"
}
# Quem publica 80 ou 443 NO HOST? Lê linhas "nome|projeto|imagem|portas" (o
# formato do docker ps) e ecoa "nome|imagem" do primeiro que casar.
#
# O lado que importa da coluna Ports é o ANTES da seta — "0.0.0.0:80->80/tcp" é
# host 80; "0.0.0.0:8080->80/tcp" é host 8080 e NÃO disputa nada. A primeira
# versão disto olhava `docker port` ancorado na porta INTERNA, e errava dos dois
# lados: abortava a instalação por causa de um phpMyAdmin em `-p 8080:80` (com
# 80/443 livres, mandando o dono derrubar o site dele), e deixava passar um proxy
# real em `-p 80:8080`, que é como se sobe Traefik sem privilégio.
#
# Separador é "|", não TAB: tab é IFS-whitespace, então dois tabs seguidos viram
# um só e o campo vazio do meio SOME — um proxy subido com `docker run` (sem
# label de compose) perdia a imagem, e o Traefik da hospedagem chamado
# "coolify-proxy" era classificado como intruso.
dono_das_portas() { # dono_das_portas < linhas → ecoa "nome|projeto|imagem"
local nome proj img ports
while IFS='|' read -r nome proj img ports; do
[ -n "$nome" ] || continue
case "${ports// /}" in
*:80-\>*|*:443-\>*) printf '%s|%s|%s' "$nome" "$proj" "$img"; return 0;;
esac
done
return 1
}
# `nome_do_projeto_compose` e a checagem da rede externa do proxy vivem em
# _common.sh: o update.sh precisa das mesmas duas coisas e duplicá-las era
# garantir que uma das cópias envelhecesse. Este arquivo as usa depois do
# `source` do bloco 2 (nada acima dele depende delas).
# A pergunta que decide é "o Docker consegue publicar a porta?", e a resposta
# vem de TENTAR — não de inferir. Toda heurística sobre `docker port` ou `ss`
# erra em algum caso real (proxy sem privilégio publicando 80:8080, app em
# 8080:80, bind só no loopback, `ss` fora do PATH, userland-proxy desligado), e
# erra dos dois lados: ou aborta uma instalação boa, ou entrega o choque de
# portas lá na frente. Publicar de mentirinha usa exatamente a mecânica que o
# Caddy vai usar, então não há espaço entre o teste e a realidade.
# O contêiner sai na hora; a imagem é a mesma que o compose do kit já usa.
porta_publicavel() { # porta_publicavel <porta>
docker run --rm -p "$1:$1" --entrypoint /bin/true alpine:3.20 >/dev/null 2>&1
}
# A decisão final, isolada para poder ser exercitada sem Docker: este é o ponto
# que já errou duas vezes (uma tratando o próprio Caddy como intruso, outra
# deixando passar proxy que não fosse Traefik), e nas duas o erro só apareceu
# rodando de verdade numa VPS.
# Ecoa: caddy | traefik | bloqueia
decide_proxy() { # decide_proxy <portas_ocupadas> <projeto_do_dono> <projeto_atual> <imagem> <nome> [árvore_do_dono] [árvore_atual]
local ocupadas="${1:-}" dono_proj="${2:-}" meu_proj="${3:-}" img="${4:-}" nome="${5:-}"
local dono_dir="${6:-}" meu_dir="${7:-}"
[ -z "$ocupadas" ] && { printf 'caddy'; return 0; }
# As portas estão com ESTA MESMA instalação, já no ar: é a re-execução, que o
# próprio kit ensina como caminho para corrigir uma resposta.
#
# Só que "mesma instalação" NÃO é o mesmo que "mesmo nome de projeto": o nome
# é o basename da pasta, e toda cópia do repo se chama DeskcommCRM. Duas
# árvores irmãs (/root/DeskcommCRM e /root/apagar7/DeskcommCRM) colidem no
# nome `deskcommcrm` e esta linha as declarava re-execução uma da outra —
# exatamente o caso que a varredura de portas foi escrita para pegar. Medido
# numa VPS de produção em 2026-08-24: a instalação de uma aula passou por
# aqui, recriou os contêineres do CRM no ar com o .env dela e trocou o banco
# da instalação de produção, sem um aviso. Quem separa as duas é a ÁRVORE.
#
# Árvore desconhecida (contêiner sem o label, ou criado fora do compose) cai
# no comportamento anterior de propósito: não dá para AFIRMAR cópia irmã, e
# fechar no escuro quebraria a re-execução legítima que o kit ensina.
if [ -n "$dono_proj" ] && [ "$dono_proj" = "$meu_proj" ]; then
if [ -n "$dono_dir" ] && [ -n "$meu_dir" ] && [ "$dono_dir" != "$meu_dir" ]; then
printf 'bloqueia'; return 0
fi
printf 'caddy'; return 0
fi
eh_traefik "$img" "$nome" && { printf 'traefik'; return 0; }
printf 'bloqueia'
}
# É um Traefik? Compara em minúsculas o par imagem+nome. A versão anterior usava
# `*[Tt]raefik*`, que só tem classe de equivalência no primeiro caractere:
# um contêiner "TRAEFIK-PROXY" escapava e era tratado como intruso.
eh_traefik() { # eh_traefik <imagem> <nome>
case "$(printf '%s %s' "${1:-}" "${2:-}" | tr '[:upper:]' '[:lower:]')" in
*traefik*) return 0;;
esac
return 1
}
# O ÚNICO Traefik de uma lista do `docker ps` — e só quando é único.
#
# Existe porque a busca por porta publicada NÃO enxerga proxy em modo host:
# compartilhando a stack de rede da máquina, ele ouve 80/443 sem publicar nada e
# a coluna Ports do `docker ps` sai VAZIA. Medido no docker 28.3.2: um contêiner
# em `--network host` sai com Ports=[] mesmo subido com `-p 80:80` (o daemon
# avisa "Published ports are discarded when using host network mode"); controle
# positivo, o mesmo nginx numa bridge com `-p 8080:80` sai com
# "0.0.0.0:8080->80/tcp". É o caso da Hostinger, e sem este caminho o dono das
# portas fica "não identificado" — a instalação morre no painel de bloqueio.
#
# Falha FECHADA no plural: com dois Traefiks não dá para saber qual está com as
# portas, e chutar publica o CRM atrás do proxy errado — um site que "instala com
# sucesso" e não responde. Nesse caso ninguém é eleito e o painel de bloqueio, que
# ao menos diz o que fazer, volta a ser o desfecho.
unico_traefik() { # unico_traefik < linhas "nome|projeto|imagem|portas" → ecoa "nome|projeto|imagem"
local nome proj img ports achado="" n=0
while IFS='|' read -r nome proj img ports; do
[ -n "$nome" ] || continue
eh_traefik "$img" "$nome" || continue
achado="$nome|$proj|$img"; n=$((n + 1))
done
[ "$n" = 1 ] || return 1
printf '%s' "$achado"
}
# Qual rede gravar em TRAEFIK_NETWORK. Isolada do Docker para poder ser
# exercitada: é aqui que moram DUAS conclusões opostas, e cada uma já foi a
# única implementada em algum momento do kit.
#
# Traefik numa bridge PRÓPRIA → a rede dele (o app é anexado a ela).
# Medido com Traefik v3.3 real: com o label apontando para a rede do projeto
# a requisição fica em HTTP 000 (timeout) mesmo com o contêiner nas duas
# redes; só apontando para a rede do PROXY vira HTTP 200.
#
# Traefik em modo HOST → uma bridge NOSSA, que o instalador cria.
# Aqui o proxy não está em rede nenhuma do Docker (`.NetworkSettings.Networks`
# devolve a string "host", que existe no `docker network ls` mas com driver
# `host` e não aceita contêiner junto de uma bridge). Compartilhando a stack
# do host ele alcança qualquer bridge por IP — medido nesta máquina: contêiner
# em `--network host` faz `curl` no IP de um contêiner numa bridge separada e
# recebe HTTP 200. Então a rede a apontar é uma bridge onde o app esteja, e
# usamos uma dedicada em vez da `_internal` do projeto por dois motivos
# medidos: (1) o compose recusa `external` apontando para a rede que ele
# mesmo criaria — "network <projeto>_internal declared as external, but could
# not be found" numa instalação nova; (2) a `internal` tem o redis SEM SENHA,
# e a rede do proxy é a única que um dia pode receber contêiner de fora.
rede_do_traefik() { # rede_do_traefik <NetworkMode do contêiner> <redes do contêiner> <bridge do projeto>
local netmode="${1:-}" redes="${2:-}" nossa="${3:-}"
[ "$netmode" = host ] && { printf '%s' "$nossa"; return 0; }
printf '%s' "$redes" | awk '{print $1}'
}
# Como o Traefik da hospedagem CHAMA as portas 80 e 443. Os nomes `web` e
# `websecure` são convenção da documentação, não regra: o EasyPanel batiza os
# dele de `http` e `https`, e um label apontando para um entrypoint que não
# existe não gera erro nenhum — o Traefik simplesmente ignora a rota, o domínio
# cai no 404 do painel e a instalação termina verde com o site mudo. Medido numa
# VPS com EasyPanel: os 6 contêineres no ar, /api/v1/health saudável por dentro
# e o domínio devolvendo a página de erro do painel.
#
# A configuração do Traefik chega por env (TRAEFIK_ENTRYPOINTS_<NOME>_ADDRESS) ou
# por flag (--entrypoints.<nome>.address), e a porta pode vir `:80`, `0.0.0.0:80`
# ou com IP. Nome nenhum encontrado devolve vazio, e quem chama fica com o
# default de sempre — quem instala hoje atrás de um Traefik com os nomes da
# documentação não muda de comportamento.
#
# Isolada do Docker pelo mesmo motivo de `rede_do_traefik`: para o teste poder
# exercitar a decisão sem uma VPS.
entrypoints_do_traefik() { # entrypoints_do_traefik <env e args do contêiner, um por linha> → "<nome do :80> <nome do :443>"
printf '%s\n' "${1:-}" | awk '
{ l = tolower($0) }
l ~ /entrypoints[._][a-z0-9-]+[._]address=/ {
nome = l; sub(/[._]address=.*/, "", nome); sub(/.*entrypoints[._]/, "", nome)
porta = l; sub(/.*address=/, "", porta); sub(/\/.*/, "", porta); sub(/.*:/, "", porta)
if (porta == "80" && http == "") http = nome
if (porta == "443" && https == "") https = nome
}
END { print http " " https }'
}
# Um Traefik eleito pela varredura de MODO HOST é suspeita, não prova. A eleição
# por porta publicada tem a evidência na mão — a coluna Ports diz `:80->`. A
# varredura por `--network host` não tem nenhuma: em modo host a coluna sai vazia
# para TODO mundo, então o que ela responde é "existe um único Traefik em modo
# host nesta máquina", e não "é ele quem está com as portas". Basta um nginx ou
# apache NATIVO segurando 80/443 e um Traefik em modo host servindo outra coisa
# para o instalador publicar o CRM atrás de um proxy que não atende: "instalou
# com sucesso" e o site mudo — o desfecho silencioso que este bloco inteiro
# existe para evitar.
#
# Fechado na AÇÃO, aberto na INFORMAÇÃO: quem está na frente do terminal
# confirma (e a pergunta diz o que foi encontrado); quem rodou --yes leva uma
# recusa que ensina a saída, que é declarar REVERSE_PROXY=traefik no .env. A
# declaração explícita continua valendo — ali a escolha é de quem instala, não
# um chute do instalador.
# Ecoa: segue | pergunta | recusa
confianca_no_dono_das_portas() { # confianca_no_dono_das_portas <veio_da_varredura_host> <noninteractive>
local varredura="${1:-0}" nao_interativo="${2:-0}"
[ "$varredura" = 1 ] || { printf 'segue'; return 0; }
[ "$nao_interativo" = 1 ] && { printf 'recusa'; return 0; }
printf 'pergunta'
}
# Esconde o miolo de um segredo para a tela de conferência.
mask() {
local v="$1"
if [ -z "$v" ]; then printf '(vazio)'; return; fi
if [ "${#v}" -le 12 ]; then printf '%s' "****"; else printf '%s…%s (%d caracteres)' "${v:0:8}" "${v: -4}" "${#v}"; fi
}
# Lê as 4 credenciais que o supabase-provision.sh imprime (`CHAVE='valor'`) SEM
# interpretar o conteúdo.
#
# Por que não `eval`: os valores saem de `printf "%s='%s'"` sem escapar a aspa
# simples, então um valor que contenha `'` fecha o literal e o resto da linha
# volta a ser CÓDIGO — e `SUPABASE_REGION`, que vem do ambiente, é interpolada
# dentro da connection string que sai de lá. Mesma postura do `load_env`
# (_common.sh): casa a chave contra uma lista fixa e copia o valor como texto.
# Chave fora da lista é ignorada, então a saída nunca cria variável arbitrária.
sb_carrega_credenciais() {
local linha val
while IFS= read -r linha; do
val="${linha#*=\'}"; val="${val%\'}"
case "$linha" in
NEXT_PUBLIC_SUPABASE_URL=\'*\') NEXT_PUBLIC_SUPABASE_URL="$val";;
NEXT_PUBLIC_SUPABASE_ANON_KEY=\'*\') NEXT_PUBLIC_SUPABASE_ANON_KEY="$val";;
SUPABASE_SERVICE_ROLE_KEY=\'*\') SUPABASE_SERVICE_ROLE_KEY="$val";;
SUPABASE_DB_URL=\'*\') SUPABASE_DB_URL="$val";;
esac
done <<<"$1"
}
# Carrega só as funções acima, sem instalar nada — é assim que
# `test-validators.sh` exercita os validadores: INSTALL_SH_LIB=1 . install.sh
if [ "${INSTALL_SH_LIB:-}" = "1" ]; then trap - EXIT; return 0; fi
# Primeiro passo interativo de todos — antes do banner, para o resto da
# instalação já nascer no idioma escolhido.
perguntar_idioma_cli
banner
# ── 1. Preflight ────────────────────────────────────────────────────────────
fase 1 "$(t "Preparando o servidor")"
step "$(t "Verificando dependências")"
# VPS "cru" (Hetzner, DigitalOcean, Contabo…) não vem com Docker. Antes isto era
# um beco sem saída: o script morria dizendo "instale antes de continuar" e a
# pessoa — que por definição não é técnica — ficava sem saber como. Hospedagens
# com template (Hostinger, HostGator) já trazem Docker, então o caso nunca
# aparecia para quem escreveu o kit.
#
# O instalador oficial do Docker é o mesmo comando da documentação deles; não
# inventamos nada. Em modo interativo PERGUNTA (instalar coisa no servidor de
# alguém sem avisar é abuso de confiança); com --yes segue direto, que é o
# contrato desse modo.
if ! command -v docker >/dev/null 2>&1; then
c_ylw "$(t "⚠ Docker não está instalado — é o motor que roda o CRM.")"
instalar=1
if [ "$NONINTERACTIVE" = 0 ]; then
read -r -p "$(t " Posso instalar agora? (S/n) ")" r
case "${r:-S}" in [Nn]*) instalar=0;; esac
fi
if [ "$instalar" = 1 ]; then
c_dim "$(t " Instalando (get.docker.com — o instalador oficial). Leva 1-2 minutos…")"
# A saída vai para um log em vez de /dev/null: silenciar o stderr também
# deixava a falha MUDA (disco cheio, apt travado, arquitetura sem pacote
# viravam todos a mesma frase genérica) — exatamente o que o trap lá em cima
# existe para impedir. Tela limpa no caminho feliz, causa real no caminho ruim.
_docker_log="$(mktemp)"
if ! curl -fsSL https://get.docker.com | sh >"$_docker_log" 2>&1; then
c_red "$(t " Últimas linhas do instalador do Docker:")"; tail -15 "$_docker_log" >&2
die "$(t "Não consegui instalar o Docker (log em {1}). Rode 'curl -fsSL https://get.docker.com | sh' e tente de novo." "$_docker_log")"
fi
rm -f "$_docker_log"; unset _docker_log
command -v docker >/dev/null 2>&1 || die "$(t "Docker instalou mas não ficou no PATH. Reabra o terminal e rode de novo.")"
c_grn "$(t "✓ Docker instalado")"
else
die "$(t "Sem Docker não dá para seguir. Instale com: curl -fsSL https://get.docker.com | sh")"
fi
fi
for bin in docker git openssl curl; do
command -v "$bin" >/dev/null 2>&1 || die "$(t "'{1}' não encontrado. Instale antes de continuar." "$bin")"
done
docker compose version >/dev/null 2>&1 || die "$(t "'docker compose' (v2) não encontrado.")"
docker info >/dev/null 2>&1 || die "$(t "O daemon do Docker não está rodando (ou seu usuário não tem permissão).")"
c_grn "$(t "✓ docker, git, openssl, curl ok")"
# RAM: a imagem é pré-buildada, então a stack SOBE com 2GB. Mas o runbook de produção
# declara 4GB como mínimo de operação: 7 contêineres, e o WAHA usa ~150MB por sessão
# sobre ~300MB de overhead do Node. Avisar só abaixo de 1.5GB deixava o operador
# instalar em 2GB achando que estava dentro do recomendado.
if [ -r /proc/meminfo ]; then
mem_kb=$(awk '/MemTotal/{print $2}' /proc/meminfo)
if ram_abaixo_do_recomendado "$mem_kb"; then
c_ylw "$(t "⚠ Este servidor tem ~{1}MB de RAM. O CRM sobe, mas fica no limite:" "$((mem_kb/1024))")"
c_ylw "$(t " são 7 contêineres e o WhatsApp usa ~150MB por número conectado.")"
c_ylw "$(t " Adicione swap antes de operar — ver docs/runbooks/waha-hostgator.md.")"
fi
fi
# ── 2. Repositório ──────────────────────────────────────────────────────────
step "$(t "Localizando o projeto")"
if [ -f "$COMPOSE" ]; then
c_grn "$(t "✓ rodando dentro do repositório")"
elif [ -f "$REPO_DIR/$COMPOSE" ]; then
cd "$REPO_DIR"; c_grn "$(t "✓ repositório em ./{1}" "$REPO_DIR")"
else
c_ylw "$(t "Clonando {1} ..." "$REPO_URL")"
git clone --depth 1 "$REPO_URL" "$REPO_DIR"
cd "$REPO_DIR"
fi
PROJECT_DIR="$(pwd)"
source "$KIT_DIR/_common.sh"
# ── O invariante 8 também vale para quem INSTALA ────────────────────────────
#
# `recusar_projeto_de_outra_arvore` já protegia o `update.sh` (:33) e o
# `agent.sh` (:45), e não protegia este arquivo — a porta por onde o incidente
# entrou. O painel de cópia irmã, mais abaixo, cobre o caso em que a instalação
# do ar é a DONA das portas 80/443; este guarda é o que vale sempre, porque
# pergunta pelos CONTÊINERES do projeto, não pelo proxy:
#
# - VPS com Traefik do painel (Coolify/Hostinger): lá `decide_proxy` sai por
# `traefik` antes de comparar árvore, e o painel nunca é alcançado;
# - pasta que já concluiu uma instalação: o próprio install.sh grava
# REVERSE_PROXY no .env (:1413), e na rodada seguinte o `if [ -z ... ]` que
# embrulha o painel é falso — o instalador desligava o próprio guarda;
# - portas 80/443 livres: `decide_proxy` devolve `caddy` na primeira linha.
#
# Nos três, `docker compose ... up -d` subia sobre o parque da produção com o
# .env desta pasta (outro banco, outras chaves) — o sintoma medido foi a senha
# "parar de funcionar" e as conexões de WhatsApp caírem.
#
# AQUI e não no preflight: `projeto_pertence_a_outra_arvore` compara contra
# `PROJECT_DIR`, que só existe a partir da linha acima. E antes da coleta de
# config, para não pedir dado nenhum a quem vai ser recusado.
#
# Instalação NOVA não é afetada: sem contêiner do projeto no ar, a função
# devolve vazio e o guarda deixa passar. Re-executar na MESMA pasta idem — a
# árvore é a mesma. `DESKCOMM_ASSUMIR_PROJETO=1` é a saída para quem move a
# instalação de lugar de propósito, e é a mesma dos outros dois call sites.
recusar_projeto_de_outra_arvore || die "$(t "Instalação interrompida para não derrubar o CRM que já está no ar nesta VPS.")"
# ── 3. Coleta de config ─────────────────────────────────────────────────────
fase 2 "$(t "Suas informações")"
step "$(t "Configuração")"
# Se já existe .env, carrega pra não repetir perguntas (idempotência).
if [ -f .env ]; then load_env .env; c_grn "$(t "✓ .env existente carregado")"; fi
# Respostas guardadas de uma tentativa que não chegou ao fim. Carregam DEPOIS do
# .env de propósito: se as duas fontes têm a chave, a mais recente é esta.
if [ -f "$PARTIAL_FILE" ]; then
load_env "$PARTIAL_FILE"
c_grn "$(t "✓ retomando: {1} resposta(s) guardadas da tentativa anterior" "$(grep -c '=' "$PARTIAL_FILE" 2>/dev/null || echo 0)")"
c_dim "$(t " (para responder tudo de novo do zero: rm {1})" "$PARTIAL_FILE")"
# Sem esta linha, ser perguntado de novo sobre o token — depois de uma tela
# dizendo que N respostas foram guardadas — lê como defeito do instalador.
c_dim "$(t " (o token do Supabase é de conta e nunca entra no rascunho: ele é perguntado de novo. Enter pula)")"
fi
# ── Proxy reverso: quem está com as portas 80 e 443? ────────────────────────
# Fica AQUI, logo depois de ler o .env e ANTES de qualquer coisa cara: era a
# última etapa da fase 2, então quem esbarrava neste problema já tinha criado um
# projeto Supabase, respondido tudo e esperado o clone — para só então o
# `docker compose up` morrer com "Bind for 0.0.0.0:80 failed: port is already
# allocated". Descobrir isso antes de cobrar qualquer trabalho é o mínimo.
#
# A varredura NÃO procura por "traefik": procura por QUEM PUBLICA as portas, e
# só depois pergunta o que é. A versão anterior só reconhecia Traefik, então um
# Caddy — inclusive o de outro DeskcommCRM instalado na mesma VPS — passava
# despercebido e a instalação escolhia `caddy`, garantindo o choque de portas.
# Medido numa VPS com produção rodando: exatamente esse erro, na fase 4.
#
# Contêineres DESTA instalação são ignorados: numa re-execução o nosso próprio
# Caddy está de pé publicando 80/443, e tratá-lo como "outro proxy" mataria a
# idempotência — que é justamente o que permite rodar de novo para corrigir uma
# resposta errada.
proj_atual="$(nome_do_projeto_atual)"
portas_ocupadas=""; n_ocupadas=0
porta_publicavel 80 || { portas_ocupadas="80"; n_ocupadas=1; }
porta_publicavel 443 || { portas_ocupadas="${portas_ocupadas:+$portas_ocupadas e }443"; n_ocupadas=$((n_ocupadas + 1)); }
# Identificação do ocupante — só para a MENSAGEM. Quem decide é o teste acima,
# então não saber quem é NUNCA vira "pode instalar": a falha é fechada.
# O "|| true" não é decorativo: numa atribuição o status do pipeline vira o
# status do script, e sob `set -e` + `pipefail` um docker ps que falhe (ou um
# SIGPIPE do consumidor) mataria o instalador mudo, no meio da fase 2.
dono_portas=""; dono_projeto=""; dono_imagem=""; dono_arvore=""
if [ -n "$portas_ocupadas" ]; then
_dono="$(docker ps --format '{{.Names}}|{{.Label "com.docker.compose.project"}}|{{.Image}}|{{.Ports}}' 2>/dev/null | dono_das_portas || true)"
if [ -n "$_dono" ]; then
dono_portas="${_dono%%|*}"; _resto="${_dono#*|}"
dono_projeto="${_resto%%|*}"; dono_imagem="${_resto#*|}"
unset _resto
# De qual cópia do repo saiu esse contêiner. É o que separa "sou eu rodando
# de novo" de "é a instalação irmã que está no ar" quando as duas pastas se
# chamam DeskcommCRM e por isso compartilham o nome do projeto.
dono_arvore="$(docker inspect "$dono_portas" --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' 2>/dev/null || true)"
dono_arvore="${dono_arvore%/}"
fi
unset _dono
fi
# Ninguém PUBLICA as portas, mas elas estão ocupadas: o candidato é um proxy em
# modo host, que ouve 80/443 pela stack de rede da máquina e por isso sai com a
# coluna Ports vazia (a medição está em `unico_traefik`). Sem este ramo o dono
# ficava "não identificado" e a instalação parava no painel de bloqueio — que
# manda pôr REVERSE_PROXY=traefik no .env, caminho que também morria adiante.
# É exatamente a VPS Hostinger da issue #139.
#
# A eleição fica MARCADA: quem veio daqui não tem a coluna Ports como prova, e o
# `case` abaixo trata essa diferença (ver `confianca_no_dono_das_portas`).
dono_por_varredura_host=0
if [ -n "$portas_ocupadas" ] && [ -z "$dono_portas" ]; then
_host="$(docker ps --filter network=host --format '{{.Names}}|{{.Label "com.docker.compose.project"}}|{{.Image}}|{{.Ports}}' 2>/dev/null | unico_traefik || true)"
if [ -n "$_host" ]; then
dono_portas="${_host%%|*}"; _resto="${_host#*|}"
dono_projeto="${_resto%%|*}"; dono_imagem="${_resto#*|}"
dono_por_varredura_host=1
unset _resto
fi
unset _host
fi
# Inicializado sempre: o bloco da rede do Traefik, mais abaixo, lê esta variável
# sem default, e sob `set -u` uma instalação que já traz REVERSE_PROXY=traefik no
# .env (portanto sem passar pela detecção) morreria com "unbound variable".
traefik_container=""
if [ -z "${REVERSE_PROXY:-}" ]; then
# A exclusão da própria instalação acontece AQUI, não na varredura: o teste de
# bind não tem como se auto-excluir, então filtrar o nosso contêiner antes só
# produzia um "ocupado por ninguém" — bloqueio sem um comando sequer.
_minha_arvore="${PROJECT_DIR:-$PWD}"; _minha_arvore="${_minha_arvore%/}"
case "$(decide_proxy "$portas_ocupadas" "$dono_projeto" "$proj_atual" "$dono_imagem" "$dono_portas" "$dono_arvore" "$_minha_arvore")" in
caddy)
REVERSE_PROXY=caddy
[ -n "$portas_ocupadas" ] && c_dim "$(t " (as portas 80/443 já estão com esta instalação — seguindo)")"
;;
traefik)
# O porquê de a varredura por modo host não bastar sozinha está em
# `confianca_no_dono_das_portas`.
case "$(confianca_no_dono_das_portas "$dono_por_varredura_host" "$NONINTERACTIVE")" in
pergunta)
c_ylw "$(t "⚠ As portas {1} estão ocupadas, mas NENHUM contêiner as publica." "$portas_ocupadas")"
c_ylw "$(t " O único Traefik em modo host aqui é '{1}'{2}." "$dono_portas" "${dono_imagem:+ ($(t "imagem") $dono_imagem)}")"
printf '\n%s\n' "$(t " Em modo host o Docker não mostra as portas, então não consigo PROVAR que é ele")"
printf '%s\n\n' "$(t " quem atende o seu domínio — poderia ser um nginx/apache instalado no servidor.")"
printf '%s\n' "$(t " Se for ele, o CRM sai publicado por ele e tudo funciona.")"
printf '%s\n\n' "$(t " Se não for, o site vai subir e não responder — sem erro nenhum na tela.")"
if ! read -r -p "$(t " É o '{1}' que atende o seu site? (s/N) " "$dono_portas")" _r; then _r=""; fi
if ! resposta_sim "$_r"; then
die "$(t "Ok, não vou arriscar. Descubra quem está com as portas 80/443 (ex.: 'ss -ltnp | grep :80')
e, se for mesmo um Traefik, ponha REVERSE_PROXY=traefik no .env e rode de novo.")"
fi
unset _r
;;
recusa)
c_red "$(t "✖ As portas {1} estão ocupadas, mas NENHUM contêiner as publica." "$portas_ocupadas")"
printf '\n%s\n' "$(t " O único Traefik em modo host aqui é '{1}'{2}," "$dono_portas" "${dono_imagem:+ ($(t "imagem") $dono_imagem)}")"
printf '%s\n\n' "$(t " e em modo host o Docker não mostra porta — não dá para provar que é ele quem atende.")"
printf '%s\n' "$(t " Publicar o CRM atrás do proxy errado instala 'com sucesso' um site que não responde,")"
printf '%s\n\n' "$(t " então em modo --yes eu paro aqui em vez de chutar.")"
printf '%s\n' "$(t " É esse Traefik mesmo? Ponha no .env e rode de novo:")"
printf '%s\n\n' " REVERSE_PROXY=traefik"
printf '%s\n' "$(t " Não é? Confira quem está com as portas: ss -ltnp | grep -E ':80|:443'")"
die "$(t "Não consigo identificar com certeza o dono das portas {1} em modo --yes." "$portas_ocupadas")"
;;
esac
REVERSE_PROXY=traefik
traefik_container="$dono_portas"
c_ylw "$(t "⚠ Detectei um Traefik já rodando neste VPS (contêiner '{1}', ocupando 80/443)." "$dono_portas")"
c_ylw "$(t " Vou publicar o CRM através dele em vez de subir um proxy próprio —")"
c_ylw "$(t " desligar o Traefik quebraria o que a sua hospedagem instalou.")"
;;
*)
# A preposição vem junto do trecho: "por o contêiner" sai errado se a frase
# fixar "por" e o pedaço variável começar com artigo. E a imagem só entra se
# for conhecida — "(imagem )" vazio era o sintoma de um campo perdido.
ocupante="${dono_portas:+$(t "pelo contêiner '{1}'{2}" "$dono_portas" "${dono_imagem:+ ($(t "imagem") $dono_imagem)}")}"
ocupante="${ocupante:-$(t "por um programa do próprio servidor")}"
# Cópia irmã tem um diagnóstico próprio: o painel genérico abaixo fala de
# "porta ocupada", e quem lê isso numa pasta recém-clonada não liga o aviso
# à instalação que está no ar — foi assim que uma aula subiu por cima de uma
# produção. Aqui o nome das DUAS pastas aparece.
if [ -n "$dono_arvore" ] && [ "$dono_projeto" = "$proj_atual" ] && [ "$dono_arvore" != "$_minha_arvore" ]; then
c_red "$(t "✖ Já existe um DeskcommCRM NO AR nesta VPS, instalado em {1}." "$dono_arvore")"
printf '\n%s\n' "$(t " Esta pasta ({1}) é outra cópia do repo. As duas se chamam" "$_minha_arvore")"
printf '%s\n' "$(t " DeskcommCRM, então o Docker dá às duas o MESMO nome de projeto")"
printf '%s\n\n' "$(t " ('{1}') — e instalar aqui recriaria os contêineres daquela." "$proj_atual")"
printf '%s\n' "$(t " Na prática: o CRM que está no ar passaria a rodar com o .env DESTA pasta")"
printf '%s\n\n' "$(t " (outro banco, outras chaves), e as conexões de WhatsApp cairiam.")"
printf '%s\n' "$(t " Quer atualizar o que já existe? Use aquela pasta:")"
printf '%s\n\n' " cd ${dono_arvore} && bash hostgator-setup-kit/update.sh"
printf '%s\n' "$(t " Quer mesmo uma SEGUNDA instalação nesta VPS? Ela precisa de nome de")"
printf '%s\n' "$(t " projeto e domínio próprios — ponha no .env desta pasta, antes de rodar:")"
printf '%s\n\n' " COMPOSE_PROJECT_NAME=deskcomm-$(basename "${_minha_arvore}" | tr 'A-Z' 'a-z')-2"
die "$(t "Instalação interrompida para não derrubar o DeskcommCRM que está no ar em {1}." "$dono_arvore")"
fi
# Concordância com o número de portas: "A porta 80 e 443 já está ocupada"
# saiu na prova real e denuncia texto montado sem olhar o próprio dado.
if [ "$n_ocupadas" -gt 1 ]; then
c_red "$(t "✖ As portas {1} já estão ocupadas {2}." "$portas_ocupadas" "$ocupante")"
else
c_red "$(t "✖ A porta {1} já está ocupada {2}." "$portas_ocupadas" "$ocupante")"
fi
printf '\n%s\n' "$(t " O CRM precisa dessas duas portas para publicar o site com HTTPS. Subir um")"
printf '%s\n\n' "$(t " segundo proxy nelas não funciona: o Docker recusa e a instalação para.")"
printf '%s\n' "$(t " Como resolver, na ordem do mais provável:")"
printf '\n%s\n' "$(t " 1. Já é outro DeskcommCRM neste servidor? Então use aquele — entre na")"
printf '%s\n' "$(t " pasta dele e rode: bash hostgator-setup-kit/update.sh")"
printf '\n%s\n' "$(t " 2. Não usa mais o que está ocupando? Desligue e rode este instalador de novo:")"
[ -n "$dono_portas" ] && printf '%s\n' "$(t " docker stop {1}" "$dono_portas")"
printf '\n%s\n' "$(t " 3. Quer manter os dois no ar? Aí o CRM tem de sair por um proxy só, e isso")"
printf '%s\n' "$(t " é configuração manual — o kit automatiza esse caminho apenas para")"
printf '%s\n\n' "$(t " Traefik (ponha REVERSE_PROXY=traefik no .env).")"
if [ "$n_ocupadas" -gt 1 ]; then
die "$(t "Libere as portas {1} (ou use a instalação que já existe) e rode de novo." "$portas_ocupadas")"
fi
die "$(t "Libere a porta {1} (ou use a instalação que já existe) e rode de novo." "$portas_ocupadas")"
;;
esac
fi
# Fica FORA do `case` porque quem põe REVERSE_PROXY=traefik no .env à mão — o
# caminho que o painel de bloqueio logo acima ENSINA — pula o `case` inteiro e
# chegava no bloco da rede com a variável vazia, para morrer em "Não consegui
# descobrir a rede Docker do seu Traefik". O instalador mandava fazer uma coisa
# que ele mesmo não sabia terminar.
if [ "${REVERSE_PROXY:-}" = "traefik" ] && [ -z "$traefik_container" ]; then
if [ -n "$dono_portas" ] && eh_traefik "$dono_imagem" "$dono_portas"; then
traefik_container="$dono_portas"
else
# Nem dono das portas nem modo host: o Traefik pode simplesmente estar
# parado agora (painel reiniciando, VPS recém-ligada). Procurá-lo entre
# TODOS os contêineres é o que resta — e continua fechado no plural, porque
# apontar para o Traefik errado publica o CRM num proxy que ninguém acessa.
_tk="$(docker ps --format '{{.Names}}|{{.Label "com.docker.compose.project"}}|{{.Image}}|{{.Ports}}' 2>/dev/null | unico_traefik || true)"
[ -n "$_tk" ] && traefik_container="${_tk%%|*}"
unset _tk
fi
fi
# ── Supabase automático (opcional) ──────────────────────────────────────────
# Criar o projeto no navegador e copiar 4 campos era o passo mais LENTO da
# instalação (medido: ~59min de preparação contra ~3min de script) e o mais
# fácil de errar — copiar a "Direct connection", que é IPv6-only e não conecta
# de um VPS IPv4, é a armadilha campeã.
#
# Com SUPABASE_ACCESS_TOKEN no ambiente e as credenciais ainda vazias, o
# projeto é criado aqui e as 4 variáveis entram direto no fluxo, sem copiar e
# colar. Sem o token, nada muda: seguem as perguntas de sempre.
if [ -z "${NEXT_PUBLIC_SUPABASE_URL:-}" ] && [ -n "${SUPABASE_ACCESS_TOKEN:-}" ]; then
step "$(t "Criando o projeto Supabase automaticamente")"
_sb_out="$(bash "$KIT_DIR/supabase-provision.sh" "${APP_NAME:-DeskcommCRM}" "${SUPABASE_REGION:-sa-east-1}")" \
|| die "$(t "Não consegui criar o projeto Supabase. Crie no painel e rode de novo sem SUPABASE_ACCESS_TOKEN.")"
# O script imprime `CHAVE='valor'` em stdout (o visual dele vai para stderr).
# A leitura é por parse, não por `eval` — o porquê está em
# sb_carrega_credenciais(), e `test-validators.sh` cobra isso.
sb_carrega_credenciais "$_sb_out"
unset _sb_out
# Credencial que não chegou tem que parar AQUI. Sem esta checagem o install
# seguiria com a variável vazia e morreria lá na frente, longe da causa — e a
# pessoa veria "erro de conexão" em vez de "o provisionamento não devolveu X".
if [ -z "${NEXT_PUBLIC_SUPABASE_URL:-}" ] || [ -z "${NEXT_PUBLIC_SUPABASE_ANON_KEY:-}" ] \
|| [ -z "${SUPABASE_SERVICE_ROLE_KEY:-}" ] || [ -z "${SUPABASE_DB_URL:-}" ]; then
die "$(t "O provisionamento não devolveu as 4 credenciais. Crie o projeto no painel e rode de novo sem SUPABASE_ACCESS_TOKEN.")"
fi
c_grn "$(t "✓ Supabase pronto — as 4 credenciais entraram sozinhas")"
fi
# Cada linha: VARIÁVEL|pergunta|padrão|validador|secret|opcional
# A ordem importa: a URL do projeto vem antes das chaves porque os validadores
# das chaves batem contra ela (chave de outro projeto é erro comum e mudo).
# O bloco final (APP_NAME, SUPPORT_EMAIL, RESEND_*) fica por último de
# propósito: é tudo opcional, e perguntar no meio das credenciais faria parecer
# obrigatório. Todas as quatro aceitam Enter — e, quando vazias, o produto
# degrada de forma declarada (marca padrão; tela de suspensão sem endereço;
# convite mostrando o link de aceite na própria tela).
# ── Qual IA vai atender ─────────────────────────────────────────────────────
#
# Antes daqui o instalador só sabia pedir a chave da Anthropic, e quem já tinha
# conta em outro provedor descobria isso tarde: instalava, cadastrava a chave
# "errada", e o agente não respondia. A OpenRouter mudou a conta dessa escolha —
# uma chave só dá acesso a centenas de modelos de dezenas de fabricantes, o que
# costuma ser o caminho mais simples para quem está começando.
#
# A pergunta vem ANTES das credenciais porque é ela que decide QUAL credencial
# será pedida; perguntar depois obrigaria a voltar atrás.
escolher_provedor() {
# Numa 2ª execução, o provedor já escolhido vira o default — quem re-roda o
# script para corrigir outra coisa não deve ter que reescolher isto.
local atual="${AI_PROVIDER:-}"
if [ -z "$atual" ]; then
if [ -n "${OPENROUTER_API_KEY:-}" ]; then atual="openrouter"
elif [ -n "${ANTHROPIC_API_KEY:-}" ]; then atual="anthropic"
elif [ -n "${OPENAI_API_KEY:-}" ]; then atual="openai"
fi
fi
if [ "$NONINTERACTIVE" = 1 ]; then
AI_PROVIDER="${atual:-anthropic}"
return 0
fi
printf '\n\033[1m%s\033[0m\n\n' "$(t "Qual inteligência artificial vai atender seus clientes?")"
printf ' %s\n' "$(t "[1] OpenRouter — uma chave, centenas de modelos de vários fabricantes.")"
printf ' %s\n' "$(t "O caminho mais simples para experimentar. (openrouter.ai/keys)")"
printf ' %s\n' "$(t "[2] Anthropic — o Claude. É o que melhor segue instruções longas e usa")"
printf ' %s\n' "$(t "as ferramentas do CRM. (console.anthropic.com)")"
printf ' %s\n' "$(t "[3] OpenAI — o GPT. (platform.openai.com/api-keys)")"
printf '\n'
printf ' %s\n\n' "$(t "Dá para trocar depois, e por parte do sistema, em Agente de IA → Provedores.")"
local padrao_num=2
case "$atual" in openrouter) padrao_num=1;; openai) padrao_num=3;; esac
while :; do
if ! read -r -p "$(t "Escolha (Enter = {1}): " "$padrao_num")" escolha; then escolha=""; fi
[ -z "$escolha" ] && escolha="$padrao_num"
case "$escolha" in
1) AI_PROVIDER="openrouter"; break;;
2) AI_PROVIDER="anthropic"; break;;
3) AI_PROVIDER="openai"; break;;
*) c_ylw "$(t "Digite 1, 2 ou 3.")";;
esac
done
}
escolher_provedor
# O campo da chave do provedor ESCOLHIDO — e só dele. Pedir as três faria a
# pessoa achar que precisa das três.
#
# O campo é `opcional` (issue #670). `docs/deploy-selfhost` promete que dá para
# "deixar vazio e cadastrar a chave depois", e o runtime concorda (`lib/env.ts`
# trata as três chaves como opcionais; faltar todas é `warn`, não erro) — mas o
# instalador exigia uma chave que PASSASSE numa chamada real, e não havia
# caminho para subir o produto sem antes abrir conta num provedor de IA. Quem
# pula instala, e o caminho de volta sai na tela final (`pendencia_da_ia`, no
# fecho). O validador continua valendo para quem digita uma chave — o que
# mudou é que pular deixou de ser erro.
case "$AI_PROVIDER" in
openrouter) CAMPO_IA="OPENROUTER_API_KEY|Chave da OpenRouter — a IA que atende (openrouter.ai/keys; Enter pula: dá para cadastrar depois pela tela, em IA › Credenciais)||v_openrouter|secret|opcional";;
openai) CAMPO_IA="OPENAI_API_KEY|Chave da OpenAI — a IA que atende (platform.openai.com/api-keys; Enter pula: dá para cadastrar depois pela tela, em IA › Credenciais)||v_openai|secret|opcional";;
*) CAMPO_IA="ANTHROPIC_API_KEY|Chave da Anthropic — a IA que atende (console.anthropic.com; Enter pula: dá para cadastrar depois pela tela, em IA › Credenciais)||v_anthropic|secret|opcional";;
esac
# A chave da OpenAI é pedida À PARTE quando ela NÃO é o provedor de conversa,
# porque ouvir áudio ainda depende dela (o Whisper é da OpenAI). A base de
# conhecimento também aceita a chave OpenRouter, com o mesmo modelo fixo. Sem esta linha, quem
# escolhe OpenRouter instala achando que está completo e descobre semanas depois
# que o agente nunca ouviu um áudio — que é exatamente o defeito já visto em
# produção, com a chave certa no .env e indo para o endpoint errado.
if [ "$AI_PROVIDER" = "openai" ]; then
CAMPO_OPENAI_EXTRA=""
else
CAMPO_OPENAI_EXTRA="OPENAI_API_KEY|Chave da OpenAI — para ouvir áudios; a base de conhecimento aceita OpenRouter (Enter pula: dá para cadastrar depois pela tela, em IA › Credenciais)||v_openai|secret|opcional"
fi
# ── A versão que esta instalação vai rodar ───────────────────────────────────
# Uma instalação nova nascia em `:latest`, e aqui `latest` NÃO quer dizer "a
# última release": ele segue a branch default, então ela
# segue o topo da `main` — código ainda não lançado. Quem instalava no dia 6
# e quem instalava no dia 20 rodavam software diferente, ambos dizendo "estou
# no latest", e o suporte não tinha como saber o quê. A issue #184 chegou
# descrevendo o ambiente como "latest do dia 06/08/2026", que é a admissão de
# que a versão não era nomeável.
#
# Resolvido no REMOTO porque o clone é `--depth 1` e não traz tag nenhuma.
VERSAO_ALVO="$(ultima_versao_publicada "$REPO_URL")"
# A tag do git é condição NECESSÁRIA, não suficiente: ela nasce minutos antes
# das imagens, e `deskcomm-worker`/`deskcomm-scheduler` só passaram a existir
# depois das releases que já estão publicadas — `deskcomm-worker:1.2.1` nunca
# vai existir, porque a v1.2.1 é passado. Sem esta conferência, o .env do
# cliente receberia duas referências impossíveis e o kit as construiria aqui em
# silêncio, do topo da main: app de uma release + worker de outro código.
#
# Cascata, do mais específico ao mais disponível. Cada nível pergunta pelas TRÊS
# imagens juntas, porque instalar com elas desalinhadas é o defeito, não a
# solução.
if [ -n "$VERSAO_ALVO" ] && trio_publicado "$VERSAO_ALVO"; then
: # o caminho normal: as três publicadas na última versão
elif trio_publicado "stable"; then
c_ylw "$(t "⚠ A versão {1} ainda não tem as três imagens publicadas." "${VERSAO_ALVO:-$(t "mais recente")}")"
c_ylw "$(t " Instalando pelo canal 'stable' (a última versão completa).")"
VERSAO_ALVO="stable"
elif [ -n "$VERSAO_ALVO" ]; then
# Nem a versão nem o `stable` têm o trio. Segue assim mesmo — o compose tem
# `build:` ao lado do `image:` do worker e do scheduler, então eles são
# construídos aqui. É lento, mas instala. O que NÃO pode é isso acontecer
# calado: o dono precisa saber que duas peças dele saíram do fonte local.
c_ylw "$(t "⚠ As imagens do worker e do agendador ainda não estão publicadas.")"
c_ylw "$(t " Elas serão construídas neste servidor — leva alguns minutos a mais.")"
c_ylw "$(t " Rode 'bash hostgator-setup-kit/update.sh' quando a próxima versão sair.")"
else
# Falha ABERTA: sem rede ou sem tag no remoto, segue como antes. Travar a
# instalação por não resolver um número seria trocar previsibilidade por
# disponibilidade — mas o aviso sai, porque o dono precisa saber que ficou
# num canal móvel em vez de numa versão.
VERSAO_ALVO="latest"
c_ylw "$(t "⚠ Não consegui descobrir a última versão publicada (rede?).")"
c_ylw "$(t " Instalando pelo canal 'latest'. Depois rode: bash hostgator-setup-kit/update.sh")"
fi
IMAGEM_APP_DEFAULT="${IMG_APP}:${VERSAO_ALVO}"
FIELDS=(
"DOMAIN|Domínio do CRM (ex: crm.suaempresa.com.br)||v_domain||"
"ACME_EMAIL|Seu e-mail (avisos de SSL)||v_email||"
"APP_IMAGE|Imagem Docker do app|${IMAGEM_APP_DEFAULT}|||"
"NEXT_PUBLIC_SUPABASE_URL|Supabase Project URL (Settings > API)||v_supabase_url||"
"NEXT_PUBLIC_SUPABASE_ANON_KEY|Supabase anon key (Settings > API)||v_anon||"
"SUPABASE_SERVICE_ROLE_KEY|Supabase service_role key (Settings > API)||v_service|secret|"
"SUPABASE_DB_URL|Supabase connection string — Session pooler, modo URI (Settings > Database)||v_db_url|secret|"
# Token de conta, e por isso NÃO vai para o `.env` (não há `envq` para ele):
# a Management API que ele abre cria e apaga projetos, e guardá-lo numa VPS
# seria trocar um bug de primeira impressão por um passivo de segurança. Ele
# é usado uma vez, aqui, e some com o processo.
#
# Sem ele, o Site URL do projeto Supabase fica em `localhost:3000` — e o reset
# de senha, a confirmação de e-mail e o aceite de convite chegam com link para
# uma máquina que não existe fora do laptop de quem desenvolve. Era o estado
# de TODA instalação feita pelo caminho documentado. (issue #431/#426)
"SUPABASE_ACCESS_TOKEN|Token de acesso do Supabase — configura os links de e-mail (supabase.com/dashboard/account/tokens). NÃO fica salvo. Enter pula|||secret|opcional"
"$CAMPO_IA"
${CAMPO_OPENAI_EXTRA:+"$CAMPO_OPENAI_EXTRA"}
"OWNER_EMAIL|E-mail do primeiro admin (dono)||v_email||"
"OWNER_PASSWORD|Senha do primeiro admin (mínimo 8 caracteres)||v_password|secret|"
"APP_NAME|Nome que aparece na interface (Enter para o padrão)|DeskcommCRM|||"
# Idioma da instalação. Fica JUNTO do nome do produto de propósito: as duas
# perguntas são "como o sistema se apresenta", e separá-las faria a segunda
# parecer configuração técnica.
"APP_LOCALE|Idioma do sistema — 1) Português 2) Español (Enter = Português)|1|v_locale||"
# Sem default, e `opcional`: em `--yes` o `ask_one` devolve 0 sem associar a
# variável (campo sem default e sem `opcional` morre em `die`), e o `envq` lá
# embaixo usa `${APP_ACCENT_HEX:-}`. Enter = a cor do produto, que é o
# comportamento de sempre para quem não tem marca própria.
"APP_ACCENT_HEX|Cor da sua marca em hex, ex.: #7a5cd6 (Enter usa a cor do sistema)||v_hex||opcional"
"SUPPORT_EMAIL|E-mail de suporte que SEUS clientes veem (Enter pula)||v_email||opcional"
"RESEND_API_KEY|Chave da Resend — envia convite e e-mail de LGPD (resend.com/api-keys, Enter pula)|||secret|opcional"
"RESEND_FROM_EMAIL|Remetente dos e-mails, de um domínio verificado na Resend (Enter pula)||v_email||opcional"
)
# Um único ponto para traduzir o PROMPT: os campos de FIELDS[] vêm separados
# por '|', e tocar em cada linha do array quebraria esse separador na hora de
# escrever a tradução (vírgula ou barra dentro do texto en español). Passar o
# F_PROMPT por t() aqui cobre as ~15 perguntas com uma entrada por prompt na
# tabela, em vez de reescrever a sintaxe do array.
field_at() { IFS='|' read -r F_VAR F_PROMPT F_DEF F_VAL F_SEC F_OPT <<< "${FIELDS[$1]}"; F_PROMPT="$(t "$F_PROMPT")"; }
if [ "$NONINTERACTIVE" = 0 ]; then
c_dim "$(t "Dica: em qualquer pergunta, digite 'voltar' para refazer a anterior.")"
if [ "$AI_PROVIDER" != "openai" ]; then
c_ylw "$(t "A chave da OpenAI é opcional, mas sem ela a IA não ouve áudio nem consulta a base de conhecimento.")"
fi
fi
i=0
while [ "$i" -lt "${#FIELDS[@]}" ]; do
field_at "$i"
set +e; ask_one "$F_VAR" "$F_PROMPT" "$F_DEF" "$F_VAL" "$F_SEC" "$F_OPT"; rc=$?; set -e
if [ "$rc" = "2" ]; then
if [ "$i" -eq 0 ]; then c_ylw "$(t " Essa já é a primeira pergunta.")"; continue; fi
i=$((i-1)); field_at "$i"; unset "$F_VAR" # limpa o anterior para ele ser perguntado de novo
else
i=$((i+1))
fi
done
# ── Conferência: a última chance de corrigir sem desfazer nada ──────────────
# Numa 2ª execução todos os campos já vêm do .env — então esta tela é também
# o caminho para consertar um valor digitado errado antes, que antes ficava
# preso no .env sem nenhuma forma de trocar pelo instalador.
if [ "$NONINTERACTIVE" = 0 ]; then
while :; do
printf '\n\033[1m%s\033[0m\n\n' "$(t "Confira antes de eu escrever a configuração:")"
n=1
for f in "${FIELDS[@]}"; do
IFS='|' read -r v p _d _val sec _o <<< "$f"
if [ "$sec" = "secret" ]; then printf ' [%2d] %-28s %s\n' "$n" "${v}" "$(mask "${!v:-}")"
else printf ' [%2d] %-28s %s\n' "$n" "${v}" "${!v:-$(t "(vazio)")}"; fi
n=$((n+1))
done
printf '\n'
if ! read -r -p "$(t "Está tudo certo? (Enter = continuar / número = corrigir): ")" answer; then answer=""; fi
[ -z "$answer" ] && break
case "$answer" in
''|*[!0-9]*) c_ylw "$(t "Digite o número do item que quer corrigir, ou Enter para continuar.")"; continue;;
esac
if [ "$answer" -lt 1 ] || [ "$answer" -gt "${#FIELDS[@]}" ]; then
c_ylw "$(t "Número fora da lista.")"; continue
fi
field_at "$((answer-1))"; unset "$F_VAR"
set +e; ask_one "$F_VAR" "$F_PROMPT" "$F_DEF" "$F_VAL" "$F_SEC" "$F_OPT"; set -e
done
else
# Sem tela para conferir: os validadores continuam sendo a rede de proteção.
for f in "${FIELDS[@]}"; do
IFS='|' read -r v _p _d val _sec opt <<< "$f"
[ -z "$val" ] && continue
[ -z "${!v:-}" ] && { [ -n "$opt" ] && continue; die "$(t "Falta {1} (modo --yes exige .env preenchido)." "$v")"; }
if ! msg="$("$val" "${!v}" 2>&1)"; then
c_red "$(t "✖ {1} inválido:" "$v")"; printf '%s\n' "$msg"
die "$(t "Corrija o .env e rode de novo.")"
fi
done
fi
# Derivados
NEXT_PUBLIC_APP_URL="https://${DOMAIN}"
NEXT_PUBLIC_ADMIN_URL="https://${DOMAIN}"
# ── 4. Geração de segredos (idempotente: só gera o que falta) ────────────────
step "$(t "Gerando segredos")"
gen_hex() { openssl rand -hex 32; }
gen_b64() { openssl rand -base64 32; }
: "${INTERNAL_SECRET:=$(gen_hex)}"
: "${INTERNAL_CRON_SECRET:=$(gen_hex)}"
: "${NUVEMSHOP_OAUTH_ENCRYPTION_KEY:=$(gen_hex)}"
: "${CPF_ENCRYPTION_KEY:=$(gen_b64)}"
: "${AI_CRED_AES_KEY:=$(gen_b64)}"
: "${WAHA_BYO_ENCRYPTION_KEY:=$(gen_b64)}"
: "${IMPERSONATE_COOKIE_SECRET:=$(gen_hex)}"
: "${LGPD_SIGNING_KEY:=$(gen_hex)}"
: "${WAHA_HMAC_SECRET:=$(gen_hex)}"
: "${SRH_TOKEN:=$(gen_hex)}"
: "${WAHA_API_KEY:=$(gen_hex)}"
# Chamada de voz (spec 18). Gerados SEMPRE, mesmo com a feature desligada: o
# serviço não sobe sem admin, e pedir ao dono que invente três segredos no dia
# em que ele quiser ligar a voz é o "edite o .env à mão" que a doutrina de
# packaging proíbe. Gerar não liga nada — quem liga é COMPOSE_PROFILES.
: "${WACALLS_ADMIN_USER:=deskcomm}"
: "${WACALLS_ADMIN_PASSWORD:=$(gen_hex)}"
: "${WACALLS_API_TOKEN:=$(gen_hex)}"
# O container WAHA espera o HASH SHA512 hex; o app envia o plaintext no X-Api-Key.
WAHA_API_KEY_SHA512="$(printf '%s' "$WAHA_API_KEY" | openssl dgst -sha512 -hex | awk '{print $NF}')"
UPSTASH_REDIS_REST_TOKEN="$SRH_TOKEN"
c_grn "$(t "✓ segredos prontos")"
# ── 5. Escreve .env (600) ───────────────────────────────────────────────────
# Onde o Traefik encontra o app. Os dois cenários e as duas medições que os
# separam estão em `rede_do_traefik`; aqui só se busca no Docker o que ela pede.
#
# A bridge reservada a este projeto sai de `rede_reservada_do_proxy` (_common.sh),
# que usa o mesmo nome que o compose calcula (NormalizeProjectName: minúsculas, só
# [a-z0-9_-], `_`/`-` iniciais aparados). Um `basename` cru diverge numa pasta com
# maiúscula, ponto ou underscore inicial — e aí o instalador cria uma rede e o
# compose procura outra.
rede_do_projeto="$(rede_reservada_do_proxy)"
if [ "$REVERSE_PROXY" = "traefik" ] && [ -z "${TRAEFIK_NETWORK:-}" ] && [ -n "$traefik_container" ]; then
# "|| true" nas duas: sem ele o `die` explicativo logo abaixo — que é o
# tratamento CERTO deste caso — é inalcançável. Numa atribuição o status do
# pipeline vira o status do script; se o painel da hospedagem recriou o proxy
# entre a detecção e aqui, o docker inspect sai 1, o 2>/dev/null engole a
# mensagem e o instalador cai no painel genérico de erro sem dizer o que houve.
traefik_netmode="$(docker inspect -f '{{.HostConfig.NetworkMode}}' "$traefik_container" 2>/dev/null || true)"
traefik_redes="$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$traefik_container" 2>/dev/null || true)"
TRAEFIK_NETWORK="$(rede_do_traefik "$traefik_netmode" "$traefik_redes" "$rede_do_projeto")"
[ "$traefik_netmode" = "host" ] && \
c_dim "$(t " (o Traefik roda em modo host, então o CRM publica numa rede própria: {1})" "$TRAEFIK_NETWORK")"
fi
if [ "$REVERSE_PROXY" = "traefik" ] && [ -z "${TRAEFIK_NETWORK:-}" ]; then
die "$(t "Não consegui descobrir a rede Docker do seu Traefik. Rode 'docker network ls',
identifique a rede dele e ponha TRAEFIK_NETWORK=<nome> no .env antes de tentar de novo.")"
fi
# Os nomes dos entrypoints saem do MESMO contêiner que já respondeu pela rede.
# Só entra onde o .env está vazio: quem declarou o nome à mão manda mais que a
# leitura — é a mesma regra que TRAEFIK_NETWORK segue logo acima.
if [ "$REVERSE_PROXY" = "traefik" ] && [ -n "$traefik_container" ] \
&& { [ -z "${TRAEFIK_ENTRYPOINT:-}" ] || [ -z "${TRAEFIK_ENTRYPOINT_HTTP:-}" ]; }; then
traefik_conf="$(docker inspect \
-f '{{range .Config.Env}}{{println .}}{{end}}{{range .Args}}{{println .}}{{end}}' \
"$traefik_container" 2>/dev/null || true)"
entrypoints_achados="$(entrypoints_do_traefik "$traefik_conf")"
if [ -z "${TRAEFIK_ENTRYPOINT_HTTP:-}" ] && [ -n "${entrypoints_achados%% *}" ]; then
TRAEFIK_ENTRYPOINT_HTTP="${entrypoints_achados%% *}"
fi
if [ -z "${TRAEFIK_ENTRYPOINT:-}" ] && [ -n "${entrypoints_achados##* }" ]; then
TRAEFIK_ENTRYPOINT="${entrypoints_achados##* }"
fi
if [ -n "${TRAEFIK_ENTRYPOINT:-}" ]; then
c_dim "$(t " (entrypoints do seu Traefik: {1} para HTTP, {2} para HTTPS)" "${TRAEFIK_ENTRYPOINT_HTTP:-web}" "$TRAEFIK_ENTRYPOINT")"
fi
fi
# Confere (e cria, quando a rede é a nossa) — em _common.sh, porque o update.sh
# precisa da mesma garantia antes do `dc up -d` dele. Também aplica o default
# 'traefik', então a variável está pronta para o .env logo abaixo.
garantir_rede_do_proxy
# ── Telemetria: perguntar, não presumir ─────────────────────────────────────
# Issue #100. Antes, quem não definisse SENTRY_DSN mandava relatório de erro pro
# Sentry da comunidade sem ter decidido nada — e só ficava sabendo na mensagem
# final, DEPOIS de instalado. Num produto que roda na infraestrutura do usuário,
# com dados de clientes dele, o consentimento vem antes.
# Quem já tem valor no .env manda: a pergunta não sobrescreve escolha anterior.
if [ -z "${SENTRY_DSN+x}" ]; then
if [ "$NONINTERACTIVE" = 1 ]; then
# Automação não consente por ninguém. Sem valor explícito, fica desligado.
SENTRY_DSN="off"
else
step "$(t "Telemetria de erros (opcional)")"
printf '%s\n' "$(t "Podemos receber os relatórios de ERRO desta instalação (stack trace) para")"
printf '%s\n' "$(t "corrigir bugs que afetam todo mundo. CPF, telefone e e-mail são substituídos,")"
printf '%s\n' "$(t "cabeçalhos sensíveis removidos e tokens de webhook/convite redigidos da URL.")"
printf '%s\n' "$(t "NÃO enviamos rastreamento de performance nem replay de sessão.")"
printf '%s\n' "$(t "Seus dados de clientes, conversas e banco NUNCA saem daqui.")"
printf '\n%s\n' "$(t "Você pode mudar depois no .env, a qualquer momento.")"
read -r -p "$(t " Enviar relatórios de erro anonimizados? (s/N) ")" _tel
if resposta_sim "${_tel:-}"; then
SENTRY_DSN=""
c_grn "$(t "✓ Telemetria de erros ligada — obrigado, isso ajuda o projeto.")"
else
SENTRY_DSN="off"
c_grn "$(t "✓ Telemetria desligada — nada será enviado.")"
fi
fi
fi
step "$(t "Escrevendo .env")"
umask 077
# Todo valor sai pelo `envq` (definido lá em cima, junto do save_partial): entre
# aspas DUPLAS, com `\`, `"`, `$` e crase escapados. Sem isso, um
# `APP_NAME=Loja do João` (ou uma senha com # ou $) quebrava tudo que lê este
# arquivo com `source` — os scripts do kit e a receita do próprio README
# (`source .env && curl ...`) —, e a versão de aspas simples que veio antes
# quebrava o terceiro leitor, o `env_file: .env` do Compose, em todo nome com
# apóstrofo. O porquê de cada caractere escapado está no comentário do envq.
# ── Preserva o que o instalador NÃO conhece ────────────────────────────────
#
# O bloco abaixo fecha com `} > .env`, que é TRUNCANTE: ele reescreve o arquivo
# inteiro a partir de uma lista fechada de chaves. Quem acrescentou qualquer
# variável à mão — uma chave de provedor de IA que o kit ainda não pergunta, um
# knob de modelo, um endpoint próprio — PERDIA tudo ao re-rodar o script. E o
# README vende o install.sh como idempotente, então re-rodar é exatamente o que
# se espera de quem quer corrigir um dado.
#
# O sintoma é dos piores: a instalação continua subindo, sem erro nenhum, e só
# depois alguém descobre que o agente voltou ao provedor padrão.
#
# Aqui as chaves desconhecidas são lidas do .env atual e reemitidas no fim do
# arquivo novo. Comparação por NOME da variável, contra a lista que este script
# escreve — assim uma chave nova que o kit passe a conhecer deixa de ser
# "alheia" sozinha, sem ninguém precisar manter uma segunda lista.
PRESERVADAS=""
if [ -f .env ]; then
# ── Antes de tudo: recarrega o .env atual no ambiente ────────────────────
#
# Este é o furo MAIOR, e ele é invisível: várias chaves da lista fechada são
# escritas como `envq X "${X:-}"` — o valor da variável de SHELL. Numa
# re-execução o shell não tem essas variáveis (o instalador só as coleta no
# fluxo interativo, e chave opcional não é perguntada), então elas são
# reescritas VAZIAS. Ou seja: a chave da OpenRouter que a pessoa configurou à
# mão é APAGADA por uma linha que parecia estar só repassando o valor.
#
# NÃO recarregue o .env aqui. Havia um `set -a; . ./.env; set +a` neste ponto,
# justificado por "carregar o .env atual primeiro faz `${X:-}` cair de volta no
# valor que já existia" — e essa premissa é FALSA: `install.sh:757` já faz
# `load_env .env` antes da entrevista, e `_common.sh:266` faz `printf -v` +
# `export` incondicionalmente. O arquivo já está carregado e exportado aqui.
#
# O que a releitura fazia de fato era DESFAZER a entrevista: a pessoa corrigia
# um valor errado na tela e o `.` sobrescrevia com o antigo do disco. Medido na
# triagem; a preservação de variável alheia (o laço abaixo) continua
# funcionando sem estas linhas.
# `$KIT_DIR/install.sh`, não `"$0"`: este bloco roda DEPOIS do `cd` da linha
# 662, e com `$0` relativo (o `bash install.sh` que o README:34 documenta) o
# grep procura o arquivo no diretório errado e morre com "No such file or
# directory" — matando a 2ª execução, que o README:126/:138 documentam como
# suportada. `KIT_DIR` (linha 16) é absoluto e imune ao `cd`.
CONHECIDAS="$(grep -oE "^\s*envq [A-Z_][A-Z0-9_]*" "$KIT_DIR/install.sh" | awk '{print $2}' | sort -u)"
while IFS= read -r linha; do
case "$linha" in
''|'#'*) continue;;
esac
nome="${linha%%=*}"
case "$nome" in
*[!A-Za-z0-9_]*|'') continue;;
esac
if ! printf '%s\n' "$CONHECIDAS" | grep -qx "$nome"; then
PRESERVADAS="${PRESERVADAS}${linha}
"
fi
done < .env
if [ -n "$PRESERVADAS" ]; then
c_ylw "$(t "→ preservando {1} variável(is) que você acrescentou à mão" "$(printf '%s' "$PRESERVADAS" | grep -c .)")"
fi
fi
# A tag que o dono escolheu (o campo APP_IMAGE é editável na entrevista) decide
# o pull_policy das três imagens. A regra é medida, não estética: 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 — só amarra a subida do CRM à disponibilidade do GHCR. Numa tag móvel
# é o contrário: sem `always`, a versão nova nunca chega.
# Olha só o último segmento do caminho: `registry.local:5000/x/y` tem ':' e NÃO
# tem tag, e um `${APP_IMAGE##*:}` ingênuo devolveria "5000/x/y" como se fosse
# uma. Um `@sha256:...` cai aqui como tag imutável, que é o correto.
_ref_final="${APP_IMAGE##*/}"
case "$_ref_final" in
*@sha256:*)
# O operador pinou o app por DIGEST. Derivar a tag daí produziria
# `deskcomm-worker:<hash-do-app>` — uma referência que não existe em lugar
# nenhum, e o `pull` falharia com "manifest unknown" sem ninguém entender
# por quê. Worker e scheduler vão para o canal estável, e o aviso sai porque
# quem pinou por digest tinha um motivo e precisa saber que ele não se
# propagou às outras duas.
TAG_ALVO="stable"
c_ylw "$(t "⚠ APP_IMAGE está pinado por digest.")"
c_ylw "$(t " O worker e o scheduler ficam em 'stable' — ajuste WORKER_IMAGE/SCHEDULER_IMAGE")"
c_ylw "$(t " no .env se você precisa deles num digest específico também.")"
;;
*:*) TAG_ALVO="${_ref_final##*:}" ;;
*) TAG_ALVO="latest" ;; # imagem sem ':' é :latest por definição do Docker
esac
case "$TAG_ALVO" in
latest|main|stable) PULL_POLICY_ALVO="always" ;;
*) PULL_POLICY_ALVO="missing" ;;
esac
{
printf '# Gerado por install.sh — NÃO comitar. Contém segredos.\n'
envq APP_IMAGE "$APP_IMAGE"
envq APP_PULL_POLICY "$PULL_POLICY_ALVO"
# Worker e scheduler acompanham a MESMA versão do app: um em 1.2.1 e outro em
# `latest` é uma matriz de compatibilidade que ninguém testou. Estas duas
# imagens existem desde que o worker deixou de ser `build:`-only — antes disso
# ele era compilado aqui na VPS e nenhum update jamais o alcançava.
envq WORKER_IMAGE "${IMG_WORKER}:${TAG_ALVO}"
envq WORKER_PULL_POLICY "$PULL_POLICY_ALVO"
envq SCHEDULER_IMAGE "${IMG_SCHEDULER}:${TAG_ALVO}"
envq SCHEDULER_PULL_POLICY "$PULL_POLICY_ALVO"
# A telefonia por SIP (profile `telefonia`, desligado por padrão) também segue
# a versão: gravar não liga nada, e no dia em que ligarem ela sobe casada.
envq VOICE_AGENT_IMAGE "${IMG_VOICE_AGENT}:${TAG_ALVO}"
envq VOICE_AGENT_PULL_POLICY "$PULL_POLICY_ALVO"
envq DOMAIN "$DOMAIN"
envq ACME_EMAIL "$ACME_EMAIL"
printf '# Proxy reverso: "caddy" (o kit sobe o dele nas portas 80/443), "traefik"\n'
printf '# (o VPS já tem um Traefik nessas portas — Hostinger, Coolify, Dokploy...)\n'
printf '# ou "npm" (Nginx Proxy Manager, que não lê labels — ver o cabeçalho de\n'
printf '# docker-compose.npm.yml). Em "traefik" entra o docker-compose.traefik.yml,\n'
printf '# que desliga o Caddy e publica o app por labels (TRAEFIK_* só é lido nesse\n'
printf '# modo). Em "npm" entra o docker-compose.npm.yml, que também desliga o Caddy\n'
printf '# e fixa o app na rede/IP que o Proxy Host espera (PROXY_NETWORK_* só é lido\n'
printf '# nesse modo, e é sempre configuração manual — não há como detectar o NPM\n'
printf '# sozinho, ao contrário do Traefik).\n'
envq REVERSE_PROXY "$REVERSE_PROXY"
# O default mora aqui, junto dos irmãos TRAEFIK_* logo abaixo, e não numa
# atribuição solta lá atrás: em modo caddy ninguém DECIDE esta variável, e
# depender de uma linha distante para ela existir é o tipo de laço que um
# refactor do bloco de proxy corta sem perceber. Com `set -u` o preço é a VPS
# limpa — a instalação mais comum de todas — parar aqui e deixar o .env pela
# metade, com o bloco do Traefik verde em todos os testes.
envq TRAEFIK_NETWORK "${TRAEFIK_NETWORK:-traefik}"
envq TRAEFIK_ENTRYPOINT_HTTP "${TRAEFIK_ENTRYPOINT_HTTP:-web}"
envq TRAEFIK_ENTRYPOINT "${TRAEFIK_ENTRYPOINT:-websecure}"
envq TRAEFIK_CERTRESOLVER "${TRAEFIK_CERTRESOLVER:-letsencrypt}"
envq NEXT_PUBLIC_SUPABASE_URL "$NEXT_PUBLIC_SUPABASE_URL"
envq NEXT_PUBLIC_SUPABASE_ANON_KEY "$NEXT_PUBLIC_SUPABASE_ANON_KEY"
envq SUPABASE_SERVICE_ROLE_KEY "$SUPABASE_SERVICE_ROLE_KEY"
envq SUPABASE_DB_URL "$SUPABASE_DB_URL"
# Modo single-server (install-single-server.sh). O .env é reescrito com
# truncamento: sem estas linhas, o update seguinte perderia o override do
# compose e o Caddy voltaria ao Caddyfile sem o Supabase. Vazio/0 = modo comum.
envq SINGLE_SERVER "${SINGLE_SERVER:-0}"
envq SINGLE_SERVER_NETWORK "${SINGLE_SERVER_NETWORK:-}"
envq PSQL_DOCKER_NETWORK "${PSQL_DOCKER_NETWORK:-}"
envq SUPABASE_INTERNAL_URL "${SUPABASE_INTERNAL_URL:-}"
envq NEXT_PUBLIC_APP_URL "$NEXT_PUBLIC_APP_URL"
envq NEXT_PUBLIC_ADMIN_URL "$NEXT_PUBLIC_ADMIN_URL"
printf '# Marca da instalação (white-label). Preencha APP_LOGO_URL com a URL de uma\n'
printf '# imagem pública para trocar o texto por logo na sidebar. Ver lib/branding.ts.\n'
printf '# APP_ACCENT_HEX é a SEMENTE da cor: o banco (platform_branding) manda depois\n'
printf '# da primeira leitura. Nos e-mails de acesso depende da topologia: na NUVEM do\n'
printf '# Supabase quem empurra é o marca-emails.sh, lendo daqui, e o banco não alcança;\n'
printf '# num Supabase PRÓPRIO o GoTrue busca /email-templates/ do app, que resolve a\n'
printf '# marca pelo banco — e aí trocar em Configurações > Marca chega ao e-mail.\n'
# Normaliza a escolha do idioma ANTES de gravar: o campo aceita "1"/"2"
# porque é o que se digita lendo um menu numerado, mas quem lê o `.env` — o
# bootstrap, o SQL abaixo, um operador conferindo — precisa do código.
case "${APP_LOCALE:-}" in
2|es) APP_LOCALE="es";;
*) APP_LOCALE="pt-BR";;
esac
envq APP_NAME "$APP_NAME"
envq APP_LOCALE "$APP_LOCALE"
# Idioma da CLI (t()/_i18n.sh) — distinto de APP_LOCALE, que é o idioma da
# aplicação web para os CLIENTES da empresa. Esta é a escolha que
# perguntar_idioma_cli() fez no início deste script. Grava para persistir:
# na próxima execução, perguntar_idioma_cli() lê esta linha deste .env e não
# pergunta de novo (nem com --yes). Só install.sh a ativa; update.sh, backup.sh
# e os demais não chamam perguntar_idioma_cli, então seguem em pt-BR mesmo com
# esta linha no .env.
envq DESKCOMM_IDIOMA_CLI "$IDIOMA_CLI"
envq APP_LOGO_URL "${APP_LOGO_URL:-}"
# Perguntar sem gravar seria PIOR que não perguntar: este bloco fecha com
# `} > .env`, que TRUNCA o arquivo a partir da lista fechada de `envq` acima e
# abaixo — a pessoa responderia a cor e a perderia em silêncio, na mesma
# execução. Enquanto a chave esteve fora desta lista, ela também não entrava em
# `CONHECIDAS` (o grep de `envq` logo acima), então uma cor posta à mão no .env
# sobrevivia por acaso, pelo laço de preservação — e não é de acaso que a
# entrevista precisa.
envq APP_ACCENT_HEX "${APP_ACCENT_HEX:-}"
printf '# Endereço de suporte que o CLIENTE FINAL vê (conta suspensa, cobrança).\n'
printf '# Vazio = a tela não mostra endereço nenhum.\n'
envq SUPPORT_EMAIL "${SUPPORT_EMAIL:-}"
# AGENDA · GOOGLE CALENDAR — gravadas VAZIAS, e de propósito NÃO perguntadas.
#
# Sem as duas a Agenda funciona inteira: some o botão "Conectar Google" e a
# tela explica o que falta — inclusive o endereço de retorno a registrar no
# console, pronto para copiar. Quem quiser ligar preenche no `.env` depois.
#
# ⚠️ Não viram pergunta na entrevista porque o instalador é a PRIMEIRA
# impressão do produto: duas perguntas a mais, sobre um recurso opcional que a
# maioria não usa, custam a todo mundo para servir a poucos. Elas existem aqui
# para que quem PREENCHER à mão não perca o valor no próximo `install.sh` —
# que é exatamente o que o gate `test-validators.sh` cobra.
envq GOOGLE_CALENDAR_CLIENT_ID "${GOOGLE_CALENDAR_CLIENT_ID:-}"
envq GOOGLE_CALENDAR_CLIENT_SECRET "${GOOGLE_CALENDAR_CLIENT_SECRET:-}"
# As três acima e as duas abaixo entram aqui pelo MESMO motivo, e não por
# simetria: o .env é escrito com truncamento (`} > .env`, no fecho deste
# bloco), então chave que este script não grava é APAGADA na execução
# seguinte. Quem pôs a chave da Resend à mão a perdia no primeiro update —
# num script que o README vende como idempotente.
printf '# E-mail transacional. RESEND_FROM_EMAIL tem de ser de um domínio\n'
printf '# VERIFICADO na SUA conta Resend. Vazio = e-mail desligado: o convite\n'
printf '# mostra o link de aceite na tela e o export de LGPD fica pendente.\n'
envq RESEND_API_KEY "${RESEND_API_KEY:-}"
envq RESEND_FROM_EMAIL "${RESEND_FROM_EMAIL:-}"
# SMTP: a alternativa à Resend. Gravado pelo mesmo motivo das duas acima — o
# `.env` é truncado, e o SMTP posto à mão sumiria na próxima execução. A tela
# /admin/email grava no banco, que prevalece; isto é o piso de rollback.
# Host ou remetente vazio mantém o envio desligado sem falhar.
printf '# E-mail pelo SEU servidor (SMTP). Preenchido, sai por ele; vazio, segue
'
printf '# pela Resend. Só o hostname. 465 + tls, ou 587 + starttls.
'
envq SMTP_HOST "${SMTP_HOST:-}"
envq SMTP_PORT "${SMTP_PORT:-587}"
envq SMTP_SECURITY "${SMTP_SECURITY:-starttls}"
envq SMTP_USERNAME "${SMTP_USERNAME:-}"
envq SMTP_PASSWORD "${SMTP_PASSWORD:-}"
envq SMTP_FROM_EMAIL "${SMTP_FROM_EMAIL:-}"
envq SMTP_FROM_NAME "${SMTP_FROM_NAME:-}"
printf '# Qual provedor você escolheu na instalação. É o que faz a 2ª execução do\n'
printf '# install.sh já vir com a sua escolha como padrão, em vez de re-adivinhar\n'
printf '# pelas chaves presentes. A app não lê esta variável.\n'
envq AI_PROVIDER "${AI_PROVIDER:-anthropic}"
# `${ANTHROPIC_API_KEY:-}`, não `$ANTHROPIC_API_KEY`: quem escolhe OpenRouter
# ou OpenAI nunca passa pelo campo da Anthropic, e sob `set -u` (linha 12) a
# variável não associada derrubava o script AQUI — no meio da escrita do
# .env, deixando o arquivo pela metade e a instalação sem como continuar.
envq ANTHROPIC_API_KEY "${ANTHROPIC_API_KEY:-}"
envq AI_GATEWAY_API_KEY "${AI_GATEWAY_API_KEY:-}"
printf '# OpenRouter: alternativa ao AI Gateway para o chat da IA. A ordem de\n'
printf '# resolução é AI_GATEWAY_API_KEY > OPENROUTER_API_KEY > provider direto,\n'
printf '# então deixar vazio NÃO muda nada — o comportamento de hoje continua.\n'
printf '# BASE_URL vazia = https://openrouter.ai/api/v1 (só mude se usa proxy).\n'
envq OPENROUTER_API_KEY "${OPENROUTER_API_KEY:-}"
envq OPENROUTER_BASE_URL "${OPENROUTER_BASE_URL:-}"
printf '# OpenAI: transcrição dos áudios do WhatsApp (Whisper) + embeddings do RAG.\n'
printf '# Opcional — sem ela a IA responde sem a base e pede o áudio em texto.\n'
envq OPENAI_API_KEY "${OPENAI_API_KEY:-}"
printf '# Web Push: aviso na bandeja do sistema com a aba do CRM fechada.\n'
printf '# Opcional e VAZIO por padrão — sem o par, os avisos aparecem só com o\n'
printf '# site aberto, que é exatamente o que acontecia antes. Para ligar:\n'
printf '# npx web-push generate-vapid-keys\n'
printf '# e cole as duas chaves aqui (depois: docker compose up -d app).\n'
envq VAPID_PUBLIC_KEY "${VAPID_PUBLIC_KEY:-}"
envq VAPID_PRIVATE_KEY "${VAPID_PRIVATE_KEY:-}"
printf '# Provisionamento por sistema externo (POST /api/v1/tenants/provision):\n'
printf '# um sistema de fora cria empresas nesta instalação. DESLIGADO — vazio, a\n'
printf '# rota responde 404. Para ligar: openssl rand -hex 32, cole aqui e entregue\n'
printf '# só ao sistema que vai criar empresas (depois: docker compose up -d app).\n'
envq TENANT_PROVISIONING_SECRET "${TENANT_PROVISIONING_SECRET:-}"
printf '# Telemetria de erros (você escolheu isto durante a instalação).\n'
printf '# "off" = não envia nada.\n'
printf '# vazio = só ERRO pro Sentry da comunidade, com CPF/telefone/e-mail\n'
printf '# substituídos e token de URL redigido. Sem trace, sem replay.\n'
printf '# <dsn> = manda pro SEU Sentry (aí com performance e replay).\n'
envq SENTRY_DSN "${SENTRY_DSN:-}"
envq INTERNAL_SECRET "$INTERNAL_SECRET"
envq INTERNAL_CRON_SECRET "$INTERNAL_CRON_SECRET"
envq NUVEMSHOP_OAUTH_ENCRYPTION_KEY "$NUVEMSHOP_OAUTH_ENCRYPTION_KEY"
envq CPF_ENCRYPTION_KEY "$CPF_ENCRYPTION_KEY"
envq AI_CRED_AES_KEY "$AI_CRED_AES_KEY"
envq WAHA_BYO_ENCRYPTION_KEY "$WAHA_BYO_ENCRYPTION_KEY"
envq IMPERSONATE_COOKIE_SECRET "$IMPERSONATE_COOKIE_SECRET"
envq LGPD_SIGNING_KEY "$LGPD_SIGNING_KEY"
envq WAHA_API_BASE_URL "http://waha:3000"
envq WAHA_WEBHOOK_BASE_URL "http://app:3000"
envq WAHA_API_KEY "$WAHA_API_KEY"
envq WAHA_API_KEY_SHA512 "$WAHA_API_KEY_SHA512"
envq WAHA_HMAC_SECRET "$WAHA_HMAC_SECRET"
printf '# Chamada de voz WhatsApp (spec 18) — DESLIGADA. Ligá-la vincula um SEGUNDO\n'
printf '# aparelho ao mesmo número que já atende, por um caminho que não é o oficial:\n'
printf '# o risco é a CONTA ser bloqueada. Para ligar: COMPOSE_PROFILES=voz e\n'
printf '# WACALLS_API_BASE_URL=http://wacalls:8080, depois ./update.sh e, na tela,\n'
printf '# Configurações › Segurança. Vazio = o serviço nem é criado.\n'
envq COMPOSE_PROFILES "${COMPOSE_PROFILES:-}"
envq WACALLS_API_BASE_URL "${WACALLS_API_BASE_URL:-}"
envq WACALLS_ADMIN_USER "$WACALLS_ADMIN_USER"
envq WACALLS_ADMIN_PASSWORD "$WACALLS_ADMIN_PASSWORD"
envq WACALLS_API_TOKEN "$WACALLS_API_TOKEN"
printf '# "true" exige assinatura em todo webhook do WAHA. O WAHA Core NÃO assina,\n'
printf '# então ligar isto sem um WAHA Plus (ou proxy que assine) para a ingestão\n'
printf '# de mensagens. A rota global já não é publicada na internet (ver Caddyfile).\n'
envq WAHA_WEBHOOK_REQUIRE_SIGNATURE "${WAHA_WEBHOOK_REQUIRE_SIGNATURE:-false}"
printf '# Retoma as sessões já pareadas quando o contêiner do transporte reinicia.\n'
printf '# Sem isto o número segue pareado no volume e MUDO até alguém abrir a tela\n'
printf '# e clicar Reconectar — nada entra nem sai nesse meio-tempo.\n'
envq WHATSAPP_RESTART_ALL_SESSIONS "${WHATSAPP_RESTART_ALL_SESSIONS:-True}"
# WAHA publica variantes x86 e ARM separadas para NOWEB. O padrão acompanha
# uname -m; uma WAHA_IMAGE escolhida pelo operador continua prevalecendo.
envq WAHA_IMAGE "${WAHA_IMAGE:-$(imagem_waha_padrao_para_host)}"
envq WAHA_DEFAULT_ENGINE "${WAHA_DEFAULT_ENGINE:-NOWEB}"
envq UPSTASH_REDIS_REST_URL "http://srh:80"
envq UPSTASH_REDIS_REST_TOKEN "$UPSTASH_REDIS_REST_TOKEN"
envq SRH_TOKEN "$SRH_TOKEN"
envq NODE_ENV "production"
envq NUVEMSHOP_ENABLED "false"
envq INTERNAL_AGENT_RUN_STUB "false"
envq OWNER_EMAIL "$OWNER_EMAIL"
envq OWNER_PASSWORD "$OWNER_PASSWORD"
# As variáveis que você acrescentou à mão, de volta — já no formato em que
# estavam, sem passar por envq (o valor original já vem com as aspas dele).
if [ -n "$PRESERVADAS" ]; then
printf '\n# ── Acrescentadas manualmente (preservadas pelo install.sh) ──\n'
printf '%s' "$PRESERVADAS"
fi
} > .env
chmod 600 .env
# O .env definitivo existe: o rascunho cumpriu o papel e some — deixá-lo no
# disco seria uma segunda cópia dos segredos, e desatualizada na primeira
# correção que alguém fizer no .env.
rm -f "$PARTIAL_FILE"
c_grn "$(t "✓ .env escrito (permissão 600)")"
# ── 6. Checagem de DNS ──────────────────────────────────────────────────────
fase 3 "$(t "Banco de dados e domínio")"
step "$(t "Conferindo DNS de {1}" "$DOMAIN")"
public_ip="$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || echo '')"
# Um domínio pode ter A (IPv4) e AAAA (IPv6) ao mesmo tempo, e o resolver não
# garante ordem entre eles. Comparar só o PRIMEIRO endereço (o antigo `hosts`
# + `head -1`) dava falso alarme sempre que o AAAA vinha antes do A: o DNS
# estava correto, o SSL ia ser emitido normalmente, e mesmo assim o instalador
# dizia que o domínio não apontava pra cá — assustando quem instala bem na hora
# em que ela mais precisa de confiança. `ahosts` lista TODOS os endereços; basta
# que UM deles seja o IP do VPS.
resolved="$(getent ahosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ' || echo '')"
if [ -n "$public_ip" ] && case " $resolved " in *" $public_ip "*) true;; *) false;; esac; then
c_grn "$(t "✓ {1} → {2} (aponta pra este VPS)" "$DOMAIN" "$public_ip")"
else
# DNS recém-apontado leva minutos para propagar: chegar aqui é estado NORMAL,
# não erro. Antes havia uma única saída — responder exatamente "s" — e
# qualquer outra coisa matava a instalação. Agora o padrão é esperar junto com
# a pessoa: Enter reconsulta, e sair é uma escolha explícita dela.
while [ "$NONINTERACTIVE" = 0 ]; do
c_ylw "$(t "⚠ {1} resolve para '{2}' e o IP deste VPS é '{3}'." "$DOMAIN" "${resolved:-$(t nada)}" "${public_ip:-$(t desconhecido)}")"
c_ylw "$(t " O SSL (Let's Encrypt) só será emitido quando o A-record apontar pra cá.")"
printf '\n%s\n' "$(t " No painel do seu domínio, crie um registro A apontando {1}" "$DOMAIN")"
printf '%s\n\n' "$(t " para {1}. Costuma valer em poucos minutos." "${public_ip:-$(t "o IP deste servidor")}")"
printf '%s\n' "$(t " Enter = conferir de novo")"
printf '%s\n' "$(t " c = continuar assim mesmo (o site sobe sem cadeado até o DNS valer)")"
printf '%s\n' "$(t " s = sair e voltar depois (o que você já respondeu fica guardado)")"
if ! read -r -p " > " a; then a="s"; fi
case "$(printf '%s' "$a" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')" in
c|continuar) c_ylw "$(t " Seguindo sem o DNS pronto — lembre de apontar o A-record.")"; break;;
s|sair|salir|n|nao|no) die "$(t "Ajuste o A-record de {1} para {2} e rode o instalador de novo." "$DOMAIN" "${public_ip:-$(t "o IP deste servidor")}")";;
*)
resolved="$(getent ahosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ' || echo '')"
if [ -n "$public_ip" ] && case " $resolved " in *" $public_ip "*) true;; *) false;; esac; then
c_grn "$(t "✓ {1} → {2} (agora aponta pra este VPS)" "$DOMAIN" "$public_ip")"; break
fi
c_ylw "$(t " Ainda não propagou. Dá pra esperar e tentar de novo.")"
;;
esac
done
fi
# ── 7. Aplica o schema (baseline) no Supabase — via container postgres ───────
step "$(t "Aplicando o schema no Supabase (baseline.sql)")"
# Tudo daqui até o fim da etapa 8 fala com o banco por `url_do_schema`
# (_common.sh), não pela string que vai para o `.env`: criar extensão, aplicar o
# baseline e promover o dono exigem o DONO do banco, e num Supabase próprio a
# string do app é — por recomendação nossa — uma role menor. Sem
# `SUPABASE_DB_ADMIN_URL` declarada as duas são a mesma, que é o caso da nuvem.
if [ -f supabase/baseline.sql ]; then
# O baseline é um pg_dump: referencia public.vector, public.citext e gin_trgm_ops
# (pg_trgm) mas NÃO cria as extensões. Supabase não as habilita no schema public por
# padrão — criamos aqui, senão o schema quebra no meio (ex.: "type public.vector does
# not exist"). Idempotente (if not exists).
pg_container postgres:17-alpine psql "$(url_do_schema)" -v ON_ERROR_STOP=1 -c \
"create extension if not exists vector with schema public; create extension if not exists citext with schema public; create extension if not exists pg_trgm with schema public;" \
>/dev/null 2>&1 \
&& c_grn "$(t "✓ extensões (vector, citext, pg_trgm) habilitadas no public")" \
|| { c_ylw "$(t "⚠ não consegui habilitar as extensões — o schema pode falhar abaixo.")"
c_ylw "$(t " Supabase próprio? Criar extensão exige o dono do banco: rode de novo com")"
c_ylw " SUPABASE_DB_ADMIN_URL='postgresql://<dono>:<senha>@<host>:5432/postgres'"; }
SCHEMA_LOG="$PROJECT_DIR/baseline-apply.log"
# Banco novo ou re-execução? Re-aplicar com ON_ERROR_STOP pararia no primeiro
# "já existe" (ex.: multiple primary keys) e PULARIA o resto do arquivo —
# inclusive o apêndice com as migrations novas. Banco existente = modo update
# (mesmo contrato do update.sh); banco novo = ON_ERROR_STOP e falha é FATAL
# (schema pela metade = app sem RLS).
# O `|| true` não é preguiça: sem ele, um psql que falha aqui sai com código 2
# dentro da substituição e, com `set -e` + `pipefail`, derruba o instalador sem
# imprimir nada (o 2>/dev/null já tinha engolido a causa). Preferimos seguir e
# deixar o erro aparecer no ponto em que dá para explicá-lo.
has_schema="$(pg_container postgres:17-alpine psql "$(url_do_schema)" -tAc \
"select 1 from information_schema.tables where table_schema='public' and table_name='organizations' limit 1" 2>/dev/null | tr -d '[:space:]' || true)"
if [ "$has_schema" = "1" ]; then
c_ylw "$(t "• schema já existe — re-aplicando em modo update (erros 'já existe' são esperados e ficam no log)")"
# Mesmo contrato do update.sh, inclusive a nova passada quando o banco está
# em disputa: `reaplicar_baseline` em _common.sh.
if reaplicar_baseline "$PROJECT_DIR/supabase/baseline.sql" "$SCHEMA_LOG"; then
c_grn "$(t "✓ schema re-aplicado (apêndice de migrations incluído)")"
else
c_ylw "$(t "⚠ Erros no banco que NÃO são os esperados (log completo: {1}):" "$SCHEMA_LOG")"
# Sem `| head`: com pipefail, o head que fecha cedo mata o printf com SIGPIPE
# numa lista grande, e o set -e derrubava o instalador aqui.
listar_erros_do_banco "$BASELINE_INESPERADO" 20
fi
else
if pg_container -i -v "$PROJECT_DIR/supabase/baseline.sql:/baseline.sql:ro" \
postgres:17-alpine psql "$(url_do_schema)" -v ON_ERROR_STOP=1 -f /baseline.sql \
> "$SCHEMA_LOG" 2>&1; then
c_grn "$(t "✓ schema aplicado (log: {1})" "$SCHEMA_LOG")"
else
tail -5 "$SCHEMA_LOG"
die "$(t "baseline falhou num banco NOVO — o schema ficaria incompleto (sem RLS). Log completo: {1}
Se o erro fala em permissão: o baseline exige o DONO do banco. Num Supabase próprio,
rode de novo com SUPABASE_DB_ADMIN_URL='postgresql://<dono>:<senha>@<host>:5432/postgres'
— ela roda só o schema e NÃO é gravada no .env dos contêineres." "$SCHEMA_LOG")"
fi
fi
# Verificação real, não wishful thinking: o app precisa das tabelas core.
n_tables="$(pg_container postgres:17-alpine psql "$(url_do_schema)" -tAc \
"select count(*) from information_schema.tables where table_schema='public'" 2>/dev/null | tr -d '[:space:]')"
if [ "${n_tables:-0}" -ge 30 ]; then
c_grn "$(t "✓ verificação: {1} tabelas no schema public" "$n_tables")"
else
c_ylw "$(t "⚠ verificação: só {1} tabelas no schema public — confira {2}" "${n_tables:-0}" "$SCHEMA_LOG")"
fi
else
c_ylw "$(t "⚠ supabase/baseline.sql não encontrado — pulei (aplique o schema manualmente).")"
fi
# ── 7.5 E-mails de acesso (criar conta / recuperar senha) ───────────────────
# O e-mail de confirmação de conta é o PRIMEIRO artefato que qualquer usuário
# recebe. Sem este passo ele chega no modelo padrão do Supabase — em inglês,
# "Confirm Your Signup", sem marca nenhuma — numa instalação em que tudo o mais
# já está com a marca de quem hospeda.
#
# Chamado SEMPRE, com ou sem token: sem `SUPABASE_ACCESS_TOKEN` o script imprime
# o passo manual do painel e sai 0. É informação que vale mais aqui, no fim da
# instalação, do que num documento que ninguém vai abrir.
#
# `|| true` como cinto de segurança: o script já promete nunca sair diferente de
# 0, e mesmo assim a instalação não pode morrer por causa do e-mail.
# O que o `marca-emails.sh` não conseguiu fazer sozinho, repetido na TELA FINAL
# com o domínio já preenchido.
#
# O aviso dele existe desde sempre, e sai ~200 linhas antes do fim — no meio de
# um log de dez minutos, seguido de uma tela verde de "Instalação concluída!".
# Quem instala não volta para lê-lo, e descobre o problema quando um usuário
# clica em "esqueci minha senha" e cai num `localhost:3000` que não existe fora
# da máquina de quem desenvolve. (issue #431/#426)
pendencia_dos_emails() {
[ -s "${PENDENCIA_EMAIL:-/dev/null}" ] || return 0
# A receita DEPENDE DA TOPOLOGIA, e mandar a errada é pior que não mandar
# nada. Num Supabase PRÓPRIO não existe supabase.com/dashboard nem
# Management API: quem configura é env do GoTrue. Este bloco já mandou o
# self-hoster para um painel que ele não tem — e a pessoa fica achando que
# perdeu a senha do Supabase quando o que falta é uma variável.
case "${NEXT_PUBLIC_SUPABASE_URL:-}" in
https://*.supabase.co*) : ;;
*) pendencia_dos_emails_proprio; return 0 ;;
esac
cat <<PEND
$(c_ylw " ─── $(t "FALTA UM PASSO, e ele é no painel do Supabase") ─────")
$(t "Os e-mails de acesso (esqueci minha senha, confirmação de cadastro e")
$(t "aceite de convite) ainda não levam para este app. Sem este passo,")
$(t "ninguém consegue redefinir a própria senha.")
$(t "O que o passo automático encontrou:")
$(sed 's/^/ /' "$PENDENCIA_EMAIL")
$(t "Em https://supabase.com/dashboard → seu projeto → Authentication →")
$(t "URL Configuration, preencha:")
Site URL: https://${DOMAIN}
Redirect URLs: https://${DOMAIN}/auth/confirm
$(t "Depois é só salvar — não precisa reiniciar nada aqui.")
$(t "Para o instalador fazer isso sozinho da próxima vez, rode")
$(t "\`bash hostgator-setup-kit/install.sh\` de novo e informe o token de")
$(t "acesso quando ele perguntar (supabase.com/dashboard/account/tokens).")
PEND
}
# ── A mesma pendência, na topologia em que o Supabase é seu ─────────────────
# POR QUE O INSTALADOR NÃO ESCREVE ISTO SOZINHO: o GoTrue não é serviço deste
# compose. O kit sobe app, worker, scheduler, waha, redis, srh e caddy; o
# Supabase próprio é outra stack, com outro arquivo, que pode nem estar nesta
# máquina. Escrever nele seria o instalador editar a instalação de terceiro.
# Então ele faz o que pode fazer com honestidade: diz as duas linhas exatas, e
# o healthcheck.sh confere depois se elas chegaram.
pendencia_dos_emails_proprio() {
cat <<PEND
$(c_ylw " ─── $(t "FALTA UM PASSO, no SEU Supabase") ───────────────────")
$(t "Os e-mails de acesso (confirmar cadastro e redefinir senha) ainda saem no")
$(t "modelo padrão do GoTrue. O link desse modelo NÃO fecha a sessão quando o")
$(t "clique vem do webmail — a conta é confirmada e a pessoa entra sem")
$(t "organização e sem menu.")
$(t "O que o passo automático encontrou:")
$(sed 's/^/ /' "$PENDENCIA_EMAIL")
$(t "Como o seu Supabase é próprio, não há painel na nuvem nem API para isto:")
$(t "a configuração é por variável de ambiente do serviço \`auth\` (GoTrue).")
$(t "Acrescente ao compose DELE — não a este:")
GOTRUE_SITE_URL=https://${DOMAIN}
GOTRUE_URI_ALLOW_LIST=https://${DOMAIN}/auth/confirm
GOTRUE_MAILER_TEMPLATES_CONFIRMATION=https://${DOMAIN}/email-templates/confirmation
GOTRUE_MAILER_TEMPLATES_RECOVERY=https://${DOMAIN}/email-templates/recovery
$(c_ylw "$(t "Tem de ser URL http(s).")") $(t "O GoTrue cola no fim do SITE_URL tudo o que não")
$(t "começa com \`http\` e busca por HTTP — um caminho de arquivo faz o cliente")
$(t "receber a tela de login dentro do e-mail.")
$(t "Depois reinicie só o auth do seu Supabase e confira aqui com:")
bash hostgator-setup-kit/healthcheck.sh
PEND
}
# ── A pendência da IA, quando a chave ficou para depois ─────────────────────
# A #670 tornou o campo da chave `opcional`: antes o instalador morria sem uma
# chave que passasse numa chamada real, contra a doc e contra o runtime.
# Instalar sem chave é legítimo; o que não pode é a pessoa terminar sem saber
# que a IA ainda não atende e ONDE cadastrar depois. Este bloco repete o
# caminho na TELA FINAL, que é a única tela que a pessoa lê inteira.
#
# Critério: nenhuma credencial DE AMBIENTE preenchida — nem a do provedor
# escolhido, nem o AI Gateway (que tem precedência na resolução do chat, ver
# `.env.hostgator.example`). Credencial cadastrada pela tela (banco) não dá
# para ver daqui; quem já cadastrou reconhece o aviso e ignora.
pendencia_da_ia() {
local chave="" rotulo=""
case "${AI_PROVIDER:-anthropic}" in
openrouter) chave="${OPENROUTER_API_KEY:-}"; rotulo="OpenRouter" ;;
openai) chave="${OPENAI_API_KEY:-}"; rotulo="OpenAI" ;;
*) chave="${ANTHROPIC_API_KEY:-}"; rotulo="Anthropic" ;;
esac
[ -n "$chave" ] && return 0
[ -n "${AI_GATEWAY_API_KEY:-}" ] && return 0
cat <<PEND
$(c_ylw " ─── $(t "A IA ainda não atende — falta cadastrar a chave") ───")
$(t "Você deixou a chave de IA para depois, e o CRM está no ar sem ela. O que")
$(t "ainda não funciona é o agente: ele responde quando uma credencial existir.")
$(t "Quando tiver a chave da {1}, cadastre em:" "$rotulo")
IA › Credenciais
$(t "A chave fica CIFRADA no banco — não precisa mexer no .env nem reiniciar nada.")
PEND
}
PENDENCIA_EMAIL="$(mktemp)"
PENDENCIA_ARQUIVO="$PENDENCIA_EMAIL" \
SUPABASE_ACCESS_TOKEN="${SUPABASE_ACCESS_TOKEN:-}" \
bash "$KIT_DIR/marca-emails.sh" --projeto "$PROJECT_DIR" || true
# ── 8. Bootstrap do 1º dono (cria no Auth + promove via psql) ───────────────
step "$(t "Criando o primeiro admin ({1})" "$OWNER_EMAIL")"
# 1) Cria o usuário no Supabase Auth. Se já existe, a API responde 422 — ignoramos
# (|| true): a re-execução é idempotente, o passo seguinte encontra o usuário.
# No single-server o Caddy pode ainda estar emitindo o certificado: fala com o
# gateway local, que já respondeu ao validador.
curl -fsS -X POST "${SUPABASE_INTERNAL_URL:-${NEXT_PUBLIC_SUPABASE_URL}}/auth/v1/admin/users" \
-H "apikey: ${SUPABASE_SERVICE_ROLE_KEY}" \
-H "Authorization: Bearer ${SUPABASE_SERVICE_ROLE_KEY}" \
-H "Content-Type: application/json" \
-d "{\"email\":\"${OWNER_EMAIL}\",\"password\":\"${OWNER_PASSWORD}\",\"email_confirm\":true,\"user_metadata\":{\"locale\":\"${APP_LOCALE:-pt-BR}\"}}" \
>/dev/null 2>&1 || true
# 2) Resolve o id direto do auth.users e cria org + membership + platform_admin.
# Resolver o uid DENTRO do SQL evita parsing frágil de JSON e funciona tanto para
# usuário recém-criado quanto para um que já existia (re-execução).
pg_container -i postgres:17-alpine psql "$(url_do_schema)" -v ON_ERROR_STOP=1 <<SQL \
&& c_grn "$(t "✓ dono criado e promovido a super-admin")" \
|| die "$(t "Não consegui promover o admin. Confira a service_role key, a URL e a connection string do Supabase.
Este passo lê auth.users e escreve em public: num Supabase próprio ele precisa do dono do
banco — declare SUPABASE_DB_ADMIN_URL e rode de novo.")"
do \$\$
declare v_org uuid; v_uid uuid;
begin
select id into v_uid from auth.users where email = '${OWNER_EMAIL}';
if v_uid is null then
raise exception 'usuário % não encontrado no auth.users (a criação no Auth falhou?)', '${OWNER_EMAIL}';
end if;
select id into v_org from public.organizations where slug='minha-empresa';
if v_org is null then
-- locale aqui, e não só no usuário dono: é a organização que responde
-- pelos convidados que ainda não existem. Quem entra sem preferência
-- própria cai neste valor, então gravar só no dono entregaria o sistema em
-- português para todo mundo que ele convidasse numa instalação em espanhol.
insert into public.organizations (slug, display_name, legal_name, locale, created_by)
values ('minha-empresa','Minha Empresa','Minha Empresa','${APP_LOCALE:-pt-BR}', v_uid)
returning id into v_org;
else
-- Re-execução do instalador com outra resposta: quem rodou de novo para
-- trocar o idioma esperaria que trocasse. Só mexe se a organização ainda
-- estiver no padrão — se alguém já escolheu pela tela, a escolha dela vale
-- mais que uma resposta repetida no terminal.
update public.organizations
set locale = '${APP_LOCALE:-pt-BR}'
where id = v_org and coalesce(locale, 'pt-BR') = 'pt-BR';
end if;
-- O provedor que a pessoa ESCOLHEU passa a valer no banco. O trigger
-- fn_seed_org_llm_defaults semeia 'anthropic' fixo — o que estava certo
-- enquanto a Anthropic era a única chave que este script pedia. Desde que
-- ele pergunta qual IA vai atender, ignorar a resposta significava: quem
-- escolhe OpenRouter instala, cadastra a chave, e todo caminho que passa
-- pelo agent-engine resolve 'anthropic' — sem chave da Anthropic, erro de
-- "IA não configurada" em tudo, mandando cadastrar a chave que ele decidiu
-- não usar.
--
-- O par vai INTEIRO: o trigger semeia provider E default_model da Anthropic
-- juntos, e trocar só o provider deixava openai com claude-sonnet-5, um id
-- que a OpenAI não conhece, pedido por todo ponto que cai no padrão da
-- empresa. O modelo é o curado do provedor escolhido no catálogo (a mesma
-- régua de escolherModeloDoProvedor para os provedores que o baseline semeia
-- com curado), e só entra quando o modelo gravado NÃO é desse provedor: quem
-- re-roda o instalador não perde o modelo escolhido pela tela. Sem curado (a
-- OpenRouter chega com o catálogo vazio até o primeiro sync) fica só o
-- provider, e lib/ai/gateway-binding.ts resolve o par na leitura.
if '${AI_PROVIDER}' not in ('', 'anthropic') then
update public.organizations
set settings = jsonb_set(
coalesce(settings, '{}'::jsonb), '{llm,provider}',
to_jsonb('${AI_PROVIDER}'::text), true)
where id = v_org;
update public.organizations o
set settings = jsonb_set(o.settings, '{llm,default_model}', to_jsonb(c.model_id), true)
from (select m.model_id from public.ai_models m
where m.provider = '${AI_PROVIDER}' and m.is_default_for_provider
and m.supports_tools and m.deprecated_at is null
limit 1) c
where o.id = v_org
and not exists (select 1 from public.ai_models a
where a.provider = '${AI_PROVIDER}'
and a.model_id = o.settings->'llm'->>'default_model');
end if;
insert into public.user_organizations (user_id, organization_id, role, accepted_at)
values (v_uid, v_org, 'admin', now())
on conflict (user_id, organization_id) do update set role='admin', revoked_at=null;
if not exists (select 1 from public.platform_admins where user_id=v_uid and revoked_at is null) then
-- ⚠️ ATENÇÃO: este heredoc NÃO é citado, então o bash expande crase aqui
-- dentro. Crase em volta de nome de coluna vira substituição de comando e o
-- instalador morre com "command not found" no meio da criação do dono.
-- Medido: a primeira versão deste comentário usava crase e a suíte do kit
-- reprovou em três casos. Nome de coluna aqui vai sem crase.
--
-- mfa_required EXPLÍCITO, contra o default "true" da coluna — a MESMA razão
-- que scripts/bootstrap-owner.ts:201 já documenta, e que este INSERT não
-- acompanhou.
--
-- Medido numa instalação self-host recém-feita por este script:
--
-- select column_default from information_schema.columns
-- where table_name='platform_admins' and column_name='mfa_required';
-- -> true
--
-- Como o INSERT abaixo não informava a coluna, TODA instalação nascia
-- exigindo TOTP do dono. lib/auth/politica-mfa.ts passou a LER essa coluna
-- (antes o gate olhava só is_platform_admin), e o cabeçalho dele registra
-- que o cadastro virou opcional exatamente para acabar com o bloqueador de
-- tela cheia logo depois do onboarding — "segurança que expulsa o usuário na
-- primeira tela não protege ninguém".
--
-- Ou seja: o defeito que a mudança de doutrina eliminou continuava vivo pelo
-- caminho do instalador, que é justamente o caminho de TODO self-hoster. O
-- bootstrap-owner.ts estava certo; este INSERT é que ficou para trás.
--
-- false e não omitir: quem quiser exigir liga em Configurações › Segurança,
-- e a decisão fica visível na linha em vez de herdada de um default.
insert into public.platform_admins (user_id, granted_by, scope, mfa_required, reason)
values (v_uid, v_uid, 'full', false, 'Bootstrap inicial do self-host');
end if;
end \$\$;
SQL
# ── 9. Sobe a stack ─────────────────────────────────────────────────────────
fase 4 "$(t "Colocando o CRM no ar")"
step "$(t "Puxando a imagem e subindo os serviços")"
# A guarda existe porque dar `image:` a um serviço que era build-only mudou o
# comportamento do `pull`: antes ele PULAVA o worker ("Skipped - No image to be
# pulled"), agora FALHA a operação inteira se a referência não resolver. E há
# três motivos reais para não resolver logo depois de um release: pacote novo no
# GHCR nasce PRIVADO até alguém trocar a visibilidade na mão; a tag git existe
# minutos antes das imagens; e o GHCR pode estar fora do ar.
#
# Sem esta guarda, uma instalação NOVA morria no passo 9 — com o banco já
# provisionado e o .env já escrito. O `up -d` seguinte não precisa do pull: o
# worker e o scheduler têm `build:` ao lado do `image:`, e o Compose os constrói
# quando a imagem não existe (medido).
if ! dc pull; then
c_ylw "$(t "⚠ Não consegui puxar todas as imagens do registro.")"
c_ylw "$(t " Sigo assim mesmo: o que faltar é construído aqui (mais lento, mesmo resultado).")"
fi
# O "sigo assim mesmo" acima vale para o worker e o scheduler, que têm `build:`
# ao lado do `image:` — mas NÃO para o app, que não tem: se a imagem dele não
# veio do registro (arquitetura da VPS diferente da das imagens publicadas, tag
# ainda publicando, pacote privado), o `up -d` morre e a instalação acabava sem
# CRM no ar. A promessa da frase acima só se sustenta com esta guarda.
CONSTRUIU_AQUI=""
if ! dc up -d; then
if construir_aqui_e_subir "$VERSAO_ALVO"; then
CONSTRUIU_AQUI=1
else
die "$(t "Não coloquei o CRM no ar: nem as imagens prontas desta versão nem a construção aqui funcionaram. O erro está logo acima; para reproduzir só a construção: {1}" "docker compose $(dc_files) -f ${COMPOSE_BUILD} build")"
fi
fi
c_grn "$(t "✓ containers no ar")"
if [ -n "$CONSTRUIU_AQUI" ]; then
c_ylw "$(t " (as três imagens desta versão foram construídas aqui nesta VPS: as prontas")"
c_ylw "$(t " não servem para a arquitetura dela. É mais lento e não precisa de nada manual.)")"
fi
# ── 10. Healthcheck ─────────────────────────────────────────────────────────
step "$(t "Aguardando o app ficar saudável")"
# Antes isto abria um socket na porta 3000 e dava por bom. A porta abre assim
# que o Node sobe, então o "✓" saía com o app ainda sem banco — e o bloco
# "Instalação concluída!" saía logo atrás, incondicionalmente. Um falso verde
# no exato momento em que a pessoa decide se confia no produto. Agora o critério
# é o mesmo do update.sh: a rota /api/v1/health responder "status":"ok".
if health_body="$(wait_app_healthy 30 3)"; then
APP_SAUDAVEL=1
c_grn "$(t "✓ app no ar e saudável")"
else
APP_SAUDAVEL=0
c_ylw "$(t "⚠ os contêineres subiram, mas o app não respondeu que está saudável.")"
# "|| true": mesma família do pipe que matava o supabase-provision.sh — o
# corpo passa de 200 bytes, o head fecha o pipe e o printf leva SIGPIPE.
[ -n "$health_body" ] && c_dim "$(t " última resposta: {1}" "$(printf '%s' "$health_body" | head -c 200 || true)")"
fi
# O catálogo dos provedores diretos vem no baseline, mas a OpenRouter é grande
# demais para ser congelada nele: seus ~400 modelos chegam pelo cron diário
# `api/v1/cron/sync-model-catalog`, que o scheduler bate às 04:15 UTC
# (docker/scheduler/entrypoint.sh). Numa instalação concluída DEPOIS dessa
# rodada, o seletor de modelos do agente ficava vazio até o dia seguinte —
# mesmo com uma chave OpenRouter válida já cadastrada. É a primeira tela que
# quem instalou vai abrir para testar a IA.
#
# O segredo NÃO passa pelo argv deste processo: as aspas simples impedem a
# expansão aqui, e quem expande `$INTERNAL_SECRET` é o sh de dentro do
# contêiner `scheduler`, que já o recebe pelo ambiente (docker-compose.prod.yml).
#
# FALHA ABERTA, de propósito: a origem é externa (openrouter.ai) e pode estar
# fora do ar no minuto da instalação. Uma instalação saudável não pode ser
# invalidada por isso — o cron das 04:15 continua sendo a recuperação, e o
# operador lê aqui que ela existe. Por isso o comando mora na CONDIÇÃO de um
# `if`, onde o `set -e` não aborta o script.
if [ "${APP_SAUDAVEL:-0}" = 1 ]; then
step "$(t "Semeando o catálogo de modelos de IA")"
if catalogo_body="$(dc exec -T scheduler sh -c 'curl -fsS -m60 -H "Authorization: Bearer $INTERNAL_SECRET" http://app:3000/api/v1/cron/sync-model-catalog' 2>&1)"; then
c_grn "$(t "✓ catálogo de modelos semeado")"
else
c_ylw "$(t "⚠ não consegui semear o catálogo de modelos agora; o agendador tenta de novo às 04:15 UTC.")"
[ -n "$catalogo_body" ] && c_dim "$(t " detalhe: {1}" "$(printf '%s' "$catalogo_body" | head -c 200 || true)")"
fi
fi
# ── 11. Automações (cron do drain de eventos) ───────────────────────────────
step "$(t "Ativando as automações")"
ensure_encryption_key .env
# A senha desta instalação nasceu agora e vai para um arquivo, nunca para a
# linha do crontab: não há o que trocar depois (ver trocar_segredo_do_cron_vazado).
marcar_segredo_do_cron_como_novo
setup_event_log_drain_cron
setup_update_agent_cron
# ── O marcador que diz que esta instalação EXISTE (#1778) ───────────────────
# A guarda de arquitetura (#1042, contornada pelo #1266) precisa distinguir
# "instalação nova" de "instalação que já está no ar", e ela não pode usar
# "tem compose e tem `.env`" como prova: o `.env` chega pronto numa instalação
# NOVA (copiado, gerado por automação, ou deixado por um `--yes` que parou no
# meio), e com esse critério uma VPS numa arquitetura sem imagens publicadas
# começava a instalação construindo as imagens na própria VPS — o que a guarda
# existe para impedir.
#
# O marcador vai aqui, e não antes, porque só a partir daqui é verdade que a
# instalação EXISTE: os contêineres subiram e o app respondeu. Gravar antes
# deixaria o arquivo afirmando uma instalação que pode não ter acontecido — e
# a próxima rodada da guarda confiaria numa instalação que não está no ar.
# `|| true` porque a ausência do marcador não pode derrubar uma instalação
# cujos contêineres já estão no ar: no pior caso a guarda cai no sinal do
# contêiner, que é o mesmo que ela usava para quem instalou numa versão
# anterior.
if [ "${APP_SAUDAVEL:-0}" = 1 ]; then
marcar_instalacao_feita "$VERSAO_ALVO" || c_ylw "$(t "⚠ Não consegui gravar o marcador desta instalação (arquivo .deskcomm-instalado). O CRM está no ar; numa arquitetura sem imagens publicadas, a atualização pode pedir uma VPS suportada até o marcador existir.")"
fi
# ── Final ───────────────────────────────────────────────────────────────────
# O app não confirmou que está de pé: dizer "Instalação concluída!" aqui seria
# mentir na única tela que a pessoa vai ler inteira. Ela recebe o estado real e
# o caminho de diagnóstico — e não a receita de apagar tudo do show_recovery,
# que existe para quem parou no MEIO. Aqui nada ficou pela metade: a config
# está salva e a stack está de pé; falta o app responder.
if [ "${APP_SAUDAVEL:-0}" != 1 ]; then
cat <<INCOMPLETO
$(c_ylw "═══════════════════════════════════════════════════════")
$(c_ylw " $(t "Quase lá — falta o app responder")")
$(c_ylw "═══════════════════════════════════════════════════════")
$(t "A configuração está salva e os contêineres estão no ar. Você NÃO precisa")
$(t "refazer nada — falta o app dizer que está saudável.")
$(t "O motivo mais comum é uma chave faltando ou errada no .env. O log diz qual:")
docker compose $(dc_files) logs --tail=50 app
$(t "procure por: [env] Falha de validação")
$(t "Diagnóstico completo dos serviços:")
bash ${KIT_DIR}/healthcheck.sh
$(t "Depois de corrigir o .env, é só subir de novo (nada é perdido):")
docker compose $(dc_files) up -d
$(t "Travou? Leve o log para a comunidade — tem gente que já passou por isso:")
${COMUNIDADE_URL}
INCOMPLETO
# Sai != 0 para que automação (e o --yes) saiba que não terminou saudável,
# mas sem o trap: a receita de "apague tudo e recomece" não cabe aqui.
trap - EXIT
exit 1
fi
# O banner dizia "por padrão os erros são enviados" para TODA instalação — e a
# pergunta de consentimento acima tem padrão NÃO enviar (issue #668 mediu o
# .env do exemplo saindo com a telemetria ligada sem ninguém escolher). O texto
# passa a refletir a escolha feita, em vez de afirmar um padrão.
telemetria_no_banner() {
if [ "${SENTRY_DSN:-}" = "off" ]; then
printf '%s\n' "$(t " Telemetria: DESLIGADA — nenhum relatório de erro sai desta instalação.")"
printf '%s\n' "$(t " Para ligar, apague a linha SENTRY_DSN do .env e rode: {1}" "docker compose $(dc_files) up -d")"
else
printf '%s\n' "$(t " Telemetria: LIGADA — só relatórios de erro anonimizados vão ao Sentry do")"
printf '%s\n' "$(t " projeto. Para desligar, ponha SENTRY_DSN='off' no .env e rode: {1}" "docker compose $(dc_files) up -d")"
fi
}
cat <<DONE
$(c_grn "═══════════════════════════════════════════════════════")
$(c_grn " $(t "Instalação concluída!")")
$(c_grn "═══════════════════════════════════════════════════════")
$(pendencia_dos_emails)
$(pendencia_da_ia)
1. $(t "Acesse:") https://${DOMAIN}
$(t "(o SSL leva ~1min pra emitir no primeiro acesso)")
2. $(t "Faça login com:")
$(t "e-mail:") ${OWNER_EMAIL}
$(t "senha:") $(t "(a que você definiu)")
3. $(t "Conecte o WhatsApp (2º passo do onboarding):")
$(t "Deixe o WhatsApp JÁ ABERTO em Configurações → Aparelhos conectados")
$(t "antes de abrir a tela — o QR code vale só uns minutos. Se expirar,")
$(t "o próprio CRM tem o botão \"Gerar novo QR Code\".")
4. $(t "A verificação em duas etapas é OPCIONAL: quem quiser liga em")
$(t "Configurações → Segurança (guarde os códigos de recuperação).")
$(t "Perdeu o celular?") bash hostgator-setup-kit/reset-mfa.sh ${OWNER_EMAIL}
$(c_grn " ─── $(t "A comunidade") ──────────────────────────────────────")
$(t "É onde saem os avisos de versão nova, os agentes que outras pessoas já")
$(t "configuraram e a resposta de quem roda exatamente este CRM:")
${COMUNIDADE_URL}
$(telemetria_no_banner)
$(t "Comandos úteis:")
$(t "ver logs:") docker compose $(dc_files) logs -f app
$(t "reiniciar:") docker compose $(dc_files) restart
$(t "atualizar:") bash hostgator-setup-kit/update.sh
$(t "backup:") bash hostgator-setup-kit/backup.sh
$(t "trocar config:") bash hostgator-setup-kit/install.sh
$(t "(mostra tudo o que você respondeu e deixa corrigir por número)")
$(t "recomeçar:") docker compose $(dc_files) down -v && rm -f .env ${MARCA_INSTALACAO_NOME:-.deskcomm-instalado}
$(t "(derruba tudo; depois rode o install.sh de novo)")
DONE