#!/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"
COMPOSE_CLOUDFLARED="docker-compose.cloudflared.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() {
  # Overlay da CA do Supabase (#829) — mesma condição da gêmea em _common.sh.
  # Aqui ela importa: antes do clone o overlay pode ainda não existir, e um -f
  # para arquivo ausente derrubaria o compose no meio da instalação.
  local -a ca=()
  if ca_do_supabase_ok && [ -f docker-compose.supabase-ca.yml ]; then ca=(-f docker-compose.supabase-ca.yml); fi
  # Rede do Supabase self-hosted no mesmo host (gemea de _common.sh).
  if rede_do_supabase_ok && [ -f docker-compose.supabase-rede.yml ]; then ca+=(-f docker-compose.supabase-rede.yml); fi
  if [ "${SINGLE_SERVER:-0}" = "1" ]; then
    # #2099: o overlay do proxy da hospedagem entra também no single-server.
    case "${REVERSE_PROXY:-caddy}" in
    traefik) docker compose -f "$COMPOSE" -f docker-compose.single-server.yml -f "$COMPOSE_TRAEFIK" ${ca[@]+"${ca[@]}"} "$@" ;;
    npm)     docker compose -f "$COMPOSE" -f docker-compose.single-server.yml -f "$COMPOSE_NPM" ${ca[@]+"${ca[@]}"} "$@" ;;
    *)       docker compose -f "$COMPOSE" -f docker-compose.single-server.yml ${ca[@]+"${ca[@]}"} "$@" ;;
    esac
    return
  fi
  case "${REVERSE_PROXY:-caddy}" in
  traefik) docker compose -f "$COMPOSE" -f "$COMPOSE_TRAEFIK" ${ca[@]+"${ca[@]}"} "$@" ;;
  npm)     docker compose -f "$COMPOSE" -f "$COMPOSE_NPM" ${ca[@]+"${ca[@]}"} "$@" ;;
  cloudflared) docker compose -f "$COMPOSE" -f "$COMPOSE_CLOUDFLARED" ${ca[@]+"${ca[@]}"} "$@" ;;
  *)       docker compose -f "$COMPOSE" ${ca[@]+"${ca[@]}"} "$@" ;;
  esac
}
dc_files() {
  # Mesma condição do dc() (#829): a mensagem que ensina o comando tem de bater
  # com o que o kit roda.
  local sufixo=""
  if ca_do_supabase_ok && [ -f docker-compose.supabase-ca.yml ]; then
    sufixo=" -f docker-compose.supabase-ca.yml"
  fi
  if rede_do_supabase_ok && [ -f docker-compose.supabase-rede.yml ]; then
    sufixo="${sufixo} -f docker-compose.supabase-rede.yml"
  fi
  if [ "${SINGLE_SERVER:-0}" = "1" ]; then
    case "${REVERSE_PROXY:-caddy}" in
    traefik) printf -- '-f %s -f %s -f %s%s' "$COMPOSE" docker-compose.single-server.yml "$COMPOSE_TRAEFIK" "$sufixo" ;;
    npm)     printf -- '-f %s -f %s -f %s%s' "$COMPOSE" docker-compose.single-server.yml "$COMPOSE_NPM" "$sufixo" ;;
    *)       printf -- '-f %s -f %s%s' "$COMPOSE" docker-compose.single-server.yml "$sufixo" ;;
    esac
    return
  fi
  case "${REVERSE_PROXY:-caddy}" in
  traefik) printf -- '-f %s -f %s%s' "$COMPOSE" "$COMPOSE_TRAEFIK" "$sufixo" ;;
  npm)     printf -- '-f %s -f %s%s' "$COMPOSE" "$COMPOSE_NPM" "$sufixo" ;;
  cloudflared) printf -- '-f %s -f %s%s' "$COMPOSE" "$COMPOSE_CLOUDFLARED" "$sufixo" ;;
  *)       printf -- '-f %s%s' "$COMPOSE" "$sufixo" ;;
  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.
#
# ── A CA do Supabase, declarada UMA vez (#829) ────────────────────────────────
# GÊMEA da de _common.sh (mesmo motivo das duas acima: este script roda antes do
# clone existir, e o validador da connection string já precisa saber explicar uma
# falha de certificado). Se mexer numa, mexa na outra — as duas mensagens e o
# caminho fixo do contêiner têm de sair byte a byte iguais.
CA_NO_CONTAINER="/etc/deskcomm/ca/supabase-ca.crt"

# stdout: caminho absoluto da CA (o docker recusa bind relativo).
# stderr: o que falta, sempre com o nome da variável — é a frase que aparece no
# lugar do `SELF_SIGNED_CERT_IN_CHAIN` cru que a issue reportou.
ca_do_supabase() {
  local p="${SUPABASE_SSL_ROOT_CERT:-}"
  if [ -z "$p" ]; then
    printf '%s' "SUPABASE_SSL_ROOT_CERT não está declarada no .env — sem ela o kit não recebe a CA do Supabase. Declare SUPABASE_SSL_ROOT_CERT=/caminho/do/prod-ca-2021.crt (baixe com: curl -fsSL -o /root/certs/prod-ca-2021.crt https://supabase-downloads.s3-ap-southeast-1.amazonaws.com/prod/ssl/prod-ca-2021.crt)" >&2
    return 1
  fi
  case "$p" in /*) ;; *) p="$PWD/$p" ;; esac
  if [ ! -f "$p" ] || [ ! -r "$p" ]; then
    printf '%s' "SUPABASE_SSL_ROOT_CERT aponta para '$p', e este arquivo não existe (ou não é legível). Corrija o caminho no .env — a CA fica FORA do checkout, e o kit só monta arquivo que existe." >&2
    return 1
  fi
  printf '%s' "$p"
}

# Idem _common.sh: silenciosa, para as decisões de montagem.
ca_do_supabase_ok() { ca_do_supabase >/dev/null 2>&1; }

# Gemea de _common.sh: rede docker de um Supabase self-hosted no mesmo host.
rede_do_supabase_ok() {
  [ -n "${SUPABASE_DOCKER_NETWORK:-}" ] && docker network inspect "$SUPABASE_DOCKER_NETWORK" >/dev/null 2>&1
}

pg_container() {
  local -a rede=() ca=()
  local caminho=""
  # Gêmea de _common.sh: sem PSQL_DOCKER_NETWORK, vale a rede do Supabase do mesmo host.
  if [ -n "${PSQL_DOCKER_NETWORK:-}" ]; then rede=(--network "$PSQL_DOCKER_NETWORK")
  elif rede_do_supabase_ok; then rede=(--network "$SUPABASE_DOCKER_NETWORK"); fi
  # `local caminho` separado de propósito: `local caminho="$(...)"` engole o
  # status do comando substituído e o `if` aceitaria CA quebrada como pronta.
  if caminho="$(ca_do_supabase 2>/dev/null)"; then
    ca=(-v "$caminho:$CA_NO_CONTAINER:ro" -e "PGSSLROOTCERT=$CA_NO_CONTAINER")
  fi
  docker run --rm ${rede[@]+"${rede[@]}"} ${ca[@]+"${ca[@]}"} "$@"
}

# ── 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;"
}
# O temporário da pendência de e-mail (passo 7) também sai aqui, em QUALQUER
# saída: sem isto cada instalação deixava um /tmp/tmp.* com o aviso dentro.
# Zerado aqui para o trap só apagar o que ESTE script criou: um valor herdado do
# ambiente apontaria o `rm -f` para um arquivo alheio se a instalação morresse
# antes do passo 7.
PENDENCIA_EMAIL=
apaga_temporarios() { [ -z "${PENDENCIA_EMAIL:-}" ] || rm -f "$PENDENCIA_EMAIL"; }
trap 'rc=$?; apaga_temporarios; [ "$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/verify 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
  # Responder não basta: tem de ser o GoTrue. Qualquer código ≠ 000 passava, e
  # numa VPS com Coolify a 127.0.0.1:8000 é o PAINEL dele (302 para /login) —
  # medido no PR 4. A URL é perguntada ANTES das chaves, então não há apikey, e
  # sem ela o gateway (Envoy no self-hosted, o da nuvem) barra /auth/v1/health
  # com 401. /auth/v1/verify é rota aberta (é o link dos e-mails): quem responde
  # é o próprio GoTrue, com o JSON de erro dele (`"msg"`), na nuvem e no
  # self-hosted. O corpo sozinho não basta (qualquer API pode ter "msg"): exige
  # também o 400 que o GoTrue devolve ali sem parâmetros — medido no GoTrue do
  # kit (supabase/gotrue v2.196.0): `{"code":400,...,"msg":"Verify requires a
  # verification type"}`. Com `type`+`token` ele devolve 303, mas não mandamos.
  local resp code
  resp="$(curl -s -m 15 -w '\n%{http_code}' "${health_url%/}/auth/v1/verify" 2>/dev/null)" || resp=000
  code="${resp##*$'\n'}"
  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
  case "$code:${resp%$'\n'*}" in
    400:*'"msg"'*) return 0;;
  esac
  echo "$(t "{1} respondeu (HTTP {2}), mas não é o Supabase: o serviço de login dele não atendeu em /auth/v1/verify. Confira o endereço (e a porta, se houver outro painel no servidor)." "$health_url" "$code")"
  return 1
}

# 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
  # ── A PROVA HTTP bate no Supabase LOCAL quando ele é desta VPS ──────────────
  # No single-server a URL pública é servida pelo Caddy desta instalação, e ele
  # só sobe DEPOIS desta fase: com o domínio atrás de proxy (Cloudflare laranja,
  # o padrão do kit) a chamada abaixo volta 521 e a instalação PARA na fase 2,
  # mandando conferir uma chave que está certa — medido numa VPS real, 10/10/2026.
  # Quem responde à pergunta "esta chave serve?" é o gateway local, publicado só
  # em loopback — a MESMA escolha que `v_supabase_url` já faz acima. O `ref` do
  # JWT continua conferido contra a URL que o operador deu: aquela comparação é
  # sobre o PROJETO, e essa não muda.
  local prova="$url"
  if [ "${SINGLE_SERVER:-0}" = "1" ]; then
    case "${SUPABASE_INTERNAL_URL:-}" in
      http://*|https://*) prova="$SUPABASE_INTERNAL_URL";;
    esac
  fi
  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" \
      "$prova/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" "$prova/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
    # ── Falha de certificado (#829) ─────────────────────────────────────────
    # É o `SELF_SIGNED_CERT_IN_CHAIN` da issue: a cadeia do pooler não está na
    # trust store padrão. O erro cru não diz o que fazer, então este ramo fala o
    # que falta — com o NOME da variável — antes de qualquer outra hipótese.
    # Só mensagem de CERTIFICADO entra aqui, nunca qualquer "SSL": senha errada
    # com a segunda tentativa citando "SSL connection is required" tem de cair
    # no ramo da senha, e "SSL SYSCALL error" é queda de rede. As mensagens de
    # verificação da libpq que conhecemos citam "certificate"; o
    # ca-supabase-tls.test.sh prende a classificação pelo comportamento.
    *[Cc]ertificate*|*[Cc]ertificado*|*SELF_SIGNED_CERT*)
      echo "   👉 $(t "O Postgres recusou o certificado TLS: a cadeia dele não está na trust store desta máquina (SELF_SIGNED_CERT_IN_CHAIN).")"
      if ca_do_supabase >/dev/null 2>&1; then
        echo "      $(t "SUPABASE_SSL_ROOT_CERT já está declarada — confira se o arquivo é o prod-ca-2021.crt oficial do Supabase e se o hostname da connection string bate com o certificado.")"
      else
        echo "      $(t "Declare SUPABASE_SSL_ROOT_CERT no .env, apontando para a CA oficial do Supabase:")"
        echo "      curl -fsSL -o /root/certs/prod-ca-2021.crt https://supabase-downloads.s3-ap-southeast-1.amazonaws.com/prod/ssl/prod-ca-2021.crt"
        echo "      SUPABASE_SSL_ROOT_CERT=/root/certs/prod-ca-2021.crt"
      fi;;
    *"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.
#
#   Traefik em MAIS DE UMA rede  → a `coolify` se ela estiver entre elas; senão
#     vazio, e quem chama recusa pedindo TRAEFIK_NETWORK. O docker lista as redes
#     em ordem alfabética, então "a primeira" é a que vence o alfabeto, não a que
#     o painel usa para os sites — medido numa VPS com Coolify: com uma rede
#     `aaa-simulado` pendurada no coolify-proxy, esta função devolveu
#     `aaa-simulado`. O que aconteceria com o domínio NÃO foi medido; o risco de
#     escolher às cegas é ligar o CRM à rede de outro projeto, ou a uma rede
#     `internal`/não-attachable que derruba o `up -d`.
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; }
  set -- $redes
  [ $# -le 1 ] && { printf '%s' "${1:-}"; return 0; }
  case " $* " in *" coolify "*) printf 'coolify' ;; esac
}

# 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

# ARM64: resolver uma release publicada e conferir as quatro imagens e a WAHA
# efetiva no índice OCI ANTES de criar projeto Supabase, rede, .env ou schema.
# Uma tag Git pode existir antes do CI. Em AMD64 a versão segue a cascata de
# sempre (mais abaixo): a sonda exige `docker buildx`, e toda imagem publicada
# já tem amd64.
PLATAFORMA_HOST="$(plataforma_oci_do_host "$(uname -m 2>/dev/null || true)" 2>/dev/null || true)"
if [ "$PLATAFORMA_HOST" = linux/arm64 ]; then
  RELEASE_TAG="$(ultima_release_estavel)"
  VERSAO_ALVO="${RELEASE_TAG#v}"
  WAHA_EFETIVA="${WAHA_IMAGE:-$(imagem_waha_padrao_para_host)}"
  if [ -z "$RELEASE_TAG" ] || ! preflight_instalacao "$VERSAO_ALVO" "$PLATAFORMA_HOST" "$WAHA_EFETIVA"; then
    c_red "$(t "✖ Não há release numérica completa para ARM64, ou a WAHA escolhida não oferece ARM64.")"
    c_red "$(t "  Confira registry, visibilidade das quatro imagens e WAHA_IMAGE; depois repita.")"
    # O painel genérico sugeriria down -v/drop schema antes de qualquer efeito.
    trap - EXIT
    exit 1
  fi
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

# ── Cloudflare Tunnel: escolha manual, atrás de NAT ─────────────────────────
# `cloudflared` NÃO entra em `decide_proxy`: não há nada para detectar. Quem
# está atrás de NAT (ou de CGNAT, ou numa rede que proíbe inbound) declara
# `REVERSE_PROXY=cloudflared` no .env, do mesmo modo manual do NPM. Aqui só se
# confere que o token veio junto e que o modo não é incompatível.
#
# A recusa no single-server é de DESENHO, não de preguiça: lá o Caddy publica
# também as seis APIs do Supabase (Auth, REST, Realtime, Storage...) pelo
# Caddyfile.single-server. Sem Caddy, o túnel não tem para onde mandar
# /auth/v1*, /rest/v1* e o Supabase deixa de funcionar — um site que "sobe" e
# não loga. Até existir um Caddyfile do túnel que roteie as APIs, recusar é a
# resposta honesta.
if [ "${REVERSE_PROXY:-}" = "cloudflared" ]; then
  if [ "${SINGLE_SERVER:-0}" = "1" ]; then
    die "$(t "REVERSE_PROXY=cloudflared não é suportado no modo single-server, porque o Supabase self-hosted depende do Caddy para publicar as APIs (Auth, REST, Storage...). Use o proxy padrão (Caddy) ou o Cloudflare Tunnel apenas com o Supabase na nuvem.")"
  fi
  if [ -z "${CLOUDFLARE_TUNNEL_TOKEN:-}" ]; then
    die "$(t "REVERSE_PROXY=cloudflared exige CLOUDFLARE_TUNNEL_TOKEN no .env.
Crie o túnel em Zero Trust > Networks > Tunnels, copie o token para essa linha
e rode de novo. Em Public Hostnames, aponte o seu domínio para http://app:3000.")"
  fi
  c_grn "$(t "✓ Cloudflare Tunnel: o Caddy fica desligado e o cloudflared publica por app:3000")"
  c_dim "$(t "  (lembre de bloquear /api/v1/webhooks/waha no WAF da Cloudflare — ver docker-compose.cloudflared.yml)")"
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.
# Em ARM64 a versão já foi resolvida e conferida pelo pré-voo, lá em cima.
[ "$PLATAFORMA_HOST" = linux/arm64 ] || 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 [ "$PLATAFORMA_HOST" = linux/arm64 ]; then
  : # pré-voo OCI já passou
elif [ -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: dá para configurar depois, em Admin → E-mail)|||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
  [ "$(printf '%s' "${traefik_redes:-}" | wc -w)" -gt 1 ] && \
    die "$(t "O seu Traefik está em mais de uma rede Docker ({1}) e não sei por qual ele alcança os sites.
Ponha TRAEFIK_NETWORK=<nome> no .env com a rede certa antes de tentar de novo." "${traefik_redes% }")"
  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"
  # O par de prazos do desligamento (#2823) — os dois GRAVADOS juntos porque um
  # sem o outro é o defeito que a issue mediu: `WORKER_STOP_GRACE_PERIOD` é o que
  # o compose passa ao `docker stop` (sem ele, o padrão de 10 s do Docker mata o
  # contêiner antes dos 30 s que o worker se dá) e `SHUTDOWN_GRACE_MS` é o prazo
  # INTERNO, contado do sinal. O de fora tem de ser maior; 40 s para 30 s dá 10 s
  # de folga para fechar pool e healthz. Mexeu num, mexa no outro.
  envq WORKER_STOP_GRACE_PERIOD "${WORKER_STOP_GRACE_PERIOD:-40s}"
  envq SHUTDOWN_GRACE_MS "${SHUTDOWN_GRACE_MS:-30000}"
  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). Ou "cloudflared" (a instalação está\n'
  printf '# atrás de NAT: o Caddy é desligado e o acesso sai pelo Cloudflare Tunnel —\n'
  printf '# entra o docker-compose.cloudflared.yml, e só CLOUDFLARE_TUNNEL_TOKEN é\n'
  printf '# lido nesse modo; a configuração do túnel vive no painel da Cloudflare).\n'
  envq REVERSE_PROXY "$REVERSE_PROXY"
  # Só consumido pelo contêiner `cloudflared` (nunca pelo app), então não entra
  # em lib/env.ts. Gravado SEMPRE (vazio fora do modo cloudflared) para o laço
  # que preserva chave alheia reconhecê-lo e uma re-execução não apagá-lo.
  envq CLOUDFLARE_TUNNEL_TOKEN "${CLOUDFLARE_TUNNEL_TOKEN:-}"
  # 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 '# Gerado pelo install/update uma única vez. NÃO troque: trocar desliga\n'
  printf '# os avisos de todo aparelho já inscrito.\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 par dos avisos com o app fechado (Web Push): gerado aqui uma única vez, pela
# MESMA função que a atualização usa (`gravar_imagens`). Reexecutar o install
# não troca nada: o `load_env` lá em cima trouxe o par, e o `envq` o regravou.
completar_par_vapid .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
}

# ── A pendência do ENVIO de e-mail, quando nada ficou no .env ───────────────
# A issue #1110 registrou a ponta que o #714 deixou visível: o campo da Resend é
# `opcional` desde então, mas quem pulava terminava sem saber que o caminho de
# volta existe. A tela final é a única que a pessoa lê inteira (mesma régua do
# `pendencia_da_ia`), e o aviso diz ONDE configurar e O QUE ainda não sai.
#
# Critério: nenhum remetente DE AMBIENTE — nem a chave da Resend, nem o host do
# SMTP. Quem cadastrou pela tela `/admin/email` (banco, que PREVALECE sobre o
# .env) reconhece o aviso e ignora; o kit não lê o banco aqui.
#
# A topologia muda o que falar: num Supabase PRÓPRIO o GoTrue usa o SMTP do CRM
# para "esqueci a senha" e confirmação de cadastro, então a tela só alcança o
# login por e-mail depois do `update.sh`. Na nuvem, esses e-mails são do
# Supabase e o que falta é só o envio do produto.
pendencia_do_email() {
  [ -n "${RESEND_API_KEY:-}" ] && return 0
  [ -n "${SMTP_HOST:-}" ] && return 0

  cat <<PEND

$(c_ylw "  ─── $(t "O envio de e-mail ainda não funciona") ─────────────────────")

  $(t "Você deixou a chave da Resend para depois e não preencheu um SMTP. O CRM")
  $(t "está no ar; o que não sai é o convite para a equipe e o e-mail com o PDF")
  $(t "de LGPD.")

  $(t "Para ligar, cadastre em Admin → E-mail o servidor SMTP próprio ou o")
  $(t "serviço externo (Resend). O que a tela salva fica CIFRADO no banco e")
  $(t "prevalece sobre o .env.")
PEND

  case "${NEXT_PUBLIC_SUPABASE_URL:-}" in
    https://*.supabase.co*) return 0 ;;
  esac

  cat <<PEND

  $(t "No SEU Supabase, os e-mails de acesso (senha, cadastro) também saem pelo")
  $(t "SMTP do CRM. Depois de salvar na tela, rode:")

      bash hostgator-setup-kit/update.sh
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
# A frase de cima só é verdade com rede de segurança embaixo dela. Worker,
# scheduler e voz têm `build:` ao lado do `image:` e o Compose os constrói em
# QUALQUER falha de pull (medido, não só na de arquitetura); o `app` não tem,
# de propósito (#1060). Se a imagem dele não veio do registro (arquitetura da
# VPS diferente da das imagens publicadas, tag ainda publicando, pacote
# privado, registro fora), o `up -d` morre e a instalação acabava sem CRM no
# ar — e a resposta a qualquer falha era construir.
#
# ── O PORTÃO DO BUILD LOCAL, AQUI TAMBÉM (#1955, #2631) ──────────────────────
# O gatilho continua sendo o CÓDIGO DE SAÍDA do `up -d` (nunca o texto do erro
# — arquitetura, tag publicando, pacote privado e registro fora caem todos no
# mesmo caminho), mas quem DECIDE se constrói é `build_local_permitido`, o
# mesmo portão que o update.sh consultava sozinho. A assimetria é o defeito:
# uma instalação nova com o registro fora do ar construía o `app` na VPS do
# mesmo jeito, e o OOM do `next-build` (#1955) derruba o resto da VPS junto —
# não há serviço no ar para proteger numa instalação, mas há uma memória para
# não gastar.
#
# Quem tem registro respondendo continua se recuperando sozinho: é a
# recuperação de arquitetura do #1060/#1143 e ela não pode sumir. Quem quer
# construir de propósito pede com DESKCOMM_BUILD_LOCAL=1.
#
# Na instalação não há versão anterior para onde voltar (o update.sh devolve a
# anterior e aponta o diagnóstico), então a recusa é UM die com a mensagem da
# instalação — e o que ela DIZ sobre o motivo é separado (#2648): o veredito
# 'indisponivel' que o portão lê junta causas com diagnósticos OPOSTOS (sem
# buildx, registro fora, tag ainda publicando, sonda estourada no prazo), e a
# mesma separação que o preflight do update.sh já fazia agora cabe aqui. Quem
# decide a frase é `motivo_da_recusa_do_build_local` (em _common.sh), na mesma
# ordem em que o portão decide: o operador sem buildx recebe "instale o
# buildx", não "mexa na rede". A saída de propósito continua sendo o
# DESKCOMM_BUILD_LOCAL=1.
CONSTRUIU_AQUI=""
if ! dc up -d; then
  if ! build_local_permitido "$VERSAO_ALVO"; then
    die "$(t "Não coloquei o CRM no ar: {1} A construção local segue fechada por padrão, e numa instalação não há versão anterior para onde voltar. Para construir as imagens aqui DE PROPÓSITO (mais lento, exige memória): {2}" "$(motivo_da_recusa_do_build_local "$VERSAO_ALVO")" "DESKCOMM_BUILD_LOCAL=1 bash hostgator-setup-kit/install.sh --yes")"
  fi
  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

# ── #2945: a PROVA de que a rota pública do Supabase chega ao GoTrue ────────
#
# Aqui, e não antes: é o ponto em que o app já respondeu saudável e o domínio já
# está no ar — a mesma condição que faz a pergunta ter sentido. Antes do #2933
# quem a respondia era, sem querer, o `v_sb_key()` da fase 2 (que batia em
# `${NEXT_PUBLIC_SUPABASE_URL}/auth/v1/settings`, a URL pública, e morria em
# "chave inválida" quando o Traefik sem as etiquetas entregava o pedido ao app);
# o #2933 o mandou perguntar ao gateway INTERNO, que é o certo atrás de
# Cloudflare laranja, e a instalação ficou SEM nenhuma prova da rota pública. O
# #2099 é o defeito que a prova pegava: etiqueta do Traefik que não chegou ao
# Envoy e um login que só falha depois, com a instalação já verde.
#
# O `SUPABASE_INTERNAL_URL` no guard não é enfeite: ele só existe quando o
# Supabase é DESTA VPS (quem o grava é o install-single-server.sh). Num
# Traefik com o Supabase na NUVEM a URL pública tem as APIs em outro host, e
# `/auth/v1/settings` no domínio do CRM responderia 404 — o mesmo 404 de
# "chave inválida" que o #2933 tirou da fase 2. Sem esta condição, a sonda
# reprovaria uma instalação correta, que é pior que não sondar.
#
# A sonda devolve 0 (provado), 1 (a ASSINATURA do app: 404, ou o 3xx para
# /login — a instalação PARA, com o diagnóstico do #2099) ou 2 (não provei, e
# o motivo pode ser só o certificado do domínio ainda saindo). O 2 AVISA e
# SEGUE (#2945): parar uma instalação certa por um `curl` que não provou nada
# é o defeito que o próprio kit consertou no #2933, do outro lado da moeda.
if [ "${REVERSE_PROXY:-caddy}" = "traefik" ] && [ -n "${SUPABASE_INTERNAL_URL:-}" ] && [ "${APP_SAUDAVEL:-0}" = 1 ]; then
  step "$(t "Conferindo a rota pública do Supabase no Traefik")"
  rota_publica_rc=0
  rota_publica_msg="$(sondar_rota_publica_do_supabase "https://${DOMAIN}" "${NEXT_PUBLIC_SUPABASE_ANON_KEY:-}" 10 3)" || rota_publica_rc=$?
  case "$rota_publica_rc" in
    0) c_grn "$(t "✓ a rota pública /auth/v1/* chega ao GoTrue do Supabase (o Traefik está publicando as APIs)")" ;;
    1) die "$rota_publica_msg" ;;
    *) c_ylw "⚠ $rota_publica_msg" ;;
  esac
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
  # `tls_recusado` (lib/net/alcance.ts) = o app não confiou no certificado do
  # PRÓPRIO domínio. Num domínio recém-apontado é o cadeado que ainda não saiu
  # (medido atrás do Coolify em 14/10/2026: o 1º pedido ao Let's Encrypt falhou
  # e o certificado saiu ~3 min depois). Mandar procurar chave no .env aqui é
  # mandar a pessoa consertar o que não está quebrado.
  if printf '%s' "${health_body:-}" | grep -q '"tls_recusado"'; then
    cat <<SEM_CADEADO

$(c_ylw "═══════════════════════════════════════════════════════")
$(c_ylw " $(t "Quase lá — falta o cadeado (HTTPS) do seu domínio")")
$(c_ylw "═══════════════════════════════════════════════════════")

  $(t "A configuração está salva e os contêineres estão no ar. Você NÃO precisa")
  $(t "refazer nada — o certificado de {1} ainda não saiu." "${DOMAIN}")
  $(t "Isso leva alguns minutos depois que o domínio aponta para esta VPS.")

  $(t "Espere alguns minutos e confira (o certo é responder 307):")

       curl -sSI https://${DOMAIN}/app | head -1

  $(t "Quando der 307, rode de novo o mesmo comando de instalação: ele confere")
  $(t "o que falta e termina, sem apagar nada.")

  $(t "Travou? Leve o log para a comunidade — tem gente que já passou por isso:")

       ${COMUNIDADE_URL}

SEM_CADEADO
    trap apaga_temporarios EXIT
    exit 1
  fi
  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 show_recovery: a receita de "apague tudo e recomece" não cabe aqui.
  trap apaga_temporarios 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_do_email)
$(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
